@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/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. 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.
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`, `createGateway`) has
12
- * always been open, and a closed union over it meant the VOCABULARY was not — `models:
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 nothing registered is
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`. Models older than 4.6 reject it outright. */
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
- /** Deepest effort that still accepts `thinking: 'disabled'`; `undefined` = every effort does. */
63
- readonly disableThinkingUpTo: Effort | undefined;
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
- * Registering the OpenAI-format rows after the Anthropic ones is what made this load-bearing:
85
- * the rung above `gpt-5.6-sol` was `claude-haiku-4-5`, so `X_LLM_REFUSED`'s fix line told an
86
- * operator to paste the cheapest model in the catalogue, from a vendor their gateway may not
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. **The three built-ins
112
- * register through this same call**, at the bottom of this file — so the default path is the
113
- * app's path and there is exactly one way to put a model in the catalogue.
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 deliberate and it is the
116
- * negotiated-rate mechanism: an app whose contract prices `claude-opus-5` below list registers it
117
- * again with its own `inputPerMillion`/`outputPerMillion`, and every `costOf`, every budget
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
- * A model appended after the built-ins is the LEAST capable rung, because that is what appending
122
- * to a most-capable-first list means. An app that wants its own ladder registers its whole
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 reset back to the built-in catalogue. Module state otherwise leaks between files. */
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, or `undefined` when it is already the most
194
- * capable one that family holds. Registration order is most-capable-first — "the others are
195
- * explicit downgrades" — so the ladder needs no second list to walk; `family` is what stops the
196
- * walk at the boundary between two vendors' lists, where order is arrival, not capability.
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(model: ModelId): ModelId | undefined {
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 `gpt-5.6-sol` is
207
- // `claude-haiku-4-5`, which is not a rung above anything — it is the previous vendor's list.
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 !== undefined && registry.get(above)?.family === spec.family) return 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: `drop effort from definePrompt, or set model: '${DEFAULT_MODEL}' on the llm() declaration`,
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: `set model: '${DEFAULT_MODEL}' on the llm() declaration, or drop thinking from definePrompt`,
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();
@@ -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`,
@@ -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' | 'user'; readonly content: string }
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
- for (const message of messages) {
77
+ messages.forEach((message, m) => {
64
78
  if (typeof message.content === 'string') {
65
79
  out.push({ role: message.role, content: message.content });
66
- continue;
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
- if (message.role === 'assistant') out.push(assistantMessage(message.content));
69
- else out.push(...toolTurn(message.content));
70
- }
86
+ });
71
87
  return out;
72
88
  }
73
89
 
74
- /** Text blocks concatenate; `tool_use` blocks move onto `tool_calls` with stringified arguments. */
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 that carries tool results. Every result becomes its own `role: 'tool'` message, in
99
- * order and before any prose, because OpenAI requires one tool message per `tool_call_id` the
100
- * previous assistant message asked for, and requires them to come first.
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 toolTurn(blocks: readonly AiContentBlock[]): readonly OpenAiMessage[] {
129
+ function userTurn(
130
+ blocks: readonly AiContentBlock[],
131
+ index: number,
132
+ target: MediaTarget,
133
+ ): readonly OpenAiMessage[] {
103
134
  const out: OpenAiMessage[] = [];
104
- let text = '';
105
- for (const block of blocks) {
106
- if (block.type === 'text') text += block.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
  *