@ultimat3/ai 1.2.0 → 2.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/src/llm.ts CHANGED
@@ -11,29 +11,50 @@
11
11
  * What the factory adds is the model half: the prompt is rendered from the parsed input, the
12
12
  * `output` schema is projected into a tool the model must answer through, a per-call budget is
13
13
  * reserved before a token is spent, and a near-duplicate prompt hits the semantic cache.
14
+ *
15
+ * `.stream()` is the same action over a different transport, and it is here rather than beside
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
18
+ * it to `aiGateway()` for tokens on a screen. `./llm-stream.ts` holds the plumbing and states the
19
+ * two decisions a stream forces (no repair turn, budget reserved exactly as before); what a
20
+ * streamed answer must satisfy is decided in this file, next to the non-streaming version of the
21
+ * same rules.
14
22
  */
15
23
 
16
- import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
24
+ import type { Action, ActionMcp, ActionPolicy, InvokeOptions } from '@ultimat3/action';
17
25
  import { action } from '@ultimat3/action';
18
- import type { Ctx } from '@ultimat3/core';
26
+ import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
19
27
  import { withSpan } from '@ultimat3/core';
20
28
  import type { Money } from '@ultimat3/money';
21
- import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
29
+ import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
22
30
  import { formatIssues, toMcpInputSchema, validateAsync } from '@ultimat3/schema';
23
31
  import { parseDuration } from '@ultimat3/time';
24
32
  import type { BudgetLimits } from './budget';
25
33
  import { BudgetLedger, currentBudget, withBudget } from './budget';
26
34
  import { embedOne, fnv1a } from './embeddings';
27
- import { LlmOutputInvalidError, LlmRefusedError, LlmTruncatedError } from './errors';
35
+ import {
36
+ LlmOutputInvalidError,
37
+ LlmRefusedError,
38
+ LlmStreamInvalidError,
39
+ LlmTruncatedError,
40
+ } from './errors';
41
+ import type { Gateway } from './gateway';
42
+ import type { LlmSink, LlmStreamChunk } from './llm-stream';
43
+ import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
28
44
  import type { ModelId } from './models';
29
- import { DEFAULT_MODEL, MODEL_IDS } from './models';
45
+ import { DEFAULT_MODEL, moreCapableThan } from './models';
30
46
  import type { Prompt, PromptVars } from './prompt';
31
47
  import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
32
- import { aiEmbedder, aiGateway, semanticCacheFor } from './runtime';
48
+ import { assertNoSecrets } from './redaction';
49
+ import { aiEmbedder, aiGateway, aiRedactor, semanticCacheFor } from './runtime';
33
50
  import type { LlmTool } from './tools';
34
51
 
35
- /** The tool the model answers through. One name, so the reader never has to guess. */
36
- const RESPOND = 'respond';
52
+ /**
53
+ * The tool the model answers through. One name, so the reader never has to guess — and shared
54
+ * with `agent()`, which offers the app's tools alongside it and needs the same name to tell an
55
+ * answer from a tool call.
56
+ */
57
+ export const RESPOND = 'respond';
37
58
 
38
59
  /** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
39
60
  const ATTEMPTS = 2;
@@ -105,19 +126,73 @@ export interface LlmDef<
105
126
  readonly maxTokens?: number;
106
127
  }
107
128
 
129
+ /**
130
+ * An `action` with one extra way to be called. Every projection an action has, it has — `.tool()`,
131
+ * `.openapi()`, `.client()`, `.job()`, `.contract()` — plus a transport for the case an action's
132
+ * single return value cannot serve: text on a screen before the answer is finished.
133
+ */
134
+ export interface LlmAction<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
135
+ extends Action<TInput, TOutput> {
136
+ /**
137
+ * The same call, delivered as it arrives. Runs the action's policy, input parse, budget scope,
138
+ * semantic cache and span exactly as calling it would — the invocation IS an ordinary one,
139
+ * marked so the model half streams. Yields `text` and `thinking` increments, then one `done`
140
+ * carrying the value that satisfied `output`.
141
+ *
142
+ * Lazy: nothing is authorised or sent until the first pull. A streamed call offers the model no
143
+ * `respond` tool — a tool call arrives whole, so forcing one leaves nothing to stream — which
144
+ * means the answer is prose, and its JSON parse is what a non-string `output` validates.
145
+ * Abandoning the iterator stops delivery, never the call: the budget reservation is reconciled
146
+ * by the chain that opened it.
147
+ */
148
+ stream(
149
+ input: InferInput<TInput>,
150
+ opts?: InvokeOptions,
151
+ ): AsyncIterable<LlmStreamChunk<InferOutput<TOutput>>>;
152
+ /** Narrowed: a renamed twin of a model call is still a model call, and still streams. */
153
+ named(name: string): LlmAction<TInput, TOutput>;
154
+ }
155
+
108
156
  export function llm<
