@ultimat3/ai 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/models.ts CHANGED
@@ -1,20 +1,38 @@
1
- // The blessed models: limits, prices, and the reasoning controls each one's request surface
2
- // actually accepts. Apart from ./provider because those controls are NOT uniform across the
3
- // catalogue one body sent to all three is a guaranteed 400 on the oldest, and a downgrade for
4
- // price is not a licence to send a body the provider rejects. As of 2026-08.
1
+ // The model catalogue: an OPEN registry of limits, prices and the reasoning controls each model's
2
+ // request surface actually accepts. Open because a company's own gateway serves ids this package
3
+ // has never heard of a closed union made them untypeable, so the only way past `tsc` was to
4
+ // claim a Claude id and be billed Anthropic list prices for a model nobody ran. As of 2026-08.
5
5
 
6
6
  import type { Money } from '@ultimat3/money';
7
- import { AiRequestInvalidError } from './errors';
7
+ import { AiModelUnknownError, AiRequestInvalidError } from './errors';
8
8
 
9
9
  /**
10
- * Blessed models. Opus 5 is the default; the others are explicit downgrades. IDs are exact alias
11
- * strings never append a date suffix.
10
+ * A model id. A plain `string`, deliberately: the routing seam (`Provider`, `createGateway`) has
11
+ * always been open, and a closed union over it meant the VOCABULARY was not — `models:
12
+ * ['llama-internal-70b']` did not typecheck, so `costOf` charged Anthropic prices for a model the
13
+ * company does not use and the budget ledger reserved against the wrong number.
14
+ *
15
+ * What replaces the union as the guard is `modelSpec()`: an id nothing registered is
16
+ * `X_AI_MODEL_UNKNOWN` at the first read, naming the registered set. A wrong id is still caught —
17
+ * at the call, with a fix line, rather than by making a correct id inexpressible.
18
+ */
19
+ export type ModelId = string;
20
+
21
+ /**
22
+ * The models `AnthropicProvider` serves, in ladder order (most capable first). Its OWN list, not
23
+ * the registry's: an app that registers an internal model must not have it routed to Anthropic.
12
24
  */
13
- export const MODEL_IDS = ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5'] as const;
14
- export type ModelId = (typeof MODEL_IDS)[number];
25
+ export const ANTHROPIC_MODEL_IDS = [
26
+ 'claude-opus-5',
27
+ 'claude-sonnet-5',
28
+ 'claude-haiku-4-5',
29
+ ] as const;
15
30
 
16
31
  export const DEFAULT_MODEL: ModelId = 'claude-opus-5';
17
32
 
33
+ /** The built-in Anthropic rows' ladder. One `family` string, spelled once. */
34
+ const ANTHROPIC_FAMILY = 'anthropic';
35
+
18
36
  /**
19
37
  * Reasoning depth, shallowest first — the order is load-bearing, because a model that caps where
20
38
  * thinking may be switched off compares against it. `xhigh` is the best setting for coding and
@@ -55,6 +73,19 @@ export interface ModelSpec {
55
73
  /** Minimum cacheable prefix; a shorter prefix silently does not cache. */
56
74
  readonly cacheMinimumTokens: number;
57
75
  readonly reasoning: ModelReasoning;
76
+ /**
77
+ * Which ladder this model is a rung on. A capability comparison only means anything inside one
78
+ * — registration order across vendors is arrival order, not capability — so `moreCapableThan`
79
+ * walks up within a family and stops at its boundary. Optional, and absent is its own family:
80
+ * an app that registers its whole catalogue in the order it wants keeps comparing across all of
81
+ * it, exactly as before this field existed.
82
+ *
83
+ * Registering the OpenAI-format rows after the Anthropic ones is what made this load-bearing:
84
+ * the rung above `gpt-5.6-sol` was `claude-haiku-4-5`, so `X_LLM_REFUSED`'s fix line told an
85
+ * operator to paste the cheapest model in the catalogue, from a vendor their gateway may not
86
+ * serve at all.
87
+ */
88
+ readonly family?: string;
58
89
  }
59
90
 
60
91
  /**
@@ -63,46 +94,100 @@ export interface ModelSpec {
63
94
  */
