@ultimat3/ai 24.0.0 → 25.1.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 +72 -34
- package/README.md +271 -69
- package/package.json +12 -12
- package/src/agent-facts.ts +9 -2
- package/src/agent-job.ts +41 -2
- package/src/agent.ts +30 -13
- package/src/budget.ts +41 -6
- package/src/content-blocks.ts +227 -0
- package/src/content-errors.ts +67 -0
- package/src/echo-provider.ts +102 -0
- package/src/embeddings.ts +30 -10
- package/src/errors.ts +17 -11
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +40 -16
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +6 -4
- package/src/index.ts +63 -24
- package/src/llm-cache.ts +1 -1
- package/src/llm.ts +15 -11
- package/src/model-resolve.ts +79 -0
- package/src/models.ts +125 -111
- package/src/openai-body.ts +13 -2
- package/src/openai-messages.ts +86 -15
- package/src/openai-provider.ts +28 -31
- package/src/openai-wire.ts +4 -1
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +14 -6
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +75 -112
- package/src/rag.ts +7 -6
- package/src/remote-embedder.ts +10 -5
- package/src/runtime.ts +7 -14
- package/src/scorers.ts +1 -1
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +38 -36
- package/src/vector-scope.ts +2 -2
- package/src/vector.ts +103 -37
- package/src/wire.ts +7 -2
- package/src/fix-line.evals.ts +0 -35
- package/src/fix-line.ts +0 -27
- package/src/openai-models.ts +0 -84
package/src/models.ts
CHANGED
|
@@ -1,39 +1,22 @@
|
|
|
1
1
|
// The model catalogue: an OPEN registry of limits, prices and the reasoning controls each model's
|
|
2
|
-
// request surface actually accepts.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// request surface actually accepts. Mechanism only — the framework registers NO row: an app brings
|
|
3
|
+
// its models (`registerModel`, dated and sourced in its own repo) and picks its provider, so no
|
|
4
|
+
// vendor's id, price or ladder ships here to go stale or to be chosen for it (M12, 25.0.0).
|
|
5
5
|
|
|
6
|
-
import { finiteCount } from '@ultimat3/core';
|
|
6
|
+
import { assert, finiteCount, renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
7
7
|
import type { Money } from '@ultimat3/money';
|
|
8
8
|
import { AiModelUnknownError, AiRequestInvalidError } from './errors';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* A model id. A plain `string`, deliberately: the routing seam (`Provider`, `
|
|
12
|
-
* always been open, and a closed union over it
|
|
13
|
-
* ['llama-internal-70b']` did not typecheck, so `costOf` charged Anthropic prices for a model the
|
|
14
|
-
* company does not use and the budget ledger reserved against the wrong number.
|
|
11
|
+
* A model id. A plain `string`, deliberately: the routing seam (`Provider`, `gateway`) has
|
|
12
|
+
* always been open, and a closed union over it made a company's own model untypeable.
|
|
15
13
|
*
|
|
16
|
-
* What replaces the union as the guard is `modelSpec()`: an id
|
|
14
|
+
* What replaces the union as the guard is `modelSpec()`: an id the app never registered is
|
|
17
15
|
* `X_AI_MODEL_UNKNOWN` at the first read, naming the registered set. A wrong id is still caught —
|
|
18
16
|
* at the call, with a fix line, rather than by making a correct id inexpressible.
|
|
19
17
|
*/
|
|
20
18
|
export type ModelId = string;
|
|
21
19
|
|
|
22
|
-
/**
|
|
23
|
-
* The models `AnthropicProvider` serves, in ladder order (most capable first). Its OWN list, not
|
|
24
|
-
* the registry's: an app that registers an internal model must not have it routed to Anthropic.
|
|
25
|
-
*/
|
|
26
|
-
export const ANTHROPIC_MODEL_IDS = [
|
|
27
|
-
'claude-opus-5',
|
|
28
|
-
'claude-sonnet-5',
|
|
29
|
-
'claude-haiku-4-5',
|
|
30
|
-
] as const;
|
|
31
|
-
|
|
32
|
-
export const DEFAULT_MODEL: ModelId = 'claude-opus-5';
|
|
33
|
-
|
|
34
|
-
/** The built-in Anthropic rows' ladder. One `family` string, spelled once. */
|
|
35
|
-
const ANTHROPIC_FAMILY = 'anthropic';
|
|
36
|
-
|
|
37
20
|
/**
|
|
38
21
|
* Reasoning depth, shallowest first — the order is load-bearing, because a model that caps where
|
|
39
22
|
* thinking may be switched off compares against it. `xhigh` is the best setting for coding and
|
|
@@ -49,18 +32,31 @@ export type Effort = (typeof EFFORTS)[number];
|
|
|
49
32
|
*/
|
|
50
33
|
export type ThinkingMode = 'adaptive' | 'disabled';
|
|
51
34
|
|
|
35
|
+
/** A non-text content block kind a model may accept (`AiContentBlock`'s `image` / `document`). */
|
|
36
|
+
export type ContentKind = 'image' | 'document';
|
|
37
|
+
|
|
52
38
|
/**
|
|
53
39
|
* What one model's request surface accepts. Every field here is a 400 when it is sent to a model
|
|
54
40
|
* that does not take it, which is why it is data on the spec rather than a rule in the request
|
|
55
41
|
* builder: adding a fourth model is a row, not an `if`.
|
|
56
42
|
*/
|
|
57
43
|
export interface ModelReasoning {
|
|
58
|
-
/** `output_config.effort`.
|
|
44
|
+
/** `output_config.effort`. A model without the control rejects it outright. */
|
|
59
45
|
readonly effort: boolean;
|
|
60
46
|
/** `thinking: {type:'adaptive'}`. Older models take a token budget this package never sends. */
|
|
61
47
|
readonly adaptive: boolean;
|
|
62
|
-
/**
|
|
63
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Deepest effort at which thinking may be switched off; `undefined` = every effort, `'never'` =
|
|
50
|
+
* none. `'never'` is a row, not an absence: on a model whose thinking is always on, an off switch
|
|
51
|
+
* is a 400 at every effort, and refusing it locally names `effort` as the knob that replaced it.
|
|
52
|
+
*/
|
|
53
|
+
readonly disableThinkingUpTo: Effort | 'never' | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* How this model SPELLS "no up-front thinking" on the wire. Absent is `'disabled'`. A model that
|
|
56
|
+
* answers `disabled` with a 400 pointing at `between_tools` (its lowest setting) states it here,
|
|
57
|
+
* so the one `thinking: 'disabled'` stays one declaration rather than a per-model vocabulary.
|
|
58
|
+
*/
|
|
59
|
+
readonly disabledThinking?: 'disabled' | 'between_tools';
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
export interface ModelSpec {
|
|
@@ -71,9 +67,27 @@ export interface ModelSpec {
|
|
|
71
67
|
readonly inputPerMillion: Money;
|
|
72
68
|
/** Cost of one million output tokens, in minor units. */
|
|
73
69
|
readonly outputPerMillion: Money;
|
|
70
|
+
/**
|
|
71
|
+
* Cost of one million cache-READ tokens, in minor units. Absent is 0.1x `inputPerMillion`, the
|
|
72
|
+
* standard multiplier — which is wrong for exactly the models whose vendor publishes its own (0.05x
|
|
73
|
+
* and 0.025x exist), so a row with a vendor rate states it.
|
|
74
|
+
*/
|
|
75
|
+
readonly cacheReadPerMillion?: Money;
|
|
76
|
+
/**
|
|
77
|
+
* Cost of one million cache-WRITE tokens at the 5-minute TTL, in minor units; absent is 1.25x
|
|
78
|
+
* input. The 5-minute rate because it is the API's default TTL and this package never asks for
|
|
79
|
+
* the 1-hour one (2x), and `usage` does not split the two.
|
|
80
|
+
*/
|
|
81
|
+
readonly cacheWritePerMillion?: Money;
|
|
74
82
|
/** Minimum cacheable prefix; a shorter prefix silently does not cache. */
|
|
75
83
|
readonly cacheMinimumTokens: number;
|
|
76
84
|
readonly reasoning: ModelReasoning;
|
|
85
|
+
/**
|
|
86
|
+
* The non-text blocks a message to this model may carry. Absent is TEXT ONLY, deliberately: an
|
|
87
|
+
* app's own row for a text-only endpoint must not have an image sent to it, and the refusal
|
|
88
|
+
* (`X_AI_CONTENT_UNSUPPORTED`) names this field, so a row that does take images is one edit.
|
|
89
|
+
*/
|
|
90
|
+
readonly input?: readonly ContentKind[];
|
|
77
91
|
/**
|
|
78
92
|
* Which ladder this model is a rung on. A capability comparison only means anything inside one
|
|
79
93
|
* — registration order across vendors is arrival order, not capability — so `moreCapableThan`
|
|
@@ -81,20 +95,13 @@ export interface ModelSpec {
|
|
|
81
95
|
* an app that registers its whole catalogue in the order it wants keeps comparing across all of
|
|
82
96
|
* it, exactly as before this field existed.
|
|
83
97
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* operator to paste
|
|
87
|
-
* serve at all.
|
|
98
|
+
* An app registering two vendors' rows is what makes this load-bearing: without it, the rung
|
|
99
|
+
* above one vendor's top model is the other vendor's cheapest, and `X_LLM_REFUSED`'s fix line
|
|
100
|
+
* tells an operator to paste a weaker model from a list their gateway may not serve at all.
|
|
88
101
|
*/
|
|
89
102
|
readonly family?: string;
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
/**
|
|
93
|
-
* A price per million tokens, in INTEGER MINOR UNITS. Token spend is money, and the house rule
|
|
94
|
-
* applies to money wherever it comes from: never a float.
|
|
95
|
-
*/
|
|
96
|
-
const usd = (minor: number): Money => ({ minor, currency: 'USD' });
|
|
97
|
-
|
|
98
105
|
/**
|
|
99
106
|
* Insertion order IS the capability ladder WITHIN a `family`, most capable first —
|
|
100
107
|
* `moreCapableThan` is its only reader, exactly as it was when the ladder was a literal tuple.
|
|
@@ -108,19 +115,16 @@ const registry = new Map<ModelId, ModelSpec>();
|
|
|
108
115
|
const SUBJECT = 'registerModel';
|
|
109
116
|
|
|
110
117
|
/**
|
|
111
|
-
* Add a model to the catalogue, or restate one that is already in it
|
|
112
|
-
*
|
|
113
|
-
* app's
|
|
118
|
+
* Add a model to the catalogue, or restate one that is already in it — the ONE way a model enters
|
|
119
|
+
* it. The framework calls it for nothing: every row is the app's, written at boot (by convention in
|
|
120
|
+
* the app's own `models.ts`, with the source and the date beside each number).
|
|
114
121
|
*
|
|
115
|
-
* Re-registering an id replaces its spec and keeps its rung. That is
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* reservation and every recorded cost is that number from then on. Boot registers after this
|
|
119
|
-
* module is imported, so the app always wins.
|
|
122
|
+
* Re-registering an id replaces its spec and keeps its rung. That is the negotiated-rate mechanism:
|
|
123
|
+
* same id, new prices, and every `costOf`, every budget reservation and every recorded cost is that
|
|
124
|
+
* number from then on.
|
|
120
125
|
*
|
|
121
|
-
*
|
|
122
|
-
* to a
|
|
123
|
-
* catalogue in the order it wants, re-registering the built-in ids it keeps.
|
|
126
|
+
* Registration order within a `family` is the capability ladder, most capable first: a model
|
|
127
|
+
* appended to a family is its LEAST capable rung.
|
|
124
128
|
*/
|
|
125
129
|
export function registerModel(spec: ModelSpec): ModelSpec {
|
|
126
130
|
// Screened at the ONE seam every model in the catalogue passes through, and at boot, which is
|
|
@@ -136,10 +140,38 @@ export function registerModel(spec: ModelSpec): ModelSpec {
|
|
|
136
140
|
finiteCount(SUBJECT, `${spec.id} cacheMinimumTokens`, spec.cacheMinimumTokens);
|
|
137
141
|
finiteCount(SUBJECT, `${spec.id} inputPerMillion.minor`, spec.inputPerMillion.minor);
|
|
138
142
|
finiteCount(SUBJECT, `${spec.id} outputPerMillion.minor`, spec.outputPerMillion.minor);
|
|
143
|
+
assertOneCurrency(spec);
|
|
144
|
+
if (spec.cacheReadPerMillion !== undefined) {
|
|
145
|
+
finiteCount(SUBJECT, `${spec.id} cacheReadPerMillion.minor`, spec.cacheReadPerMillion.minor);
|
|
146
|
+
}
|
|
147
|
+
if (spec.cacheWritePerMillion !== undefined) {
|
|
148
|
+
finiteCount(SUBJECT, `${spec.id} cacheWritePerMillion.minor`, spec.cacheWritePerMillion.minor);
|
|
149
|
+
}
|
|
139
150
|
registry.set(spec.id, spec);
|
|
140
151
|
return spec;
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Every price on a row in ONE currency. `costOf` sums them into a single `Money` stamped with the
|
|
156
|
+
* input price's currency, so a cache rate in another one would be added as if it were the same.
|
|
157
|
+
*/
|
|
158
|
+
function assertOneCurrency(spec: ModelSpec): void {
|
|
159
|
+
const currency = spec.inputPerMillion.currency;
|
|
160
|
+
const prices = {
|
|
161
|
+
outputPerMillion: spec.outputPerMillion,
|
|
162
|
+
cacheReadPerMillion: spec.cacheReadPerMillion,
|
|
163
|
+
cacheWritePerMillion: spec.cacheWritePerMillion,
|
|
164
|
+
};
|
|
165
|
+
for (const [field, price] of Object.entries(prices)) {
|
|
166
|
+
// X_INVARIANT, as `finiteCount`'s refusals of the same row are: a bad row, caught at boot.
|
|
167
|
+
assert(
|
|
168
|
+
price === undefined || price.currency === currency,
|
|
169
|
+
`${SUBJECT} row "${spec.id}" prices ${field} in ${renderCauseValue(price?.currency)} and inputPerMillion in ${renderCauseValue(currency)}; costOf sums every price into one Money`,
|
|
170
|
+
`registerModel({ ...spec, ${field}: { minor, currency: ${renderFixLiteral(currency, '<input currency>')} } }) # every price on a row in one currency`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
143
175
|
/** Every registered id, in ladder order. */
|
|
144
176
|
export function modelIds(): readonly ModelId[] {
|
|
145
177
|
return [...registry.keys()];
|
|
@@ -181,37 +213,61 @@ export function assertModel(id: ModelId): void {
|
|
|
181
213
|
modelSpec(id);
|
|
182
214
|
}
|
|
183
215
|
|
|
184
|
-
/** Test-only
|
|
216
|
+
/** Test-only: empty the catalogue. Module state otherwise leaks between files. */
|
|
185
217
|
export function resetModels(): void {
|
|
186
218
|
registry.clear();
|
|
187
|
-
registerBuiltInModels();
|
|
188
219
|
}
|
|
189
220
|
|
|
190
221
|
const rankOf = (effort: Effort): number => EFFORTS.indexOf(effort);
|
|
191
222
|
|
|
192
223
|
/**
|
|
193
|
-
* The model one rung ABOVE `model` IN ITS OWN FAMILY,
|
|
194
|
-
* capable one that family holds
|
|
195
|
-
*
|
|
196
|
-
*
|
|
224
|
+
* The model one rung ABOVE `model` IN ITS OWN FAMILY, among the rows the APP registered, or
|
|
225
|
+
* `undefined` when it is already the most capable one that family holds — or nothing above it was
|
|
226
|
+
* registered at all. The framework holds no rung of its own, so it never suggests a model the app
|
|
227
|
+
* did not choose. Registration order is most-capable-first, so the ladder needs no second list;
|
|
228
|
+
* `family` stops the walk at the boundary between two lists, where order is arrival, not capability.
|
|
197
229
|
*
|
|
198
230
|
* A refusal is only worth retrying UPWARD. `MODEL_IDS.find((id) => id !== refused)` answered a
|
|
199
231
|
* refusal on the default model with the next entry DOWN, which is the one retry that cannot help:
|
|
200
232
|
* the fix line told an operator to buy the same refusal from a weaker model.
|
|
201
233
|
*/
|
|
202
|
-
export function moreCapableThan(
|
|
234
|
+
export function moreCapableThan(
|
|
235
|
+
model: ModelId,
|
|
236
|
+
declared: DeclaredReasoning = {},
|
|
237
|
+
): ModelId | undefined {
|
|
203
238
|
const spec = registry.get(model);
|
|
204
239
|
if (spec === undefined) return undefined;
|
|
205
240
|
const ids = modelIds();
|
|
206
|
-
// Up, but only within the model's own family: the entry before
|
|
207
|
-
//
|
|
241
|
+
// Up, but only within the model's own family: the entry before another family's top rung is the
|
|
242
|
+
// previous family's bottom one, which is not a rung above anything.
|
|
208
243
|
for (let at = ids.indexOf(model) - 1; at >= 0; at -= 1) {
|
|
209
244
|
const above = ids[at];
|
|
210
|
-
if (above
|
|
245
|
+
if (above === undefined || registry.get(above)?.family !== spec.family) continue;
|
|
246
|
+
// A rung the SAME declaration cannot run on is not an answer: from a model with
|
|
247
|
+
// `thinking: 'disabled'`, pasting one whose thinking is always on turns a refusal into an
|
|
248
|
+
// `X_AI_REQUEST_INVALID` on every call. Skip it and keep climbing.
|
|
249
|
+
if (acceptsReasoning(above, declared)) return above;
|
|
211
250
|
}
|
|
212
251
|
return undefined;
|
|
213
252
|
}
|
|
214
253
|
|
|
254
|
+
/** The reasoning controls a declaration named — what a suggested rung must also accept. */
|
|
255
|
+
export interface DeclaredReasoning {
|
|
256
|
+
readonly effort?: Effort | undefined;
|
|
257
|
+
readonly thinking?: ThinkingMode | undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Whether `reasoningBody` would build this declaration's reasoning half for `model`. */
|
|
261
|
+
function acceptsReasoning(model: ModelId, declared: DeclaredReasoning): boolean {
|
|
262
|
+
try {
|
|
263
|
+
reasoningBody(model, declared.effort, declared.thinking);
|
|
264
|
+
return true;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof AiRequestInvalidError) return false;
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
215
271
|
/**
|
|
216
272
|
* The reasoning half of a Messages body, shaped for one model. Everything it refuses, it refuses
|
|
217
273
|
* LOCALLY with a real code — a round trip to learn a rule this file already states costs latency
|
|
@@ -231,8 +287,8 @@ export function reasoningBody(
|
|
|
231
287
|
|
|
232
288
|
if (effort !== undefined && !rules.effort) {
|
|
233
289
|
throw new AiRequestInvalidError({
|
|
234
|
-
detail: `model "${model}" has no effort control; output_config.effort is a 400 on it`,
|
|
235
|
-
fix:
|
|
290
|
+
detail: `model "${model}" has no effort control; output_config.effort is a 400 on it (modelId: a registered id whose registerModel row has reasoning.effort: true)`,
|
|
291
|
+
fix: 'agent({ …, model: modelId }) # or the llm() declaration, or definePrompt — or drop effort from definePrompt',
|
|
236
292
|
});
|
|
237
293
|
}
|
|
238
294
|
// Only what the caller asked for. `output_config`, not a top-level `effort` — a top-level one
|
|
@@ -243,8 +299,8 @@ export function reasoningBody(
|
|
|
243
299
|
if (!rules.adaptive) {
|
|
244
300
|
if (thinking === 'adaptive') {
|
|
245
301
|
throw new AiRequestInvalidError({
|
|
246
|
-
detail: `model "${model}" predates adaptive thinking; a thinking block is a 400 on it`,
|
|
247
|
-
fix:
|
|
302
|
+
detail: `model "${model}" predates adaptive thinking; a thinking block is a 400 on it (modelId: a registered id whose registerModel row has reasoning.adaptive: true)`,
|
|
303
|
+
fix: 'agent({ …, model: modelId }) # or the llm() declaration, or definePrompt — or drop thinking from definePrompt',
|
|
248
304
|
});
|
|
249
305
|
}
|
|
250
306
|
// No `thinking` field at all is exactly "no thinking" on a pre-4.6 model, so `disabled`
|
|
@@ -254,7 +310,7 @@ export function reasoningBody(
|
|
|
254
310
|
|
|
255
311
|
if (thinking === 'disabled') {
|
|
256
312
|
assertDisableAllowed(model, rules, effort ?? 'high');
|
|
257
|
-
body['thinking'] = { type: 'disabled' };
|
|
313
|
+
body['thinking'] = { type: rules.disabledThinking ?? 'disabled' };
|
|
258
314
|
return body;
|
|
259
315
|
}
|
|
260
316
|
// Nothing asked for, nothing sent — the rule this file states, now the rule it follows.
|
|
@@ -268,57 +324,15 @@ export function reasoningBody(
|
|
|
268
324
|
/** Some models cap the effort at which thinking may be switched off. Above the cap it is a 400. */
|
|
269
325
|
function assertDisableAllowed(model: ModelId, rules: ModelReasoning, effort: Effort): void {
|
|
270
326
|
const cap = rules.disableThinkingUpTo;
|
|
327
|
+
if (cap === 'never') {
|
|
328
|
+
throw new AiRequestInvalidError({
|
|
329
|
+
detail: `model "${model}" has thinking always on; thinking: 'disabled' is a 400 on it at every effort (modelId: a registered id whose registerModel row lets thinking be disabled)`,
|
|
330
|
+
fix: `effort: 'low' # in definePrompt, in place of thinking: 'disabled' — or model: modelId on the agent() or llm() declaration`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
271
333
|
if (cap === undefined || rankOf(effort) <= rankOf(cap)) return;
|
|
272
334
|
throw new AiRequestInvalidError({
|
|
273
335
|
detail: `model "${model}" allows thinking: 'disabled' only at effort '${cap}' or below, not '${effort}'`,
|
|
274
336
|
fix: `set effort: '${cap}' in definePrompt alongside thinking: 'disabled', or drop thinking from it`,
|
|
275
337
|
});
|
|
276
338
|
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* The blessed models. Opus 5 is the default; the others are explicit downgrades. IDs are exact
|
|
280
|
-
* alias strings — never append a date suffix. Registered through the public `registerModel`, in
|
|
281
|
-
* ladder order, so nothing about the built-in path is a private door an app cannot use.
|
|
282
|
-
*/
|
|
283
|
-
function registerBuiltInModels(): void {
|
|
284
|
-
// $5 / $25 per MTok.
|
|
285
|
-
registerModel({
|
|
286
|
-
id: 'claude-opus-5',
|
|
287
|
-
family: ANTHROPIC_FAMILY,
|
|
288
|
-
contextWindow: 1_000_000,
|
|
289
|
-
maxOutput: 128_000,
|
|
290
|
-
inputPerMillion: usd(500),
|
|
291
|
-
outputPerMillion: usd(2_500),
|
|
292
|
-
cacheMinimumTokens: 512,
|
|
293
|
-
// Thinking is on by default here, and switching it OFF is legal only at `high` or below.
|
|
294
|
-
reasoning: { effort: true, adaptive: true, disableThinkingUpTo: 'high' },
|
|
295
|
-
});
|
|
296
|
-
// $3 / $15 per MTok. The introductory rate is deliberately NOT modelled: a price that lapses on
|
|
297
|
-
// a date makes every recorded cost depend on when it was read, and a budget that under-reports
|
|
298
|
-
// spend after the lapse is a budget that is not one. List price over-reserves, which is safe.
|
|
299
|
-
registerModel({
|
|
300
|
-
id: 'claude-sonnet-5',
|
|
301
|
-
family: ANTHROPIC_FAMILY,
|
|
302
|
-
contextWindow: 1_000_000,
|
|
303
|
-
maxOutput: 128_000,
|
|
304
|
-
inputPerMillion: usd(300),
|
|
305
|
-
outputPerMillion: usd(1_500),
|
|
306
|
-
cacheMinimumTokens: 1_024,
|
|
307
|
-
reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
|
|
308
|
-
});
|
|
309
|
-
// $1 / $5 per MTok. Pre-4.6, so it has neither knob: an `output_config.effort` or an adaptive
|
|
310
|
-
// `thinking` block sent here is a 400 on every request, which is what made the cheap tier
|
|
311
|
-
// uncallable while the request body was one shape for the whole catalogue.
|
|
312
|
-
registerModel({
|
|
313
|
-
id: 'claude-haiku-4-5',
|
|
314
|
-
family: ANTHROPIC_FAMILY,
|
|
315
|
-
contextWindow: 200_000,
|
|
316
|
-
maxOutput: 64_000,
|
|
317
|
-
inputPerMillion: usd(100),
|
|
318
|
-
outputPerMillion: usd(500),
|
|
319
|
-
cacheMinimumTokens: 4_096,
|
|
320
|
-
reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
registerBuiltInModels();
|
package/src/openai-body.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// The per-model rules live in the model's spec, never in an `if` here — same rule as
|
|
5
5
|
// `reasoningBody()`: adding a model an endpoint serves is a `registerModel` row, not a branch.
|
|
6
6
|
|
|
7
|
+
import { assertMediaContent } from './content-blocks';
|
|
7
8
|
import { AiRequestInvalidError } from './errors';
|
|
8
9
|
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
9
10
|
import { modelSpec } from './models';
|
|
@@ -14,6 +15,8 @@ export interface ChatCompletionBodyInput {
|
|
|
14
15
|
readonly request: GenerateRequest;
|
|
15
16
|
/** Already resolved — the gateway picks the model, the provider never guesses mid-request. */
|
|
16
17
|
readonly model: ModelId;
|
|
18
|
+
/** The provider's `name`, so a content refusal names the endpoint that cannot take the block. */
|
|
19
|
+
readonly provider: string;
|
|
17
20
|
readonly stream: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
@@ -27,11 +30,12 @@ export interface ChatCompletionBodyInput {
|
|
|
27
30
|
* reject outright.
|
|
28
31
|
*/
|
|
29
32
|
export function chatCompletionBody(input: ChatCompletionBodyInput): Record<string, unknown> {
|
|
30
|
-
const { request, model, stream } = input;
|
|
33
|
+
const { request, model, stream, provider } = input;
|
|
31
34
|
const spec = modelSpec(model);
|
|
35
|
+
assertMediaContent(request.messages, { provider, model });
|
|
32
36
|
const body: Record<string, unknown> = {
|
|
33
37
|
model,
|
|
34
|
-
messages: toOpenAiMessages(request.system, request.messages),
|
|
38
|
+
messages: toOpenAiMessages(request.system, request.messages, { provider, model }),
|
|
35
39
|
max_completion_tokens: Math.min(request.maxTokens, spec.maxOutput),
|
|
36
40
|
...reasoningFields(model, request.effort, request.thinking),
|
|
37
41
|
};
|
|
@@ -80,6 +84,13 @@ export function reasoningFields(
|
|
|
80
84
|
fix: 'drop thinking from definePrompt and set effort instead, or route the prompt to a model registered with reasoning: { adaptive: true }',
|
|
81
85
|
});
|
|
82
86
|
}
|
|
87
|
+
// The row says reasoning cannot be switched off, whatever the wire would spell the switch as.
|
|
88
|
+
if (thinking === 'disabled' && rules.disableThinkingUpTo === 'never') {
|
|
89
|
+
throw new AiRequestInvalidError({
|
|
90
|
+
detail: `model "${model}" is registered with reasoning always on (disableThinkingUpTo: 'never'), so reasoning_effort: 'none' cannot be sent to it`,
|
|
91
|
+
fix: "effort: 'low' # in definePrompt, in place of thinking: 'disabled'",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
83
94
|
if (thinking === 'disabled' && effort !== undefined) {
|
|
84
95
|
throw new AiRequestInvalidError({
|
|
85
96
|
detail: `model "${model}" writes both thinking and effort onto one reasoning_effort field, and the request asked for both`,
|
package/src/openai-messages.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// system prompt lives, how an assistant asks for a tool, and how a tool answers. Pure functions, so
|
|
6
6
|
// every disagreement is a unit test with no socket.
|
|
7
7
|
|
|
8
|
+
import type { AiMediaBlock } from './content-blocks';
|
|
9
|
+
import { isMediaBlock } from './content-blocks';
|
|
10
|
+
import { AiContentUnsupportedError } from './content-errors';
|
|
8
11
|
import type { AiContentBlock, AiMessage } from './provider';
|
|
9
12
|
import type { JsonSchema, LlmTool } from './tools';
|
|
10
13
|
|
|
@@ -15,8 +18,18 @@ export interface OpenAiToolCall {
|
|
|
15
18
|
readonly function: { readonly name: string; readonly arguments: string };
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
/** A user message's parts, when it carries more than prose. Field names are the format's own. */
|
|
22
|
+
export type OpenAiContentPart =
|
|
23
|
+
| { readonly type: 'text'; readonly text: string }
|
|
24
|
+
| { readonly type: 'image_url'; readonly image_url: { readonly url: string } }
|
|
25
|
+
| {
|
|
26
|
+
readonly type: 'file';
|
|
27
|
+
readonly file: { readonly filename: string; readonly file_data: string };
|
|
28
|
+
};
|
|
29
|
+
|
|
18
30
|
export type OpenAiMessage =
|
|
19
|
-
| { readonly role: 'system'
|
|
31
|
+
| { readonly role: 'system'; readonly content: string }
|
|
32
|
+
| { readonly role: 'user'; readonly content: string | readonly OpenAiContentPart[] }
|
|
20
33
|
| {
|
|
21
34
|
readonly role: 'assistant';
|
|
22
35
|
readonly content?: string | undefined;
|
|
@@ -57,21 +70,33 @@ export interface OpenAiToolChoice {
|
|
|
57
70
|
export function toOpenAiMessages(
|
|
58
71
|
system: string | undefined,
|
|
59
72
|
messages: readonly AiMessage[],
|
|
73
|
+
target: MediaTarget,
|
|
60
74
|
): readonly OpenAiMessage[] {
|
|
61
75
|
const out: OpenAiMessage[] = [];
|
|
62
76
|
if (system !== undefined && system !== '') out.push({ role: 'system', content: system });
|
|
63
|
-
|
|
77
|
+
messages.forEach((message, m) => {
|
|
64
78
|
if (typeof message.content === 'string') {
|
|
65
79
|
out.push({ role: message.role, content: message.content });
|
|
66
|
-
|
|
80
|
+
} else if (message.role === 'assistant') {
|
|
81
|
+
out.push(assistantMessage(message.content));
|
|
82
|
+
} else {
|
|
83
|
+
// `m` is the index in the CALLER's list, so a refusal's path matches `assertMediaContent`'s.
|
|
84
|
+
out.push(...userTurn(message.content, m, target));
|
|
67
85
|
}
|
|
68
|
-
|
|
69
|
-
else out.push(...toolTurn(message.content));
|
|
70
|
-
}
|
|
86
|
+
});
|
|
71
87
|
return out;
|
|
72
88
|
}
|
|
73
89
|
|
|
74
|
-
/**
|
|
90
|
+
/** Who a media refusal names: this provider, and the model the request is for. */
|
|
91
|
+
export interface MediaTarget {
|
|
92
|
+
readonly provider: string;
|
|
93
|
+
readonly model: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Text blocks concatenate; `tool_use` blocks move onto `tool_calls` with stringified arguments.
|
|
98
|
+
* A media block never reaches here: `assertMediaContent` refuses one outside a user turn first.
|
|
99
|
+
*/
|
|
75
100
|
function assistantMessage(blocks: readonly AiContentBlock[]): OpenAiMessage {
|
|
76
101
|
let content = '';
|
|
77
102
|
const toolCalls: OpenAiToolCall[] = [];
|
|
@@ -95,15 +120,23 @@ function assistantMessage(blocks: readonly AiContentBlock[]): OpenAiMessage {
|
|
|
95
120
|
}
|
|
96
121
|
|
|
97
122
|
/**
|
|
98
|
-
* A user turn
|
|
99
|
-
*
|
|
100
|
-
*
|
|
123
|
+
* A user turn. Every tool result becomes its own `role: 'tool'` message, in order and before any
|
|
124
|
+
* prose, because OpenAI requires one tool message per `tool_call_id` the previous assistant
|
|
125
|
+
* message asked for, and requires them to come first. What remains is ONE user message: a plain
|
|
126
|
+
* string when it is only prose — the shape every server in the family takes — and parts, in
|
|
127
|
+
* block order, the moment it carries an image or a document.
|
|
101
128
|
*/
|
|
102
|
-
function
|
|
129
|
+
function userTurn(
|
|
130
|
+
blocks: readonly AiContentBlock[],
|
|
131
|
+
index: number,
|
|
132
|
+
target: MediaTarget,
|
|
133
|
+
): readonly OpenAiMessage[] {
|
|
103
134
|
const out: OpenAiMessage[] = [];
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (block.type === 'text') text
|
|
135
|
+
const parts: OpenAiContentPart[] = [];
|
|
136
|
+
blocks.forEach((block, b) => {
|
|
137
|
+
if (block.type === 'text') parts.push({ type: 'text', text: block.text });
|
|
138
|
+
if (isMediaBlock(block))
|
|
139
|
+
parts.push(mediaPart(block, `messages[${index}].content[${b}]`, target));
|
|
107
140
|
if (block.type === 'tool_result') {
|
|
108
141
|
out.push({
|
|
109
142
|
role: 'tool',
|
|
@@ -113,11 +146,49 @@ function toolTurn(blocks: readonly AiContentBlock[]): readonly OpenAiMessage[] {
|
|
|
113
146
|
content: block.is_error === true ? `error: ${block.content}` : block.content,
|
|
114
147
|
});
|
|
115
148
|
}
|
|
149
|
+
});
|
|
150
|
+
if (parts.some((part) => part.type !== 'text')) out.push({ role: 'user', content: parts });
|
|
151
|
+
else {
|
|
152
|
+
const text = parts.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
153
|
+
if (text !== '') out.push({ role: 'user', content: text });
|
|
116
154
|
}
|
|
117
|
-
if (text !== '') out.push({ role: 'user', content: text });
|
|
118
155
|
return out;
|
|
119
156
|
}
|
|
120
157
|
|
|
158
|
+
/**
|
|
159
|
+
* One media block as a content part. An image goes as `image_url` either way — a data URL for
|
|
160
|
+
* base64, the URL itself otherwise. A PDF goes as a `file` part with a base64 data URL. Chat
|
|
161
|
+
* completions has no shape for a document by URL ("not supported" — URL files are the Responses
|
|
162
|
+
* API's) and takes only PDFs as files, so those two are refused here rather than approximated.
|
|
163
|
+
*/
|
|
164
|
+
function mediaPart(block: AiMediaBlock, at: string, target: MediaTarget): OpenAiContentPart {
|
|
165
|
+
const { source } = block;
|
|
166
|
+
if (block.type === 'image') {
|
|
167
|
+
const url =
|
|
168
|
+
source.type === 'url' ? source.url : `data:${source.media_type};base64,${source.data}`;
|
|
169
|
+
return { type: 'image_url', image_url: { url } };
|
|
170
|
+
}
|
|
171
|
+
if (source.type === 'base64') {
|
|
172
|
+
const file_data = `data:${source.media_type};base64,${source.data}`;
|
|
173
|
+
return { type: 'file', file: { filename: block.title ?? 'document.pdf', file_data } };
|
|
174
|
+
}
|
|
175
|
+
const refusal =
|
|
176
|
+
source.type === 'url'
|
|
177
|
+
? {
|
|
178
|
+
why: 'the chat-completions format has no document-by-URL part',
|
|
179
|
+
fix: "source: { type: 'base64', media_type: 'application/pdf', data } # fetch the PDF and send its bytes",
|
|
180
|
+
}
|
|
181
|
+
: {
|
|
182
|
+
why: 'the chat-completions format takes only PDF files, never a text/plain document',
|
|
183
|
+
fix: "{ type: 'text', text } # the document's text, as a block in the same user turn",
|
|
184
|
+
};
|
|
185
|
+
throw new AiContentUnsupportedError({
|
|
186
|
+
kind: 'document',
|
|
187
|
+
at,
|
|
188
|
+
refusal: { by: 'provider', ...target, ...refusal },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
121
192
|
/**
|
|
122
193
|
* Tool definitions, wrapped in the `{ type: 'function', function: … }` envelope.
|
|
123
194
|
*
|