@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/CLAUDE.md +363 -0
- package/README.md +229 -6
- package/package.json +10 -9
- package/src/agent.ts +287 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +142 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +40 -10
- package/src/index.ts +45 -8
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +260 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +94 -35
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +13 -4
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
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 {
|
|
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,
|
|
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 {
|
|
48
|
+
import { assertNoSecrets } from './redaction';
|
|
49
|
+
import { aiEmbedder, aiGateway, aiRedactor, semanticCacheFor } from './runtime';
|
|
33
50
|
import type { LlmTool } from './tools';
|
|
34
51
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
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>):
|
|
160
|
+
>(def: LlmDef<TInput, TOutput, V>): LlmAction<TInput, TOutput> {
|
|
113
161
|
const respond = respondToolFor(def.output);
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
// actually accepts.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
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 (
|
|
231
|
+
if (thinking === 'disabled') {
|
|
147
232
|
assertDisableAllowed(model, rules, effort ?? 'high');
|
|
148
233
|
body['thinking'] = { type: 'disabled' };
|
|
149
234
|
return body;
|
|
150
235
|
}
|
|
151
|
-
|
|
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();
|