64
95
  const usd = (minor: number): Money => ({ minor, currency: 'USD' });
65
96
 
66
- export const MODELS: Readonly<Record<ModelId, ModelSpec>> = {
67
- // $5 / $25 per MTok.
68
- 'claude-opus-5': {
69
- id: 'claude-opus-5',
70
- contextWindow: 1_000_000,
71
- maxOutput: 128_000,
72
- inputPerMillion: usd(500),
73
- outputPerMillion: usd(2_500),
74
- cacheMinimumTokens: 512,
75
- // Thinking is on by default here, and switching it OFF is legal only at `high` or below.
76
- reasoning: { effort: true, adaptive: true, disableThinkingUpTo: 'high' },
77
- },
78
- // $3 / $15 per MTok. The introductory rate is deliberately NOT modelled: a price that lapses on
79
- // a date makes every recorded cost depend on when it was read, and a budget that under-reports
80
- // spend after the lapse is a budget that is not one. List price over-reserves, which is safe.
81
- 'claude-sonnet-5': {
82
- id: 'claude-sonnet-5',
83
- contextWindow: 1_000_000,
84
- maxOutput: 128_000,
85
- inputPerMillion: usd(300),
86
- outputPerMillion: usd(1_500),
87
- cacheMinimumTokens: 1_024,
88
- reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
89
- },
90
- // $1 / $5 per MTok. Pre-4.6, so it has neither knob: an `output_config.effort` or an adaptive
91
- // `thinking` block sent here is a 400 on every request, which is what made the cheap tier
92
- // uncallable while the request body was one shape for the whole catalogue.
93
- 'claude-haiku-4-5': {
94
- id: 'claude-haiku-4-5',
95
- contextWindow: 200_000,
96
- maxOutput: 64_000,
97
- inputPerMillion: usd(100),
98
- outputPerMillion: usd(500),
99
- cacheMinimumTokens: 4_096,
100
- reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
101
- },
102
- };
97
+ /**
98
+ * Insertion order IS the capability ladder WITHIN a `family`, most capable first —
99
+ * `moreCapableThan` is its only reader, exactly as it was when the ladder was a literal tuple.
100
+ * Across families it is arrival order and means nothing. A `Map` because re-registering
101
+ * an id REPLACES its spec in place without moving its rung, which is what makes a negotiated
102
+ * enterprise rate expressible: one call, same id, new prices, same position in the ladder.
103
+ */
104
+ const registry = new Map<ModelId, ModelSpec>();
105
+
106
+ /**
107
+ * Add a model to the catalogue, or restate one that is already in it. **The three built-ins
108
+ * register through this same call**, at the bottom of this file — so the default path is the
109
+ * app's path and there is exactly one way to put a model in the catalogue.
110
+ *
111
+ * Re-registering an id replaces its spec and keeps its rung. That is deliberate and it is the
112
+ * negotiated-rate mechanism: an app whose contract prices `claude-opus-5` below list registers it
113
+ * again with its own `inputPerMillion`/`outputPerMillion`, and every `costOf`, every budget
114
+ * reservation and every recorded cost is that number from then on. Boot registers after this
115
+ * module is imported, so the app always wins.
116
+ *
117
+ * A model appended after the built-ins is the LEAST capable rung, because that is what appending
118
+ * to a most-capable-first list means. An app that wants its own ladder registers its whole
119
+ * catalogue in the order it wants, re-registering the built-in ids it keeps.
120
+ */
121
+ export function registerModel(spec: ModelSpec): ModelSpec {
122
+ registry.set(spec.id, spec);
123
+ return spec;
124
+ }
125
+
126
+ /** Every registered id, in ladder order. */
127
+ export function modelIds(): readonly ModelId[] {
128
+ return [...registry.keys()];
129
+ }
130
+
131
+ /** Every registered spec, in ladder order. Consumed by `x manifest` and by doctor-style checks. */
132
+ export function registeredModels(): readonly ModelSpec[] {
133
+ return [...registry.values()];
134
+ }
135
+
136
+ export function isModelRegistered(id: ModelId): boolean {
137
+ return registry.has(id);
138
+ }
139
+
140
+ /**
141
+ * The spec behind an id. The ONE read path, and the guard the closed union used to be: an
142
+ * unregistered id throws here — at the pricing, request-building and streaming seams that all
143
+ * call it — rather than silently pricing a foreign model at somebody else's rates.
144
+ */
145
+ export function modelSpec(id: ModelId): ModelSpec {
146
+ const spec = registry.get(id);
147
+ if (spec === undefined) throw new AiModelUnknownError({ model: id, registered: modelIds() });
148
+ return spec;
149
+ }
150
+
151
+ /**
152
+ * Refuse an unregistered id without needing its spec. For a boot-time or `x doctor`-style check
153
+ * AFTER registration has run; every request path reads `modelSpec` instead, and a declaration
154
+ * cannot check at all — an `llm()` is evaluated at module scope, before boot registers anything.
155
+ */
156
+ export function assertModel(id: ModelId): void {
157
+ modelSpec(id);
158
+ }
159
+
160
+ /** Test-only reset back to the built-in catalogue. Module state otherwise leaks between files. */
161
+ export function resetModels(): void {
162
+ registry.clear();
163
+ registerBuiltInModels();
164
+ }
103
165
 