109
157
  TInput extends StandardSchemaV1,
110
158
  TOutput extends StandardSchemaV1,
111
159
  V extends PromptVars,
112
- >(def: LlmDef<TInput, TOutput, V>): Action<TInput, TOutput> {
160
+ >(def: LlmDef<TInput, TOutput, V>): LlmAction<TInput, TOutput> {
113
161
  const respond = respondToolFor(def.output);
114
- return action<TInput, TOutput>({
162
+ const built = action<TInput, TOutput>({
115
163
  input: def.input,
116
164
  output: def.output,
117
165
  policy: def.policy,
118
166
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
119
167
  handle: (args) => generate(def, respond, args),
120
168
  });
169
+ return streamable(built);
170
+ }
171
+
172
+ /**
173
+ * Attach `.stream()` to an action IN PLACE, rather than wrapping it. One object is the point:
174
+ * `nameAction` stamps a name onto the very object an app exported, `invoke` reads the declaration
175
+ * off it, and a wrapper would leave a second action the registry never saw.
176
+ *
177
+ * `named()` is re-narrowed for the same reason. `action()`'s `named` builds a fresh twin, which
178
+ * would silently be a model call that cannot stream — so the twin is passed back through here.
179
+ * The original is captured first, or the override would call itself.
180
+ */
181
+ function streamable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
182
+ target: Action<TInput, TOutput>,
183
+ ): LlmAction<TInput, TOutput> {
184
+ const rename = target.named.bind(target);
185
+ const self: LlmAction<TInput, TOutput> = Object.assign(target, {
186
+ stream: (
187
+ input: InferInput<TInput>,
188
+ opts?: InvokeOptions,
189
+ ): AsyncIterable<LlmStreamChunk<InferOutput<TOutput>>> =>
190
+ // `self`, not `target`: the invocation has to be of the object that carries the name the
191
+ // audit record, the rate-limit key and the span are filed under.
192
+ llmStream<InferOutput<TOutput>>((sink) => withLlmSink(sink, () => self(input, opts ?? {}))),
193
+ named: (next: string): LlmAction<TInput, TOutput> => streamable(rename(next)),
194
+ });
195
+ return self;
121
196
  }
122
197
 
123
198
  async function generate<
@@ -134,13 +209,27 @@ async function generate<
134
209
  // export name it exists before registration and can never twin.
135
210
  const name = prompt.ref;
136
211
  const model = def.model ?? prompt.model ?? DEFAULT_MODEL;
137
- const rendered = prompt.render(await def.vars({ input: args.input, ctx: args.ctx }));
212
+ // `vars()` is the one declared place a model call loads data, so it is the one place the
213
+ // framework can refuse a `Secret` and the one place an app's redactor can see the row before it
214
+ // leaves the process. Both run here, between the load and the request, and neither is optional
215
+ // in the sense that matters: the redactor may be absent, the Secret check never is.
216
+ const vars = await def.vars({ input: args.input, ctx: args.ctx });
217
+ assertNoSecrets(name, vars);
218
+ const redact = aiRedactor();
219
+ const rawPrompt = prompt.render(vars);
220
+ const rendered = redact(rawPrompt);
221
+ const system = prompt.system === undefined ? undefined : redact(prompt.system);
222
+ const redacted = rendered !== rawPrompt || system !== prompt.system;
138
223
 
139
224
  return withSpan('ai.llm', async (span) => {
140
225
  span.setAttributes({
141
226
  'llm.model': model,
142
227
  'llm.prompt': prompt.ref,
143
228
  'llm.prompt.hash': prompt.hash,
229
+ // Whether the installed redactor changed anything. Recorded because "we redact" is a claim
230
+ // an audit asks evidence for, and a redactor that silently stopped matching looks identical
231
+ // to one that had nothing to remove until this attribute separates them.
232
+ 'llm.redacted': redacted,
144
233
  });
145
234
 
146
235
  // A cached answer is still data of unknown provenance, so it goes through the schema like
@@ -151,15 +240,18 @@ async function generate<
151
240
  span.setAttribute('llm.cache.hit', hit !== undefined);
152
241
  if (hit !== undefined) return hit.value;
153
242
 
154
- const request: GenerateRequest = {
243
+ // No `tools` yet: the `respond` projection belongs to the non-streaming path alone. A tool
244
+ // call is emitted whole, so forcing the answer through one leaves a stream with nothing to
245
+ // deliver until it is already over.
246
+ const base: GenerateRequest = {
155
247
  model,
156
- ...(prompt.system === undefined ? {} : { system: prompt.system }),
248
+ ...(system === undefined ? {} : { system }),
157
249
  messages: [{ role: 'user', content: rendered }],
158
250
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
159
251
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
160
252
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
161
- tools: [respond],
162
253
  };
254
+ const request: GenerateRequest = { ...base, tools: [respond] };
163
255
 
164
256
  // A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
165
257
  // and org ceilings this call runs inside, never widen them. The gateway reserves against
@@ -168,18 +260,19 @@ async function generate<
168
260
  limitsOf(def.budget),
169
261
  );
170
262
  const gateway = aiGateway(name);
263
+ const sink = currentLlmSink();
171
264
 
172
265
  return withBudget(ledger, async () => {
266
+ if (sink !== undefined) {
267
+ const value = await streamedAnswer(def.output, name, gateway, base, sink, span);
268
+ await cache?.remember(value);
269
+ return value;
270
+ }
173
271
  let messages: readonly AiMessage[] = request.messages;
174
272
  let issues = 'no output';
175
273
  for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
176
274
  const result = await gateway.generate({ ...request, messages });
177
- span.setAttributes({
178
- 'llm.attempts': attempt,
179
- 'llm.stop': result.stopReason,
180
- 'llm.tokens': result.usage.inputTokens + result.usage.outputTokens,
181
- 'llm.cost.minor': result.cost.minor,
182
- });
275
+ span.setAttributes({ 'llm.attempts': attempt, ...answerAttributes(result) });
183
276
  // Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
184
277
  // content, so parsing it first reports a schema disagreement — a cause that is wrong, a
185
278
  // fix that does not apply, and a repair turn spent buying the same refusal again.
@@ -187,9 +280,10 @@ async function generate<
187
280
  throw new LlmRefusedError({
188
281
  prompt: name,
189
282
  model: result.model,
190
- // The fix names a model the caller can paste. `<another model>` is not one, and a
191
- // refusal is exactly the moment nobody wants to go read the catalogue.
192
- alternative: MODEL_IDS.find((id) => id !== result.model) ?? DEFAULT_MODEL,
283
+ // The fix names a model the caller can paste, and only ever a MORE capable one:
284
+ // "the first id that differs" answered a refusal on the default model with the next
285
+ // entry down the ladder, which is a retry that cannot succeed.
286
+ alternative: moreCapableThan(result.model),
193
287
  category: result.stopDetails?.category,
194
288
  explanation: result.stopDetails?.explanation,
195
289
  });
@@ -205,13 +299,77 @@ async function generate<
205
299
  throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
206
300
  }
207
301
  issues = formatIssues(parsed.issues).join('; ');
208
- messages = [...messages, { role: 'assistant', content: result.text }, repair(issues)];
302
+ const echo = assistantEcho(result);
303
+ messages =
304
+ echo === undefined
305
+ ? [...messages, repair(issues)]
306
+ : [...messages, { role: 'assistant', content: echo }, repair(issues)];
209
307
  }
210
308
  throw new LlmOutputInvalidError({ prompt: name, attempts: ATTEMPTS, issues });
211
309
  });
212
310
  });
