@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.
Files changed (48) hide show
  1. package/CLAUDE.md +96 -32
  2. package/README.md +249 -75
  3. package/package.json +12 -11
  4. package/src/agent-facts.ts +9 -2
  5. package/src/agent-transcript.ts +1 -1
  6. package/src/agent.ts +43 -25
  7. package/src/budget.ts +178 -51
  8. package/src/content-blocks.ts +227 -0
  9. package/src/content-errors.ts +67 -0
  10. package/src/echo-provider.ts +102 -0
  11. package/src/embeddings.ts +40 -21
  12. package/src/error-body.ts +38 -3
  13. package/src/errors.ts +19 -11
  14. package/src/eval-baseline.ts +5 -1
  15. package/src/eval-errors.ts +3 -1
  16. package/src/evals.ts +1 -1
  17. package/src/failure-disclosure.ts +90 -0
  18. package/src/gateway.ts +73 -37
  19. package/src/hive-pool.ts +24 -16
  20. package/src/hive-result.ts +4 -1
  21. package/src/hive.ts +15 -5
  22. package/src/index.ts +66 -25
  23. package/src/llm-cache.ts +4 -3
  24. package/src/llm.ts +27 -65
  25. package/src/model-resolve.ts +79 -0
  26. package/src/models.ts +125 -111
  27. package/src/openai-body.ts +13 -2
  28. package/src/openai-messages.ts +86 -15
  29. package/src/openai-provider.ts +28 -31
  30. package/src/openai-wire.ts +11 -3
  31. package/src/pg-vector-sql.ts +11 -7
  32. package/src/pg-vector.ts +21 -10
  33. package/src/prompt-fence.ts +128 -0
  34. package/src/prompt.ts +43 -13
  35. package/src/provider.ts +82 -117
  36. package/src/rag.ts +17 -9
  37. package/src/remote-embedder.ts +15 -41
  38. package/src/respond.ts +86 -0
  39. package/src/runtime.ts +12 -14
  40. package/src/scorers.ts +14 -3
  41. package/src/tool-idempotency.ts +79 -0
  42. package/src/tools.ts +52 -39
  43. package/src/vector-scope.ts +46 -4
  44. package/src/vector.ts +117 -41
  45. package/src/wire.ts +7 -2
  46. package/src/fix-line.evals.ts +0 -35
  47. package/src/fix-line.ts +0 -27
  48. 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
- BudgetLedger,
25
+ budgetKeysFor,
22
26
  currentBudget,
23
27
  estimateSpend,
24
- MemoryBudgetStore,
28
+ memoryBudgetStore,
25
29
  withBudget,
26
30
  } from './budget';
27
- export type { Embedder, HashEmbedderInput } from './embeddings';
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
- fnv1a,
33
- HashEmbedder,
34
- normalize,
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 { CreateGatewayInput, Gateway, GatewayCache, RetryPolicy } from './gateway';
96
- export { backoffMs, cacheKeyFor, createGateway, DEFAULT_RETRY, isRetryable } from './gateway';
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 { Effort, ModelId, ModelReasoning, ModelSpec, ThinkingMode } from './models';
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 { PgVectorStoreInput } from './pg-vector';
123
- export { PgVectorStore } from './pg-vector';
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
- AnthropicProvider,
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 { assembleContext, chunk, indexDocument, passthroughReranker, retrieve } from './rag';
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 { RemoteEmbedder } from './remote-embedder';
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, MemoryVectorStore } from './vector';
261
+ export { fuse, memoryVectorStore } from './vector';
221
262
  export type { VectorScope } from './vector-scope';
222
- export { NO_TENANT, tenantOf, UNSCOPED } from './vector-scope';
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, fnv1a } from './embeddings';
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 (`createContext` defaults it to `anonymousActor()`), so every
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}:${fnv1a(args.rendered).toString(16)}`,
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.tool()`, `.openapi()`, `.client()`, `.job()` and `.contract()` all exist
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, `.tool()` — is lost the moment a feature has to reach past
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, toMcpInputSchema, validateAsync } from '@ultimat3/schema';
30
+ import { formatIssues, validateAsync } from '@ultimat3/schema';
31
31
  import type { BudgetLimits } from './budget';
32
- import { BudgetLedger, currentBudget, withBudget } from './budget';
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 { DEFAULT_MODEL, moreCapableThan } from './models';
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 — `.tool()`,
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 = respondToolFor(def.output);
142
- // Screened at DECLARATION, beside `respondToolFor`'s own refusal and for the same reason: an
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: LlmTool,
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
- const model = def.model ?? prompt.model ?? DEFAULT_MODEL;
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
- const ledger = (
263
- currentBudget() ??
264
- gateway.callLedger?.() ??
265
- new BudgetLedger({ limits: {} })
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, structuredOutputOf(result));
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 ? {} : { costPerCall: budget.costPerCall }),
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
+ }