104
166
  const rankOf = (effort: Effort): number => EFFORTS.indexOf(effort);
105
167
 
168
+ /**
169
+ * The model one rung ABOVE `model` IN ITS OWN FAMILY, or `undefined` when it is already the most
170
+ * capable one that family holds. Registration order is most-capable-first — "the others are
171
+ * explicit downgrades" — so the ladder needs no second list to walk; `family` is what stops the
172
+ * walk at the boundary between two vendors' lists, where order is arrival, not capability.
173
+ *
174
+ * A refusal is only worth retrying UPWARD. `MODEL_IDS.find((id) => id !== refused)` answered a
175
+ * refusal on the default model with the next entry DOWN, which is the one retry that cannot help:
176
+ * the fix line told an operator to buy the same refusal from a weaker model.
177
+ */
178
+ export function moreCapableThan(model: ModelId): ModelId | undefined {
179
+ const spec = registry.get(model);
180
+ if (spec === undefined) return undefined;
181
+ const ids = modelIds();
182
+ // Up, but only within the model's own family: the entry before `gpt-5.6-sol` is
183
+ // `claude-haiku-4-5`, which is not a rung above anything — it is the previous vendor's list.
184
+ for (let at = ids.indexOf(model) - 1; at >= 0; at -= 1) {
185
+ const above = ids[at];
186
+ if (above !== undefined && registry.get(above)?.family === spec.family) return above;
187
+ }
188
+ return undefined;
189
+ }
190
+
106
191
  /**
107
192
  * The reasoning half of a Messages body, shaped for one model. Everything it refuses, it refuses
108
193
  * LOCALLY with a real code — a round trip to learn a rule this file already states costs latency
@@ -117,7 +202,7 @@ export function reasoningBody(
117
202
  effort: Effort | undefined,
118
203
  thinking: ThinkingMode | undefined,
119
204
  ): Record<string, unknown> {
120
- const rules = MODELS[model].reasoning;
205
+ const rules = modelSpec(model).reasoning;
121
206
  const body: Record<string, unknown> = {};
122
207
 
123
208
  if (effort !== undefined && !rules.effort) {
@@ -143,12 +228,16 @@ export function reasoningBody(
143
228
  return body;
144
229
  }
145
230
 
146
- if ((thinking ?? 'adaptive') === 'disabled') {
231
+ if (thinking === 'disabled') {
147
232
  assertDisableAllowed(model, rules, effort ?? 'high');
148
233
  body['thinking'] = { type: 'disabled' };
149
234
  return body;
150
235
  }
151
- body['thinking'] = { type: 'adaptive', display: 'summarized' };
236
+ // Nothing asked for, nothing sent — the rule this file states, now the rule it follows.
237
+ // Adaptive is the server's own default on every model that has it, so emitting the block
238
+ // unrequested bought nothing and made a defaulted control indistinguishable on the wire from
239
+ // a declared one.
240
+ if (thinking === 'adaptive') body['thinking'] = { type: 'adaptive', display: 'summarized' };
152
241
  return body;
153
242
  }
154
243
 
@@ -161,3 +250,51 @@ function assertDisableAllowed(model: ModelId, rules: ModelReasoning, effort: Eff
161
250
  fix: `set effort: '${cap}' in definePrompt alongside thinking: 'disabled', or drop thinking from it`,
162
251
  });
163
252
  }
253
+
254
+ /**
255
+ * The blessed models. Opus 5 is the default; the others are explicit downgrades. IDs are exact
256
+ * alias strings — never append a date suffix. Registered through the public `registerModel`, in
257
+ * ladder order, so nothing about the built-in path is a private door an app cannot use.
258
+ */
259
+ function registerBuiltInModels(): void {
260
+ // $5 / $25 per MTok.
261
+ registerModel({
262
+ id: 'claude-opus-5',
263
+ family: ANTHROPIC_FAMILY,
264
+ contextWindow: 1_000_000,
265
+ maxOutput: 128_000,
266
+ inputPerMillion: usd(500),
267
+ outputPerMillion: usd(2_500),
268
+ cacheMinimumTokens: 512,
269
+ // Thinking is on by default here, and switching it OFF is legal only at `high` or below.
270
+ reasoning: { effort: true, adaptive: true, disableThinkingUpTo: 'high' },
271
+ });
272
+ // $3 / $15 per MTok. The introductory rate is deliberately NOT modelled: a price that lapses on
273
+ // a date makes every recorded cost depend on when it was read, and a budget that under-reports
274
+ // spend after the lapse is a budget that is not one. List price over-reserves, which is safe.
275
+ registerModel({
276
+ id: 'claude-sonnet-5',
277
+ family: ANTHROPIC_FAMILY,
278
+ contextWindow: 1_000_000,
279
+ maxOutput: 128_000,
280
+ inputPerMillion: usd(300),
281
+ outputPerMillion: usd(1_500),
282
+ cacheMinimumTokens: 1_024,
283
+ reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
284
+ });
285
+ // $1 / $5 per MTok. Pre-4.6, so it has neither knob: an `output_config.effort` or an adaptive
286
+ // `thinking` block sent here is a 400 on every request, which is what made the cheap tier
287
+ // uncallable while the request body was one shape for the whole catalogue.
288
+ registerModel({
289
+ id: 'claude-haiku-4-5',
290
+ family: ANTHROPIC_FAMILY,
291
+ contextWindow: 200_000,
292
+ maxOutput: 64_000,
293
+ inputPerMillion: usd(100),
294
+ outputPerMillion: usd(500),
295
+ cacheMinimumTokens: 4_096,
296
+ reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
297
+ });
298
+ }
299
+
300
+ registerBuiltInModels();
@@ -0,0 +1,96 @@
1
+ // Single responsibility: one chat-completions request body, assembled from a `GenerateRequest`.
2
+ // Pure and side-effect free, so what leaves the process is asserted directly in a test.
3
+ //
4
+ // The per-model rules live in the model's spec, never in an `if` here — same rule as
5
+ // `reasoningBody()`: adding a model an endpoint serves is a `registerModel` row, not a branch.
6
+
7
+ import { AiRequestInvalidError } from './errors';
8
+ import type { Effort, ModelId, ThinkingMode } from './models';
9
+ import { modelSpec } from './models';
10
+ import { toOpenAiMessages, toOpenAiTools, toolChoiceFor } from './openai-messages';
11
+ import type { GenerateRequest } from './provider';
12
+
13
+ export interface ChatCompletionBodyInput {
14
+ readonly request: GenerateRequest;
15
+ /** Already resolved — the gateway picks the model, the provider never guesses mid-request. */
16
+ readonly model: ModelId;
17
+ readonly stream: boolean;
18
+ }
19
+
20
+ /**
21
+ * The body. What is deliberately absent is as load-bearing as what is present:
22
+ * - no `temperature` / `top_p` / `presence_penalty`. Steering is the prompt's job, and a sampling
23
+ * knob on a reasoning model in this family is a 400.
24
+ * - no `response_format`. The output schema is projected into the `respond` tool by `llm()`, and
25
+ * that projection is the framework's ONE structured-output path — see the README.
26
+ * - `max_completion_tokens`, never the deprecated `max_tokens`, which current reasoning models
27
+ * reject outright.
28
+ */
29
+ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<string, unknown> {
30
+ const { request, model, stream } = input;
31
+ const spec = modelSpec(model);
32
+ const body: Record<string, unknown> = {
33
+ model,
34
+ messages: toOpenAiMessages(request.system, request.messages),
35
+ max_completion_tokens: Math.min(request.maxTokens, spec.maxOutput),
36
+ ...reasoningFields(model, request.effort, request.thinking),
37
+ };
38
+ if (request.tools !== undefined && request.tools.length > 0) {
39
+ body['tools'] = toOpenAiTools(request.tools);
40
+ const choice = toolChoiceFor(request.tools);
41
+ if (choice !== undefined) body['tool_choice'] = choice;
42
+ }
43
+ if (request.stopSequences !== undefined && request.stopSequences.length > 0) {
44
+ body['stop'] = request.stopSequences;
45
+ }
46
+ if (stream) {
47
+ body['stream'] = true;
48
+ // Without this the final chunk carries no `usage` and the budget reconciles against nothing —
49
+ // a full refund of the reservation for a call that really happened. It is one field and it is
50
+ // the difference between a ledger and a decoration.
51
+ body['stream_options'] = { include_usage: true };
52
+ }
53
+ return body;
54
+ }
55
+
56
+ /**
57
+ * The reasoning half, shaped for one model. Refused LOCALLY when the model's spec says the endpoint
58
+ * has no such control, for the reason models.ts states: a round trip to learn a rule the registry
59
+ * already holds costs latency and teaches nothing.
60
+ *
61
+ * `reasoning_effort` is one field carrying two of the framework's controls, so asking for both is
62
+ * refused rather than resolved — a declaration that reads `effort: 'max'` next to
63
+ * `thinking: 'disabled'` cannot have both, and picking one silently is the failure nobody sees.
64
+ */
65
+ export function reasoningFields(
66
+ model: ModelId,
67
+ effort: Effort | undefined,
68
+ thinking: ThinkingMode | undefined,
69
+ ): Record<string, unknown> {
70
+ const rules = modelSpec(model).reasoning;
71
+ if (effort !== undefined && !rules.effort) {
72
+ throw new AiRequestInvalidError({
73
+ detail: `model "${model}" is registered with no effort control, so reasoning_effort cannot be sent to it`,
74
+ fix: 'drop effort from definePrompt, or re-register the model with reasoning: { effort: true } if its endpoint accepts reasoning_effort',
75
+ });
76
+ }
77
+ if (thinking === 'adaptive' && !rules.adaptive) {
78
+ throw new AiRequestInvalidError({
79
+ detail: `model "${model}" has no adaptive thinking; the OpenAI format's only depth control is reasoning_effort`,
80
+ fix: 'drop thinking from definePrompt and set effort instead, or route the prompt to a model registered with reasoning: { adaptive: true }',
81
+ });
82
+ }
83
+ if (thinking === 'disabled' && effort !== undefined) {
84
+ throw new AiRequestInvalidError({
85
+ detail: `model "${model}" writes both thinking and effort onto one reasoning_effort field, and the request asked for both`,
86
+ fix: "drop one from definePrompt: keep thinking: 'disabled', or keep effort",
87
+ });
88
+ }
89
+ // `none` IS the off switch on this wire, and a model with no effort control has nothing to
90
+ // switch off — so it sends nothing rather than a field the endpoint would reject.
91
+ if (thinking === 'disabled') return rules.effort ? { reasoning_effort: 'none' } : {};
92
+ if (effort !== undefined) return { reasoning_effort: effort };
93
+ // Nothing asked for, nothing sent. Adaptive depth is the server's own default here, so emitting
94
+ // anything for it would make a defaulted control indistinguishable from a declared one.
95
+ return {};
96
+ }
@@ -0,0 +1,174 @@
1
+ // Single responsibility: the REQUEST half of the OpenAI chat-completions format — `AiMessage`
2
+ // (which carries Anthropic's block names) onto OpenAI's messages, and `LlmTool` onto its functions.
3
+ //
4
+ // This mapping is the whole reason the provider exists: the two formats disagree about where a
5
+ // system prompt lives, how an assistant asks for a tool, and how a tool answers. Pure functions, so
6
+ // every disagreement is a unit test with no socket.
7
+
8
+ import type { AiContentBlock, AiMessage } from './provider';
9
+ import type { JsonSchema, LlmTool } from './tools';
10
+
11
+ export interface OpenAiToolCall {
12
+ readonly id: string;
13
+ readonly type: 'function';
14
+ /** Arguments are a JSON STRING on this wire, not an object. The one field everybody gets wrong. */
15
+ readonly function: { readonly name: string; readonly arguments: string };
16
+ }
17
+
18
+ export type OpenAiMessage =
19
+ | { readonly role: 'system' | 'user'; readonly content: string }
20
+ | {
21
+ readonly role: 'assistant';
22
+ readonly content?: string | undefined;
23
+ readonly tool_calls?: readonly OpenAiToolCall[] | undefined;
24
+ }
25
+ | { readonly role: 'tool'; readonly tool_call_id: string; readonly content: string };
26
+
27
+ export interface OpenAiFunctionTool {
28
+ readonly type: 'function';
29
+ readonly function: {
30
+ readonly name: string;
31
+ readonly description: string;
32
+ /** OpenAI calls it `parameters`; the JSON Schema inside is byte-identical to `input_schema`. */
33
+ readonly parameters: JsonSchema;
34
+ readonly strict?: boolean | undefined;
35
+ };
36
+ }
37
+
38
+ /** `tool_choice`, when the framework is forcing one. Named function, the only forcing shape. */
39
+ export interface OpenAiToolChoice {
40
+ readonly type: 'function';
41
+ readonly function: { readonly name: string };
42
+ }
43
+
44
+ /**
45
+ * The conversation, translated. Three structural differences, each of which silently corrupts a
46
+ * transcript if it is missed:
47
+ *
48
+ * - the system prompt is a MESSAGE here, not a top-level field, and it must lead;
49
+ * - an assistant's `tool_use` blocks become `tool_calls` ON the assistant message, with their
50
+ * arguments serialised to a string;
51
+ * - a `tool_result` block is not a user block at all — it is its own `role: 'tool'` message,
52
+ * one per result, keyed by `tool_call_id`.
53
+ *
54
+ * `system` rather than `developer`: the newer role is OpenAI's alone, and every other server
55
+ * speaking this format — vLLM, Ollama, LiteLLM, Together — knows only `system`. OpenAI accepts it.
56
+ */
57
+ export function toOpenAiMessages(
58
+ system: string | undefined,
59
+ messages: readonly AiMessage[],
60
+ ): readonly OpenAiMessage[] {
61
+ const out: OpenAiMessage[] = [];
62
+ if (system !== undefined && system !== '') out.push({ role: 'system', content: system });
63
+ for (const message of messages) {
64
+ if (typeof message.content === 'string') {
65
+ out.push({ role: message.role, content: message.content });
66
+ continue;
67
+ }
68
+ if (message.role === 'assistant') out.push(assistantMessage(message.content));
69
+ else out.push(...toolTurn(message.content));
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Text blocks concatenate; `tool_use` blocks move onto `tool_calls` with stringified arguments. */
75
+ function assistantMessage(blocks: readonly AiContentBlock[]): OpenAiMessage {
76
+ let content = '';
77
+ const toolCalls: OpenAiToolCall[] = [];
78
+ for (const block of blocks) {
79
+ if (block.type === 'text') content += block.text;
80
+ if (block.type === 'tool_use') {
81
+ toolCalls.push({
82
+ id: block.id,
83
+ type: 'function',
84
+ function: { name: block.name, arguments: JSON.stringify(block.input) },
85
+ });
86
+ }
87
+ }
88
+ // Content is OMITTED, not empty-stringed, when the turn was only tool calls: an assistant message
89
+ // carrying both an empty string and `tool_calls` is rejected by some servers in the family.
90
+ return {
91
+ role: 'assistant',
92
+ ...(content === '' ? {} : { content }),
93
+ ...(toolCalls.length === 0 ? {} : { tool_calls: toolCalls }),
94
+ };
95
+ }
96
+
97
+ /**
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.
101
+ */
102
+ function toolTurn(blocks: readonly AiContentBlock[]): readonly OpenAiMessage[] {
103
+ const out: OpenAiMessage[] = [];
104
+ let text = '';
105
+ for (const block of blocks) {
106
+ if (block.type === 'text') text += block.text;
107
+ if (block.type === 'tool_result') {
108
+ out.push({
109
+ role: 'tool',
110
+ tool_call_id: block.tool_use_id,
111
+ // There is no `is_error` on this wire, and dropping the flag would hand the model a failure
112
+ // that reads as data. The marker is the format's only place to say so.
113
+ content: block.is_error === true ? `error: ${block.content}` : block.content,
114
+ });
115
+ }
116
+ }
117
+ if (text !== '') out.push({ role: 'user', content: text });
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Tool definitions, wrapped in the `{ type: 'function', function: … }` envelope.
123
+ *
124
+ * `strict` is claimed only when the projected schema actually satisfies OpenAI's strict rules.
125
+ * `LlmTool.strict` is `true` on every projection the framework makes, but on THIS wire the flag is
126
+ * a promise the server checks: a schema with an optional field — one key in `properties` and not in
127
+ * `required` — is a 400 (`Invalid schema for function …`) rather than a looser check. So the flag
128
+ * is derived from the schema, never forwarded, and a schema that cannot keep the promise is sent
129
+ * without it and validated by the output schema on the way back, exactly as the Anthropic path is.
130
+ */
131
+ export function toOpenAiTools(tools: readonly LlmTool[]): readonly OpenAiFunctionTool[] {
132
+ return tools.map((tool) => ({
133
+ type: 'function',
134
+ function: {
135
+ name: tool.name,
136
+ description: tool.description,
137
+ parameters: tool.input_schema,
138
+ ...(tool.strict === true && satisfiesStrictMode(tool.input_schema) ? { strict: true } : {}),
139
+ },
140
+ }));
141
+ }
142
+
143
+ /**
144
+ * Whether a schema keeps OpenAI's strict-mode promise: every object closed with
145
+ * `additionalProperties: false`, and every one of its keys listed in `required`. Recursive, because
146
+ * the server checks it recursively.
147
+ */
148
+ export function satisfiesStrictMode(schema: JsonSchema): boolean {
149
+ if (schema.items !== undefined && !satisfiesStrictMode(schema.items)) return false;
150
+ if (schema.type !== 'object' && schema.properties === undefined) return true;
151
+ if (schema.additionalProperties !== false) return false;
152
+ const properties = schema.properties ?? {};
153
+ const required = new Set(schema.required ?? []);
154
+ for (const [key, child] of Object.entries(properties)) {
155
+ if (!required.has(key)) return false;
156
+ if (!satisfiesStrictMode(child)) return false;
157
+ }
158
+ return true;
159
+ }
160
+
161
+ /**
162
+ * `tool_choice`, or nothing. Forced when the request offers EXACTLY ONE tool, which is precisely
163
+ * the shape `llm()` builds: the `respond` projection of the output schema, with the instruction to
164
+ * answer through it. One tool is nothing to choose between, and left to `auto` the family answers
165
+ * in prose often enough that structured output becomes a repair turn on every second call.
166
+ *
167
+ * Never forced when a tool loop is running: `agent()` offers the app's tools alongside `respond`,
168
+ * and forcing a name there would decide the loop's next step for the model.
169
+ */
170
+ export function toolChoiceFor(tools: readonly LlmTool[]): OpenAiToolChoice | undefined {
171
+ const only = tools.length === 1 ? tools[0] : undefined;
172
+ if (only === undefined) return undefined;
173
+ return { type: 'function', function: { name: only.name } };
174
+ }
@@ -0,0 +1,84 @@
1
+ // The OpenAI-format built-in catalogue: the vendor's own list prices, registered through the same
2
+ // public `registerModel` the Anthropic built-ins use. Here rather than in models.ts because these
3
+ // rows belong to a PROVIDER — models.ts owns the registry mechanism, never one vendor's price list.
4
+
5
+ import type { Money } from '@ultimat3/money';
6
+ import type { ModelId } from './models';
7
+ import { registerModel } from './models';
8
+
9
+ /** A price per million tokens, in INTEGER MINOR UNITS. Same rule as models.ts: never a float. */
10
+ const usd = (minor: number): Money => ({ minor, currency: 'USD' });
11
+
12
+ /**
13
+ * The ids this package prices, in ladder order (most capable first). A provider's `models` list is
14
+ * still its own — an endpoint speaking this format serves whatever ids it was deployed with, and
15
+ * `openAiProvider({ models })` is where those are named.
16
+ */
17
+ export const OPENAI_MODEL_IDS: readonly ModelId[] = [
18
+ 'gpt-5.6-sol',
19
+ 'gpt-5.6-terra',
20
+ 'gpt-5.6-luna',
21
+ ];
22
+
23
+ /**
24
+ * Shared by the whole 5.6 family: a 1.05M context, a 128k output ceiling, and `reasoning_effort`
25
+ * over exactly the five rungs `EFFORTS` declares (the endpoint also takes `none`, which is what
26
+ * `thinking: 'disabled'` maps onto). `adaptive` is false because the format has no
27
+ * adaptive-thinking control at all — depth is `reasoning_effort` and nothing else.
28
+ */
29
+ const FAMILY = {
30
+ /** One ladder: `moreCapableThan` compares these three with each other and with nothing else. */
31
+ family: 'openai',
32
+ contextWindow: 1_050_000,
33
+ maxOutput: 128_000,
34
+ /** Automatic caching starts at a 1024-token prefix; a shorter one silently does not cache. */
35
+ cacheMinimumTokens: 1_024,
36
+ reasoning: { effort: true, adaptive: false, disableThinkingUpTo: undefined },
37
+ } as const;
38
+
39
+ /**
40
+ * The three models this package is confident enough to price, and no more.
41
+ *
42
+ * Prices are the vendor's published list, read from developers.openai.com/api/docs/pricing on
43
+ * **2026-08-16**, in USD per million tokens:
44
+ *
45
+ * | id | input | cached input | output |
46
+ * |---|---|---|---|
47
+ * | `gpt-5.6-sol` | $5.00 | $0.50 | $30.00 |
48
+ * | `gpt-5.6-terra` | $2.00 | $0.20 | $12.00 |
49
+ * | `gpt-5.6-luna` | $0.20 | $0.02 | $1.20 |
50
+ *
51
+ * Deliberately not registered: `gpt-4o`, `gpt-4o-mini` and the `o1` family, whose cached input is
52
+ * **0.5x** their input rate rather than the 0.1x `costOf` assumes — a spec that prices them would
53
+ * under-report a cache-heavy workload by four fifths, and `costOf` answers confidently either way.
54
+ * `gpt-5.5-pro` and `o1-pro` are out for the same class of reason: they publish no cached rate.
55
+ * A wrong price is worse than no entry, so an app wanting one of those registers it itself, with
56
+ * the rate its own contract names.
57
+ */
58
+ export function registerOpenAiModels(): void {
59
+ registerModel({
60
+ id: 'gpt-5.6-sol',
61
+ ...FAMILY,
62
+ inputPerMillion: usd(500),
63
+ outputPerMillion: usd(3_000),
64
+ });
65
+ registerModel({
66
+ id: 'gpt-5.6-terra',
67
+ ...FAMILY,
68
+ inputPerMillion: usd(200),
69
+ outputPerMillion: usd(1_200),
70
+ });
71
+ registerModel({
72
+ id: 'gpt-5.6-luna',
73
+ ...FAMILY,
74
+ inputPerMillion: usd(20),
75
+ outputPerMillion: usd(120),
76
+ });
77
+ }
78
+
79
+ // The same shape models.ts uses for its built-ins: registration is a module side effect, so the
80
+ // default path is the app's path and importing the provider is enough to price what it serves.
81
+ // Exported as well as called, because the registry is module state and a suite that clears it with
82
+ // `resetModels()` otherwise leaves this provider serving ids nothing can price. Re-registering
83
+ // REPLACES, so an app with a negotiated rate calls `registerModel` after this one and wins.
84
+ registerOpenAiModels();