213
311
  }
214
312
 
313
+ /**
314
+ * What one answered turn puts on the span. `llm.provider` is the half the LLM-gateway table
315
+ * called "a fallback is recorded in the span, never silent": fallback in this framework is across
316
+ * PROVIDERS serving one model, never across models, and until the gateway stamped the provider
317
+ * that answered, a fallback was exactly as silent as no fallback at all.
318
+ */
319
+ export function answerAttributes(result: GenerateResult): SpanAttributes {
320
+ return {
321
+ 'llm.stop': result.stopReason,
322
+ 'llm.tokens': result.usage.inputTokens + result.usage.outputTokens,
323
+ 'llm.cost.minor': result.cost.minor,
324
+ 'llm.provider': result.provider ?? 'unknown',
325
+ };
326
+ }
327
+
328
+ /**
329
+ * One streamed turn, from the same declaration and under the same rules — with one difference
330
+ * that is forced rather than chosen: no repair turn. The tokens are already on the consumer's
331
+ * screen, so a second answer would be two answers to one question; a stream gets one attempt and
332
+ * `X_LLM_STREAM_INVALID` names the non-streaming call as the fix.
333
+ *
334
+ * The stop reason is still read BEFORE the answer, for the reason it always was: a refusal
335
+ * carries empty or partial content and parsing it first reports a schema disagreement that is not
336
+ * one. Truncation is the same call it is on the non-streaming path.
337
+ */
338
+ async function streamedAnswer<TOutput extends StandardSchemaV1>(
339
+ output: TOutput,
340
+ name: string,
341
+ gateway: Gateway,
342
+ request: GenerateRequest,
343
+ sink: LlmSink,
344
+ span: Span,
345
+ ): Promise<InferOutput<TOutput>> {
346
+ const result = await streamOneTurn(gateway, request, sink);
347
+ span.setAttributes({ 'llm.attempts': 1, ...answerAttributes(result) });
348
+ if (result.stopReason === 'refusal') {
349
+ throw new LlmRefusedError({
350
+ prompt: name,
351
+ model: result.model,
352
+ alternative: moreCapableThan(result.model),
353
+ category: result.stopDetails?.category,
354
+ explanation: result.stopDetails?.explanation,
355
+ });
356
+ }
357
+ if (result.stopReason === 'max_tokens') {
358
+ throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
359
+ }
360
+ // Prose, and its JSON parse when it has one. Both, because a stream carries no `respond` tool
361
+ // to tell them apart: `output: t.string` is satisfied by the text itself, and an object schema
362
+ // by what the text parses to — trying only one of the two makes a legal declaration unusable.
363
+ const parsed = await accept(output, parseJsonish(result.text));
364
+ if (parsed !== undefined) return parsed.value;
365
+ const fallback = await validateAsync(output, result.text);
366
+ if (fallback.issues === undefined) return fallback.value;
367
+ throw new LlmStreamInvalidError({
368
+ prompt: name,
369
+ issues: formatIssues(fallback.issues).join('; '),
370
+ });
371
+ }
372
+
215
373
  /** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
216
374
  async function accept<TOutput extends StandardSchemaV1>(
217
375
  schema: TOutput,
@@ -242,7 +400,7 @@ function limitsOf(budget: LlmBudget | undefined): BudgetLimits {
242
400
  * a model and an agent are shown one shape, and a schema it cannot express throws HERE, at
243
401
  * declaration time, rather than degrading into a permissive node the model cannot satisfy.
244
402
  */
