@ultimat3/ai 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +96 -32
- package/README.md +249 -75
- package/package.json +12 -11
- package/src/agent-facts.ts +9 -2
- package/src/agent-transcript.ts +1 -1
- package/src/agent.ts +43 -25
- package/src/budget.ts +178 -51
- package/src/content-blocks.ts +227 -0
- package/src/content-errors.ts +67 -0
- package/src/echo-provider.ts +102 -0
- package/src/embeddings.ts +40 -21
- package/src/error-body.ts +38 -3
- package/src/errors.ts +19 -11
- package/src/eval-baseline.ts +5 -1
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +73 -37
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +15 -5
- package/src/index.ts +66 -25
- package/src/llm-cache.ts +4 -3
- package/src/llm.ts +27 -65
- package/src/model-resolve.ts +79 -0
- package/src/models.ts +125 -111
- package/src/openai-body.ts +13 -2
- package/src/openai-messages.ts +86 -15
- package/src/openai-provider.ts +28 -31
- package/src/openai-wire.ts +11 -3
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +21 -10
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +82 -117
- package/src/rag.ts +17 -9
- package/src/remote-embedder.ts +15 -41
- package/src/respond.ts +86 -0
- package/src/runtime.ts +12 -14
- package/src/scorers.ts +14 -3
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +52 -39
- package/src/vector-scope.ts +46 -4
- package/src/vector.ts +117 -41
- package/src/wire.ts +7 -2
- package/src/fix-line.evals.ts +0 -35
- package/src/fix-line.ts +0 -27
- package/src/openai-models.ts +0 -84
package/src/index.ts
CHANGED
|
@@ -11,28 +11,50 @@ export { describeAgents, resetAgents } from './agent-facts';
|
|
|
11
11
|
export type { AgentJobOptions } from './agent-job';
|
|
12
12
|
export { agentJob } from './agent-job';
|
|
13
13
|
export type {
|
|
14
|
+
BudgetKeys,
|
|
15
|
+
BudgetLedger,
|
|
14
16
|
BudgetLedgerInput,
|
|
15
17
|
BudgetLimits,
|
|
16
18
|
BudgetReport,
|
|
17
19
|
BudgetStore,
|
|
20
|
+
BudgetTake,
|
|
21
|
+
MemoryBudgetStore,
|
|
18
22
|
SpendEstimate,
|
|
19
23
|
} from './budget';
|
|
20
24
|
export {
|
|
21
|
-
|
|
25
|
+
budgetKeysFor,
|
|
22
26
|
currentBudget,
|
|
23
27
|
estimateSpend,
|
|
24
|
-
|
|
28
|
+
memoryBudgetStore,
|
|
25
29
|
withBudget,
|
|
26
30
|
} from './budget';
|
|
27
|
-
export type {
|
|
31
|
+
export type {
|
|
32
|
+
AiDocumentBlock,
|
|
33
|
+
AiDocumentSource,
|
|
34
|
+
AiImageBlock,
|
|
35
|
+
AiImageSource,
|
|
36
|
+
AiMediaBlock,
|
|
37
|
+
ImageMediaType,
|
|
38
|
+
} from './content-blocks';
|
|
39
|
+
export {
|
|
40
|
+
IMAGE_MEDIA_TYPES,
|
|
41
|
+
IMAGE_TOKEN_ESTIMATE,
|
|
42
|
+
isMediaBlock,
|
|
43
|
+
MAX_DOCUMENT_BASE64_CHARS,
|
|
44
|
+
MAX_IMAGE_BASE64_CHARS,
|
|
45
|
+
} from './content-blocks';
|
|
46
|
+
export type { ContentRefusal } from './content-errors';
|
|
47
|
+
export { AiContentUnsupportedError } from './content-errors';
|
|
48
|
+
export type { EchoProvider, EchoProviderInput } from './echo-provider';
|
|
49
|
+
export { echoProvider } from './echo-provider';
|
|
50
|
+
export type { Embedder, HashEmbedder, HashEmbedderInput } from './embeddings';
|
|
28
51
|
export {
|
|
29
52
|
cosine,
|
|
30
53
|
embedBatched,
|
|
31
54
|
embedOne,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
tokenize,
|
|
55
|
+
hashEmbedder,
|
|
56
|
+
normalizeVector,
|
|
57
|
+
wordTokens,
|
|
36
58
|
} from './embeddings';
|
|
37
59
|
export type { AiErrorCode } from './errors';
|
|
38
60
|
export {
|
|
@@ -92,8 +114,14 @@ export {
|
|
|
92
114
|
resetEvals,
|
|
93
115
|
} from './evals';
|
|
94
116
|
export type { AiFetch } from './fetch-seam';
|
|
95
|
-
export type {
|
|
96
|
-
export {
|
|
117
|
+
export type { Gateway, GatewayCache, ProviderGatewayInput, RetryPolicy } from './gateway';
|
|
118
|
+
export {
|
|
119
|
+
backoffMs,
|
|
120
|
+
DEFAULT_GATEWAY_RETRY,
|
|
121
|
+
isRetryable,
|
|
122
|
+
promptCacheKey,
|
|
123
|
+
providerGateway,
|
|
124
|
+
} from './gateway';
|
|
97
125
|
export type { HiveDef, HiveSplitArgs } from './hive';
|
|
98
126
|
export { hive } from './hive';
|
|
99
127
|
export { HiveEmptyError } from './hive-errors';
|
|
@@ -102,11 +130,19 @@ export type { LlmAction, LlmBudget, LlmDef, LlmVarsArgs } from './llm';
|
|
|
102
130
|
export { llm } from './llm';
|
|
103
131
|
export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
|
|
104
132
|
export type { LlmStreamChunk } from './llm-stream';
|
|
105
|
-
export type {
|
|
133
|
+
export type { ModelSource } from './model-resolve';
|
|
134
|
+
export { AiModelUnresolvedError } from './model-resolve';
|
|
135
|
+
export type {
|
|
136
|
+
ContentKind,
|
|
137
|
+
DeclaredReasoning,
|
|
138
|
+
Effort,
|
|
139
|
+
ModelId,
|
|
140
|
+
ModelReasoning,
|
|
141
|
+
ModelSpec,
|
|
142
|
+
ThinkingMode,
|
|
143
|
+
} from './models';
|
|
106
144
|
export {
|
|
107
|
-
ANTHROPIC_MODEL_IDS,
|
|
108
145
|
assertModel,
|
|
109
|
-
DEFAULT_MODEL,
|
|
110
146
|
EFFORTS,
|
|
111
147
|
isModelRegistered,
|
|
112
148
|
modelIds,
|
|
@@ -116,11 +152,10 @@ export {
|
|
|
116
152
|
registerModel,
|
|
117
153
|
resetModels,
|
|
118
154
|
} from './models';
|
|
119
|
-
export { OPENAI_MODEL_IDS, registerOpenAiModels } from './openai-models';
|
|
120
155
|
export type { OpenAiProviderInput } from './openai-provider';
|
|
121
156
|
export { openAiProvider } from './openai-provider';
|
|
122
|
-
export type {
|
|
123
|
-
export {
|
|
157
|
+
export type { PostgresVectorStore, PostgresVectorStoreInput } from './pg-vector';
|
|
158
|
+
export { postgresVectorStore } from './pg-vector';
|
|
124
159
|
export type {
|
|
125
160
|
PgHybridArgs,
|
|
126
161
|
PgSearchArgs,
|
|
@@ -135,18 +170,19 @@ export {
|
|
|
135
170
|
} from './pg-vector-sql';
|
|
136
171
|
export type { DefinePromptInput, Prompt, PromptVars } from './prompt';
|
|
137
172
|
export {
|
|
138
|
-
contentHash,
|
|
139
173
|
definePrompt,
|
|
140
174
|
describePrompts,
|
|
141
175
|
getPrompt,
|
|
176
|
+
promptHash,
|
|
142
177
|
promptVersions,
|
|
143
178
|
resetPrompts,
|
|
144
179
|
} from './prompt';
|
|
180
|
+
export { promptFences } from './prompt-fence';
|
|
145
181
|
export type {
|
|
146
182
|
AiContentBlock,
|
|
147
183
|
AiMessage,
|
|
184
|
+
AnthropicProvider,
|
|
148
185
|
AnthropicProviderInput,
|
|
149
|
-
EchoProviderInput,
|
|
150
186
|
GenerateRequest,
|
|
151
187
|
GenerateResult,
|
|
152
188
|
Provider,
|
|
@@ -156,9 +192,8 @@ export type {
|
|
|
156
192
|
TokenUsage,
|
|
157
193
|
} from './provider';
|
|
158
194
|
export {
|
|
159
|
-
|
|
195
|
+
anthropicProvider,
|
|
160
196
|
costOf,
|
|
161
|
-
EchoProvider,
|
|
162
197
|
estimateCost,
|
|
163
198
|
estimateInputTokens,
|
|
164
199
|
estimateTextTokens,
|
|
@@ -177,17 +212,22 @@ export type {
|
|
|
177
212
|
Reranker,
|
|
178
213
|
RetrieveInput,
|
|
179
214
|
} from './rag';
|
|
180
|
-
export {
|
|
215
|
+
export {
|
|
216
|
+
assembleContext,
|
|
217
|
+
chunkDocument,
|
|
218
|
+
indexDocument,
|
|
219
|
+
passthroughReranker,
|
|
220
|
+
retrieve,
|
|
221
|
+
} from './rag';
|
|
181
222
|
export { assertNoSecrets } from './redaction';
|
|
182
|
-
export type { RemoteEmbedderInput } from './remote-embedder';
|
|
183
|
-
export {
|
|
223
|
+
export type { RemoteEmbedder, RemoteEmbedderInput } from './remote-embedder';
|
|
224
|
+
export { remoteEmbedder } from './remote-embedder';
|
|
184
225
|
export type { AiRuntimeInput, Redactor } from './runtime';
|
|
185
226
|
export {
|
|
186
227
|
aiEmbedder,
|
|
187
228
|
aiGateway,
|
|
188
229
|
aiRedactor,
|
|
189
230
|
configureAi,
|
|
190
|
-
MAX_SEMANTIC_CACHE_SCOPES,
|
|
191
231
|
semanticCacheFor,
|
|
192
232
|
} from './runtime';
|
|
193
233
|
export type { Scorer } from './scorers';
|
|
@@ -210,6 +250,7 @@ export type {
|
|
|
210
250
|
export { asProjectableAction, runLlmToolCall, toLlmTool, toLlmTools } from './tools';
|
|
211
251
|
export type {
|
|
212
252
|
HybridSearchInput,
|
|
253
|
+
MemoryVectorStore,
|
|
213
254
|
MemoryVectorStoreInput,
|
|
214
255
|
MetadataFilter,
|
|
215
256
|
SearchHit,
|
|
@@ -217,7 +258,7 @@ export type {
|
|
|
217
258
|
VectorRecord,
|
|
218
259
|
VectorStore,
|
|
219
260
|
} from './vector';
|
|
220
|
-
export { fuse,
|
|
261
|
+
export { fuse, memoryVectorStore } from './vector';
|
|
221
262
|
export type { VectorScope } from './vector-scope';
|
|
222
|
-
export {
|
|
263
|
+
export { NO_VECTOR_TENANT, tenantOf, UNSCOPED, VectorUnscopedError } from './vector-scope';
|
|
223
264
|
export type { StreamState } from './wire';
|
package/src/llm-cache.ts
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { Ctx } from '@ultimat3/core';
|
|
19
|
+
import { fingerprint } from '@ultimat3/core';
|
|
19
20
|
import { parseDuration } from '@ultimat3/time';
|
|
20
|
-
import { embedOne
|
|
21
|
+
import { embedOne } from './embeddings';
|
|
21
22
|
import { aiEmbedder, semanticCacheFor } from './runtime';
|
|
22
23
|
|
|
23
24
|
/** What a `scope` may decide from. The same pair `vars()` receives, and for the same reason. */
|
|
@@ -73,7 +74,7 @@ export interface PromptCache {
|
|
|
73
74
|
* writing its own `scope`, and the locale has to survive that — so it lives in the unconditional
|
|
74
75
|
* half of the store key, beside the prompt hash.
|
|
75
76
|
*
|
|
76
|
-
* `ctx.actor` is never absent (`
|
|
77
|
+
* `ctx.actor` is never absent (`ctxOf` defaults it to `anonymousActor()`), so every
|
|
77
78
|
* anonymous caller shares one partition — which is what the anonymous actor already means
|
|
78
79
|
* everywhere else in the framework.
|
|
79
80
|
*/
|
|
@@ -119,7 +120,7 @@ export async function openCache<TParsed>(
|
|
|
119
120
|
},
|
|
120
121
|
remember(value: unknown): Promise<void> {
|
|
121
122
|
return store.remember(
|
|
122
|
-
`${args.prompt.hash}:${
|
|
123
|
+
`${args.prompt.hash}:${fingerprint(args.rendered)}`,
|
|
123
124
|
embedding,
|
|
124
125
|
value,
|
|
125
126
|
ttlMs === undefined ? {} : { ttlMs },
|
package/src/llm.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The eight primitives are the whole vocabulary. A model call is a server-authoritative
|
|
5
5
|
* operation with an input schema, an output schema and a policy, which is the definition of
|
|
6
6
|
* an `action` — so this file is a FACTORY over `action()`, never a new kind of thing. That is
|
|
7
|
-
* why `summarize.
|
|
7
|
+
* why `summarize.openapi()`, `.client()`, `.job()`, `.contract()` and its MCP tool all exist
|
|
8
8
|
* without a line here: the value is an action, so it projects like one, and an app gains an
|
|
9
9
|
* MCP tool backed by a model the moment it declares one.
|
|
10
10
|
*
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* `.stream()` is the same action over a different transport, and it is here rather than beside
|
|
16
16
|
* the gateway for one reason: everything that makes `llm()` worth using — policy, input parse,
|
|
17
|
-
* budget scope, semantic cache, span,
|
|
17
|
+
* budget scope, semantic cache, span, MCP tool — is lost the moment a feature has to reach past
|
|
18
18
|
* it to `aiGateway()` for tokens on a screen. `./llm-stream.ts` holds the plumbing and states the
|
|
19
19
|
* two decisions a stream forces (no repair turn, budget reserved exactly as before); what a
|
|
20
20
|
* streamed answer must satisfy is decided in this file, next to the non-streaming version of the
|
|
@@ -27,9 +27,9 @@ import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
|
|
|
27
27
|
import { finiteCount, withSpan } from '@ultimat3/core';
|
|
28
28
|
import type { Money } from '@ultimat3/money';
|
|
29
29
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
30
|
-
import { formatIssues,
|
|
30
|
+
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
31
31
|
import type { BudgetLimits } from './budget';
|
|
32
|
-
import {
|
|
32
|
+
import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
|
|
33
33
|
import {
|
|
34
34
|
LlmOutputInvalidError,
|
|
35
35
|
LlmRefusedError,
|
|
@@ -41,21 +41,16 @@ import type { LlmCache } from './llm-cache';
|
|
|
41
41
|
import { openCache } from './llm-cache';
|
|
42
42
|
import type { LlmSink, LlmStreamChunk } from './llm-stream';
|
|
43
43
|
import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
|
|
44
|
+
import { modelForCall } from './model-resolve';
|
|
44
45
|
import type { ModelId } from './models';
|
|
45
|
-
import {
|
|
46
|
+
import { moreCapableThan } from './models';
|
|
46
47
|
import type { Prompt, PromptVars } from './prompt';
|
|
47
48
|
import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
|
|
48
49
|
import { isTruncated } from './provider';
|
|
49
50
|
import { assertNoSecrets } from './redaction';
|
|
51
|
+
import type { Respond } from './respond';
|
|
52
|
+
import { parseJsonish, RESPOND, respondFor } from './respond';
|
|
50
53
|
import { aiGateway, aiRedactor } from './runtime';
|
|
51
|
-
import type { LlmTool } from './tools';
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The tool the model answers through. One name, so the reader never has to guess — and shared
|
|
55
|
-
* with `agent()`, which offers the app's tools alongside it and needs the same name to tell an
|
|
56
|
-
* answer from a tool call.
|
|
57
|
-
*/
|
|
58
|
-
export const RESPOND = 'respond';
|
|
59
54
|
|
|
60
55
|
/** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
|
|
61
56
|
const ATTEMPTS = 2;
|
|
@@ -107,7 +102,7 @@ export interface LlmDef<
|
|
|
107
102
|
}
|
|
108
103
|
|
|
109
104
|
/**
|
|
110
|
-
* An `action` with one extra way to be called. Every projection an action has, it has —
|
|
105
|
+
* An `action` with one extra way to be called. Every projection an action has, it has — its MCP tool,
|
|
111
106
|
* `.openapi()`, `.client()`, `.job()`, `.contract()` — plus a transport for the case an action's
|
|
112
107
|
* single return value cannot serve: text on a screen before the answer is finished.
|
|
113
108
|
*/
|
|
@@ -138,8 +133,8 @@ export function llm<
|
|
|
138
133
|
TOutput extends StandardSchemaV1,
|
|
139
134
|
V extends PromptVars,
|
|
140
135
|
>(def: LlmDef<TInput, TOutput, V>): LlmAction<TInput, TOutput> {
|
|
141
|
-
const respond =
|
|
142
|
-
// Screened at DECLARATION, beside `
|
|
136
|
+
const respond = respondFor(def.output);
|
|
137
|
+
// Screened at DECLARATION, beside `respondFor`'s own refusal and for the same reason: an
|
|
143
138
|
// `llm()` is evaluated at module scope, so a bound that is not one fails the boot rather than
|
|
144
139
|
// the first request. It is the read at `generate` below that makes it urgent — `maxTokens`
|
|
145
140
|
// becomes the pre-flight ESTIMATE, a `NaN` estimate passes every `want > remaining` check, and
|
|
@@ -190,14 +185,17 @@ async function generate<
|
|
|
190
185
|
V extends PromptVars,
|
|
191
186
|
>(
|
|
192
187
|
def: LlmDef<TInput, TOutput, V>,
|
|
193
|
-
respond:
|
|
188
|
+
respond: Respond,
|
|
194
189
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
195
190
|
): Promise<InferOutput<TOutput>> {
|
|
196
191
|
const { prompt } = def;
|
|
197
192
|
// The prompt ref is the identity every failure here is about, and unlike the action's
|
|
198
193
|
// export name it exists before registration and can never twin.
|
|
199
194
|
const name = prompt.ref;
|
|
200
|
-
|
|
195
|
+
// Screened first: a bad declaration is refused whether or not a gateway exists.
|
|
196
|
+
const limits = limitsOf(def.budget);
|
|
197
|
+
// The declaration, its prompt, then the gateway's `defaultModel` — the app's three places.
|
|
198
|
+
const model = modelForCall('llm', name, def.model, prompt.model);
|
|
201
199
|
// `vars()` is the one declared place a model call loads data, so it is the one place the
|
|
202
200
|
// framework can refuse a `Secret` and the one place an app's redactor can see the row before it
|
|
203
201
|
// leaves the process. Both run here, between the load and the request, and neither is optional
|
|
@@ -250,20 +248,17 @@ async function generate<
|
|
|
250
248
|
// the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
|
|
251
249
|
signal: args.ctx.signal,
|
|
252
250
|
};
|
|
253
|
-
const request: GenerateRequest = { ...base, tools: [respond] };
|
|
251
|
+
const request: GenerateRequest = { ...base, tools: [respond.tool] };
|
|
254
252
|
|
|
255
253
|
// A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
|
|
256
254
|
// and org ceilings this call runs inside, never widen them. The gateway reserves against
|
|
257
255
|
// it before the provider is touched — that is where `X_AI_BUDGET_EXCEEDED` comes from.
|
|
258
|
-
// Screened first, as before: a bad declaration is refused whether or not a gateway exists.
|
|
259
|
-
const limits = limitsOf(def.budget);
|
|
260
256
|
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
261
257
|
const gateway = aiGateway(name);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
).derive(limits);
|
|
258
|
+
// Keyed on the CALLER, so the gateway's `actor` / `org` ceilings count this call against them.
|
|
259
|
+
const ledger = (currentBudget() ?? gateway.callLedger(budgetKeysFor(args.ctx.actor))).derive(
|
|
260
|
+
limits,
|
|
261
|
+
);
|
|
267
262
|
const sink = currentLlmSink();
|
|
268
263
|
|
|
269
264
|
return withBudget(ledger, async () => {
|
|
@@ -287,12 +282,12 @@ async function generate<
|
|
|
287
282
|
// The fix names a model the caller can paste, and only ever a MORE capable one:
|
|
288
283
|
// "the first id that differs" answered a refusal on the default model with the next
|
|
289
284
|
// entry down the ladder, which is a retry that cannot succeed.
|
|
290
|
-
alternative: moreCapableThan(result.model),
|
|
285
|
+
alternative: moreCapableThan(result.model, request),
|
|
291
286
|
category: result.stopDetails?.category,
|
|
292
287
|
explanation: result.stopDetails?.explanation,
|
|
293
288
|
});
|
|
294
289
|
}
|
|
295
|
-
const parsed = await validateAsync(def.output,
|
|
290
|
+
const parsed = await validateAsync(def.output, respond.read(result));
|
|
296
291
|
if (parsed.issues === undefined) {
|
|
297
292
|
await cache?.remember(parsed.value);
|
|
298
293
|
return parsed.value;
|
|
@@ -353,7 +348,7 @@ async function streamedAnswer<TOutput extends StandardSchemaV1>(
|
|
|
353
348
|
throw new LlmRefusedError({
|
|
354
349
|
prompt: name,
|
|
355
350
|
model: result.model,
|
|
356
|
-
alternative: moreCapableThan(result.model),
|
|
351
|
+
alternative: moreCapableThan(result.model, request),
|
|
357
352
|
category: result.stopDetails?.category,
|
|
358
353
|
explanation: result.stopDetails?.explanation,
|
|
359
354
|
});
|
|
@@ -399,22 +394,9 @@ function limitsOf(budget: LlmBudget | undefined): BudgetLimits {
|
|
|
399
394
|
...(budget?.tokensIn === undefined
|
|
400
395
|
? {}
|
|
401
396
|
: { tokensIn: finiteCount('llm', 'budget.tokensIn', budget.tokensIn) }),
|
|
402
|
-
...(budget?.costPerCall === undefined
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* The output schema as the only tool the model may answer through — the spec's "structured
|
|
408
|
-
* output drives tool use". `toMcpInputSchema` is the same projection an MCP client sees, so
|
|
409
|
-
* a model and an agent are shown one shape, and a schema it cannot express throws HERE, at
|
|
410
|
-
* declaration time, rather than degrading into a permissive node the model cannot satisfy.
|
|
411
|
-
*/
|
|
412
|
-
export function respondToolFor(output: StandardSchemaV1): LlmTool {
|
|
413
|
-
return {
|
|
414
|
-
name: RESPOND,
|
|
415
|
-
description: 'Return the result. Call this exactly once; do not answer in prose.',
|
|
416
|
-
input_schema: toMcpInputSchema(output),
|
|
417
|
-
strict: true,
|
|
397
|
+
...(budget?.costPerCall === undefined
|
|
398
|
+
? {}
|
|
399
|
+
: { costPerCall: screenedMoney('llm', 'budget.costPerCall', budget.costPerCall) }),
|
|
418
400
|
};
|
|
419
401
|
}
|
|
420
402
|
|
|
@@ -436,23 +418,3 @@ function assistantEcho(result: GenerateResult): string | undefined {
|
|
|
436
418
|
const replayed = JSON.stringify(call.input);
|
|
437
419
|
return replayed === undefined || replayed === '' ? undefined : replayed;
|
|
438
420
|
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* The tool call if the model made one, otherwise the text parsed as JSON — a model that
|
|
442
|
-
* answers in prose is a schema failure, not a crash, so it flows into the repair turn.
|
|
443
|
-
*/
|
|
444
|
-
export function structuredOutputOf(result: GenerateResult): unknown {
|
|
445
|
-
const call = result.toolCalls.find((c) => c.name === RESPOND);
|
|
446
|
-
if (call !== undefined) return call.input;
|
|
447
|
-
return parseJsonish(result.text);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function parseJsonish(text: string): unknown {
|
|
451
|
-
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
452
|
-
const body = (fenced?.[1] ?? text).trim();
|
|
453
|
-
try {
|
|
454
|
-
return JSON.parse(body);
|
|
455
|
-
} catch {
|
|
456
|
-
return undefined;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Which model a call runs on, decided in ONE place. The app names it in one of three places — the
|
|
2
|
+
// declaration's `model`, its prompt's, `providerGateway({ defaultModel })` — and the framework has
|
|
3
|
+
// no fourth: a call that names none is refused here, naming all three, never routed to a vendor.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { ModelId } from './models';
|
|
7
|
+
import { aiGateway } from './runtime';
|
|
8
|
+
|
|
9
|
+
/** Where a model was resolved. Bounded, and the first word of the refusal's cause. */
|
|
10
|
+
export type ModelSite = 'llm' | 'agent' | 'gateway' | 'provider' | 'echo-provider';
|
|
11
|
+
|
|
12
|
+
/** The three places, spelled once so the refusal and the docs cannot drift apart. */
|
|
13
|
+
const PLACES =
|
|
14
|
+
'model: modelId on the llm()/agent() declaration, model: modelId in its definePrompt, or providerGateway({ …, defaultModel: modelId })';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The first declared candidate, in the caller's precedence order (a declaration, then its prompt,
|
|
18
|
+
* then the gateway's `defaultModel`). None declared is `X_AI_MODEL_UNRESOLVED`: which model, and so
|
|
19
|
+
* which vendor, is the app's call and never the framework's.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveModel(
|
|
22
|
+
site: ModelSite,
|
|
23
|
+
...candidates: readonly (ModelId | undefined)[]
|
|
24
|
+
): ModelId {
|
|
25
|
+
for (const candidate of candidates) if (candidate !== undefined) return candidate;
|
|
26
|
+
throw new AiModelUnresolvedError(site);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A call that named no model: its own code, because the fix is a declaration, not a request. */
|
|
30
|
+
export class AiModelUnresolvedError extends UltimateError {
|
|
31
|
+
constructor(site: ModelSite) {
|
|
32
|
+
super({
|
|
33
|
+
code: 'X_AI_MODEL_UNRESOLVED',
|
|
34
|
+
cause: `${site} resolved no model: no declaration, prompt or providerGateway({ defaultModel }) named one, and the framework chooses none (modelId: an id your app registerModel-ed and a configured provider lists)`,
|
|
35
|
+
fix: `${PLACES} # one of the three`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The model an `llm()` or `agent()` call runs on. The gateway is asked only when neither the
|
|
42
|
+
* declaration nor its prompt names one — and with none installed that is `X_AI_GATEWAY_MISSING`
|
|
43
|
+
* for `prompt`, because the third place does not exist yet: the boot call is the whole fix.
|
|
44
|
+
*/
|
|
45
|
+
export function modelForCall(
|
|
46
|
+
site: 'llm' | 'agent',
|
|
47
|
+
prompt: string,
|
|
48
|
+
declared: ModelId | undefined,
|
|
49
|
+
promptModel: ModelId | undefined,
|
|
50
|
+
): ModelId {
|
|
51
|
+
if (declared !== undefined || promptModel !== undefined) {
|
|
52
|
+
return resolveModel(site, declared, promptModel);
|
|
53
|
+
}
|
|
54
|
+
return resolveModel(site, aiGateway(prompt).defaultModel);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Which of the app's three places a model came from. */
|
|
58
|
+
export type ModelSource = 'declaration' | 'prompt' | 'gateway';
|
|
59
|
+
|
|
60
|
+
const SOURCES: readonly ModelSource[] = ['declaration', 'prompt', 'gateway'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same answer as `resolveModel`, over the same three candidates in the same order, WITHOUT
|
|
64
|
+
* refusing — and with where it came from. For a fact published about a declaration
|
|
65
|
+
* (`describeAgents()`): a reader describing an app is not a call, so a declaration that names no
|
|
66
|
+
* model yet (the gateway is installed later) is described as `undefined`, never thrown over.
|
|
67
|
+
*/
|
|
68
|
+
export function describedModel(
|
|
69
|
+
declared: ModelId | undefined,
|
|
70
|
+
prompt: ModelId | undefined,
|
|
71
|
+
gateway: ModelId | undefined,
|
|
72
|
+
): { readonly model: ModelId | undefined; readonly from: ModelSource | undefined } {
|
|
73
|
+
const candidates = [declared, prompt, gateway];
|
|
74
|
+
for (const [at, candidate] of candidates.entries()) {
|
|
75
|
+
const from = SOURCES[at];
|
|
76
|
+
if (candidate !== undefined && from !== undefined) return { model: candidate, from };
|
|
77
|
+
}
|
|
78
|
+
return { model: undefined, from: undefined };
|
|
79
|
+
}
|