245
- function respondToolFor(output: StandardSchemaV1): LlmTool {
403
+ export function respondToolFor(output: StandardSchemaV1): LlmTool {
246
404
  return {
247
405
  name: RESPOND,
248
406
  description: 'Return the result. Call this exactly once; do not answer in prose.',
@@ -251,11 +409,30 @@ function respondToolFor(output: StandardSchemaV1): LlmTool {
251
409
  };
252
410
  }
253
411
 
412
+ /**
413
+ * What the model answered, as text the Messages API will accept — or nothing.
414
+ *
415
+ * `result.text` is the EMPTY STRING whenever the answer came through the `respond` tool, which
416
+ * is the dominant path: an empty text block is a 400 (`text content blocks must be non-empty`),
417
+ * so the repair turn came back as `X_AI_PROVIDER_UNAVAILABLE` and the caller never saw the
418
+ * `X_LLM_OUTPUT_INVALID` this loop exists to raise. The tool call's own arguments ARE the answer
419
+ * in that case, and replaying them is what gives the repair turn its context — `AiMessage`
420
+ * carries a string, so the `tool_use` block cannot survive the round trip as itself, and
421
+ * replaying it as text avoids the `tool_result` the API would then demand of the next message.
422
+ */
423
+ function assistantEcho(result: GenerateResult): string | undefined {
424
+ if (result.text !== '') return result.text;
425
+ const call = result.toolCalls.find((c) => c.name === RESPOND) ?? result.toolCalls[0];
426
+ if (call === undefined) return undefined;
427
+ const replayed = JSON.stringify(call.input);
428
+ return replayed === undefined || replayed === '' ? undefined : replayed;
429
+ }
430
+
254
431
  /**
255
432
  * The tool call if the model made one, otherwise the text parsed as JSON — a model that
256
433
  * answers in prose is a schema failure, not a crash, so it flows into the repair turn.
257
434
  */
258
- function structuredOutputOf(result: GenerateResult): unknown {
435
+ export function structuredOutputOf(result: GenerateResult): unknown {
259
436
  const call = result.toolCalls.find((c) => c.name === RESPOND);
260
437
  if (call !== undefined) return call.input;
261
438
  return parseJsonish(result.text);
package/src/models.ts CHANGED
@@ -1,20 +1,38 @@
1
- // The blessed models: limits, prices, and the reasoning controls each one's request surface
2
- // actually accepts. Apart from ./provider because those controls are NOT uniform across the
3
- // catalogue one body sent to all three is a guaranteed 400 on the oldest, and a downgrade for
4
- // price is not a licence to send a body the provider rejects. As of 2026-08.
1
+ // The model catalogue: an OPEN registry of limits, prices and the reasoning controls each model's
2
+ // request surface actually accepts. Open because a company's own gateway serves ids this package
3
+ // has never heard of a closed union made them untypeable, so the only way past `tsc` was to
4
+ // claim a Claude id and be billed Anthropic list prices for a model nobody ran. As of 2026-08.
5
5
 
6
6
  import type { Money } from '@ultimat3/money';
7
- import { AiRequestInvalidError } from './errors';
7
+ import { AiModelUnknownError, AiRequestInvalidError } from './errors';
8
8
 
9
9
  /**
10
- * Blessed models. Opus 5 is the default; the others are explicit downgrades. IDs are exact alias
11
- * strings never append a date suffix.
10
+ * A model id. A plain `string`, deliberately: the routing seam (`Provider`, `createGateway`) has
11
+ * always been open, and a closed union over it meant the VOCABULARY was not — `models:
12
+ * ['llama-internal-70b']` did not typecheck, so `costOf` charged Anthropic prices for a model the
13
+ * company does not use and the budget ledger reserved against the wrong number.
14
+ *
15
+ * What replaces the union as the guard is `modelSpec()`: an id nothing registered is
16
+ * `X_AI_MODEL_UNKNOWN` at the first read, naming the registered set. A wrong id is still caught —
17
+ * at the call, with a fix line, rather than by making a correct id inexpressible.
18
+ */
19
+ export type ModelId = string;
20
+
21
+ /**
22
+ * The models `AnthropicProvider` serves, in ladder order (most capable first). Its OWN list, not
23
+ * the registry's: an app that registers an internal model must not have it routed to Anthropic.
12
24
  */
13
- export const MODEL_IDS = ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5'] as const;
14
- export type ModelId = (typeof MODEL_IDS)[number];
25
+ export const ANTHROPIC_MODEL_IDS = [
26
+ 'claude-opus-5',
27
+ 'claude-sonnet-5',
28
+ 'claude-haiku-4-5',
29
+ ] as const;
15
30
 
16
31
  export const DEFAULT_MODEL: ModelId = 'claude-opus-5';
17
32
 
33
+ /** The built-in Anthropic rows' ladder. One `family` string, spelled once. */
34
+ const ANTHROPIC_FAMILY = 'anthropic';
35
+
18
36
  /**
19
37
  * Reasoning depth, shallowest first — the order is load-bearing, because a model that caps where
20
38
  * thinking may be switched off compares against it. `xhigh` is the best setting for coding and
@@ -55,6 +73,19 @@ export interface ModelSpec {
55
73
  /** Minimum cacheable prefix; a shorter prefix silently does not cache. */
56
74
  readonly cacheMinimumTokens: number;
57
75
  readonly reasoning: ModelReasoning;
76
+ /**
77
+ * Which ladder this model is a rung on. A capability comparison only means anything inside one
78
+ * — registration order across vendors is arrival order, not capability — so `moreCapableThan`
79
+ * walks up within a family and stops at its boundary. Optional, and absent is its own family:
80
+ * an app that registers its whole catalogue in the order it wants keeps comparing across all of
81
+ * it, exactly as before this field existed.
82
+ *
83
+ * Registering the OpenAI-format rows after the Anthropic ones is what made this load-bearing:
84
+ * the rung above `gpt-5.6-sol` was `claude-haiku-4-5`, so `X_LLM_REFUSED`'s fix line told an
85
+ * operator to paste the cheapest model in the catalogue, from a vendor their gateway may not
86
+ * serve at all.
87
+ */
88
+ readonly family?: string;
58
89
  }
59
90
 
60
91
  /**
@@ -63,46 +94,100 @@ export interface ModelSpec {
63
94
  */
64
95
  const usd = (minor: number): Money => ({ minor, currency: 'USD' });
65
96
 
66
- export const MODELS: Readonly<Record<ModelId, ModelSpec>> = {
67
- // $5 / $25 per MTok.
68
- 'claude-opus-5': {
69
- id: 'claude-opus-5',
70
- contextWindow: 1_000_000,
71
- maxOutput: 128_000,
72
- inputPerMillion: usd(500),
73
- outputPerMillion: usd(2_500),
74
- cacheMinimumTokens: 512,
75
- // Thinking is on by default here, and switching it OFF is legal only at `high` or below.
76
- reasoning: { effort: true, adaptive: true, disableThinkingUpTo: 'high' },
77
- },
78
- // $3 / $15 per MTok. The introductory rate is deliberately NOT modelled: a price that lapses on
79
- // a date makes every recorded cost depend on when it was read, and a budget that under-reports
80
- // spend after the lapse is a budget that is not one. List price over-reserves, which is safe.
81
- 'claude-sonnet-5': {
82
- id: 'claude-sonnet-5',
83
- contextWindow: 1_000_000,
84
- maxOutput: 128_000,
85
- inputPerMillion: usd(300),
86
- outputPerMillion: usd(1_500),
87
- cacheMinimumTokens: 1_024,
88
- reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
89
- },
90
- // $1 / $5 per MTok. Pre-4.6, so it has neither knob: an `output_config.effort` or an adaptive
91
- // `thinking` block sent here is a 400 on every request, which is what made the cheap tier
92
- // uncallable while the request body was one shape for the whole catalogue.
93
- 'claude-haiku-4-5': {
94
- id: 'claude-haiku-4-5',
95
- contextWindow: 200_000,
96
- maxOutput: 64_000,
97
- inputPerMillion: usd(100),
98
- outputPerMillion: usd(500),
99
- cacheMinimumTokens: 4_096,
100
- reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
101
- },
102
- };
97
+ /**
98
+ * Insertion order IS the capability ladder WITHIN a `family`, most capable first —
99
+ * `moreCapableThan` is its only reader, exactly as it was when the ladder was a literal tuple.
100
+ * Across families it is arrival order and means nothing. A `Map` because re-registering
101
+ * an id REPLACES its spec in place without moving its rung, which is what makes a negotiated
102
+ * enterprise rate expressible: one call, same id, new prices, same position in the ladder.
103
+ */
104
+ const registry = new Map<ModelId, ModelSpec>();
105
+
106
+ /**
107
+ * Add a model to the catalogue, or restate one that is already in it. **The three built-ins
108
+ * register through this same call**, at the bottom of this file — so the default path is the
109
+ * app's path and there is exactly one way to put a model in the catalogue.
110
+ *
111
+ * Re-registering an id replaces its spec and keeps its rung. That is deliberate and it is the
112
+ * negotiated-rate mechanism: an app whose contract prices `claude-opus-5` below list registers it
113
+ * again with its own `inputPerMillion`/`outputPerMillion`, and every `costOf`, every budget
114
+ * reservation and every recorded cost is that number from then on. Boot registers after this
115
+ * module is imported, so the app always wins.
116
+ *
117
+ * A model appended after the built-ins is the LEAST capable rung, because that is what appending
118
+ * to a most-capable-first list means. An app that wants its own ladder registers its whole
119
+ * catalogue in the order it wants, re-registering the built-in ids it keeps.
120
+ */
121
+ export function registerModel(spec: ModelSpec): ModelSpec {
122
+ registry.set(spec.id, spec);
123
+ return spec;
124
+ }
125
+
126
+ /** Every registered id, in ladder order. */
127
+ export function modelIds(): readonly ModelId[] {
128
+ return [...registry.keys()];
129
+ }
130
+
131
+ /** Every registered spec, in ladder order. Consumed by `x manifest` and by doctor-style checks. */
132
+ export function registeredModels(): readonly ModelSpec[] {
133
+ return [...registry.values()];
134
+ }
135
+
136
+ export function isModelRegistered(id: ModelId): boolean {
137
+ return registry.has(id);
138
+ }
139
+
140
+ /**
141
+ * The spec behind an id. The ONE read path, and the guard the closed union used to be: an
142
+ * unregistered id throws here — at the pricing, request-building and streaming seams that all
143
+ * call it — rather than silently pricing a foreign model at somebody else's rates.
144
+ */
145
+ export function modelSpec(id: ModelId): ModelSpec {
146
+ const spec = registry.get(id);
147
+ if (spec === undefined) throw new AiModelUnknownError({ model: id, registered: modelIds() });
148
+ return spec;
149
+ }
150
+
151
+ /**
152
+ * Refuse an unregistered id without needing its spec. For a boot-time or `x doctor`-style check
153
+ * AFTER registration has run; every request path reads `modelSpec` instead, and a declaration
154
+ * cannot check at all — an `llm()` is evaluated at module scope, before boot registers anything.
155
+ */
156
+ export function assertModel(id: ModelId): void {
157
+ modelSpec(id);
158
+ }
159
+
160
+ /** Test-only reset back to the built-in catalogue. Module state otherwise leaks between files. */
161
+ export function resetModels(): void {
162
+ registry.clear();
163
+ registerBuiltInModels();
164
+ }
103
165
 
104
166
  const rankOf = (effort: Effort): number => EFFORTS.indexOf(effort);
105
167
 
168
+ /**
169
+ * The model one rung ABOVE `model` IN ITS OWN FAMILY, or `undefined` when it is already the most
170
+ * capable one that family holds. Registration order is most-capable-first — "the others are
171
+ * explicit downgrades" — so the ladder needs no second list to walk; `family` is what stops the
172
+ * walk at the boundary between two vendors' lists, where order is arrival, not capability.
173
+ *
174
+ * A refusal is only worth retrying UPWARD. `MODEL_IDS.find((id) => id !== refused)` answered a
175
+ * refusal on the default model with the next entry DOWN, which is the one retry that cannot help:
176
+ * the fix line told an operator to buy the same refusal from a weaker model.
177
+ */
178
+ export function moreCapableThan(model: ModelId): ModelId | undefined {
179
+ const spec = registry.get(model);
180
+ if (spec === undefined) return undefined;
181
+ const ids = modelIds();
182
+ // Up, but only within the model's own family: the entry before `gpt-5.6-sol` is
183
+ // `claude-haiku-4-5`, which is not a rung above anything — it is the previous vendor's list.
184
+ for (let at = ids.indexOf(model) - 1; at >= 0; at -= 1) {
185
+ const above = ids[at];
186
+ if (above !== undefined && registry.get(above)?.family === spec.family) return above;
187
+ }
188
+ return undefined;
189
+ }
190
+
106
191
  /**
107
192
  * The reasoning half of a Messages body, shaped for one model. Everything it refuses, it refuses
108
193
  * LOCALLY with a real code — a round trip to learn a rule this file already states costs latency
@@ -117,7 +202,7 @@ export function reasoningBody(
117
202
  effort: Effort | undefined,
118
203
  thinking: ThinkingMode | undefined,
119
204
  ): Record<string, unknown> {
120
- const rules = MODELS[model].reasoning;
205
+ const rules = modelSpec(model).reasoning;
121
206
  const body: Record<string, unknown> = {};
122
207
 
123
208
  if (effort !== undefined && !rules.effort) {
@@ -143,12 +228,16 @@ export function reasoningBody(
143
228
  return body;
144
229
  }
145
230
 
146
- if ((thinking ?? 'adaptive') === 'disabled') {
231
+ if (thinking === 'disabled') {
147
232
  assertDisableAllowed(model, rules, effort ?? 'high');
148
233
  body['thinking'] = { type: 'disabled' };
149
234
  return body;
150
235
  }
151
- body['thinking'] = { type: 'adaptive', display: 'summarized' };
236
+ // Nothing asked for, nothing sent — the rule this file states, now the rule it follows.
237
+ // Adaptive is the server's own default on every model that has it, so emitting the block
238
+ // unrequested bought nothing and made a defaulted control indistinguishable on the wire from
239
+ // a declared one.
240
+ if (thinking === 'adaptive') body['thinking'] = { type: 'adaptive', display: 'summarized' };
152
241
  return body;
153
242
  }
154
243
 
@@ -161,3 +250,51 @@ function assertDisableAllowed(model: ModelId, rules: ModelReasoning, effort: Eff
161
250
  fix: `set effort: '${cap}' in definePrompt alongside thinking: 'disabled', or drop thinking from it`,
162
251
  });
163
252
  }
253
+
254
+ /**
255
+ * The blessed models. Opus 5 is the default; the others are explicit downgrades. IDs are exact
256
+ * alias strings — never append a date suffix. Registered through the public `registerModel`, in
257
+ * ladder order, so nothing about the built-in path is a private door an app cannot use.
258
+ */
259
+ function registerBuiltInModels(): void {
260
+ // $5 / $25 per MTok.
261
+ registerModel({
262
+ id: 'claude-opus-5',
263
+ family: ANTHROPIC_FAMILY,
264
+ contextWindow: 1_000_000,
265
+ maxOutput: 128_000,
266
+ inputPerMillion: usd(500),
267
+ outputPerMillion: usd(2_500),
268
+ cacheMinimumTokens: 512,
269
+ // Thinking is on by default here, and switching it OFF is legal only at `high` or below.
270
+ reasoning: { effort: true, adaptive: true, disableThinkingUpTo: 'high' },
271
+ });
272
+ // $3 / $15 per MTok. The introductory rate is deliberately NOT modelled: a price that lapses on
273
+ // a date makes every recorded cost depend on when it was read, and a budget that under-reports
274
+ // spend after the lapse is a budget that is not one. List price over-reserves, which is safe.
275
+ registerModel({
276
+ id: 'claude-sonnet-5',
277
+ family: ANTHROPIC_FAMILY,
278
+ contextWindow: 1_000_000,
279
+ maxOutput: 128_000,
280
+ inputPerMillion: usd(300),
281
+ outputPerMillion: usd(1_500),
282
+ cacheMinimumTokens: 1_024,
283
+ reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
284
+ });
285
+ // $1 / $5 per MTok. Pre-4.6, so it has neither knob: an `output_config.effort` or an adaptive
286
+ // `thinking` block sent here is a 400 on every request, which is what made the cheap tier
287
+ // uncallable while the request body was one shape for the whole catalogue.
288
+ registerModel({
289
+ id: 'claude-haiku-4-5',
290
+ family: ANTHROPIC_FAMILY,
291
+ contextWindow: 200_000,
292
+ maxOutput: 64_000,
293
+ inputPerMillion: usd(100),
294
+ outputPerMillion: usd(500),
295
+ cacheMinimumTokens: 4_096,
296
+ reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
297
+ });
298
+ }
299
+
300
+ registerBuiltInModels();