@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/CLAUDE.md +521 -0
- package/README.md +367 -6
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +396 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +144 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +57 -19
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +55 -9
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +274 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +112 -38
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +107 -11
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
package/src/models.ts
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// actually accepts.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The model catalogue: an OPEN registry of limits, prices and the reasoning controls each model's
|
|
2
|
+
// request surface actually accepts. Open because a company's own gateway serves ids this package
|
|
3
|
+
// has never heard of — a closed union made them untypeable, so the only way past `tsc` was to
|
|
4
|
+
// claim a Claude id and be billed Anthropic list prices for a model nobody ran. As of 2026-08.
|
|
5
5
|
|
|
6
6
|
import type { Money } from '@ultimat3/money';
|
|
7
|
-
import { AiRequestInvalidError } from './errors';
|
|
7
|
+
import { AiModelUnknownError, AiRequestInvalidError } from './errors';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* A model id. A plain `string`, deliberately: the routing seam (`Provider`, `createGateway`) has
|
|
11
|
+
* always been open, and a closed union over it meant the VOCABULARY was not — `models:
|
|
12
|
+
* ['llama-internal-70b']` did not typecheck, so `costOf` charged Anthropic prices for a model the
|
|
13
|
+
* company does not use and the budget ledger reserved against the wrong number.
|
|
14
|
+
*
|
|
15
|
+
* What replaces the union as the guard is `modelSpec()`: an id nothing registered is
|
|
16
|
+
* `X_AI_MODEL_UNKNOWN` at the first read, naming the registered set. A wrong id is still caught —
|
|
17
|
+
* at the call, with a fix line, rather than by making a correct id inexpressible.
|
|
18
|
+
*/
|
|
19
|
+
export type ModelId = string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The models `AnthropicProvider` serves, in ladder order (most capable first). Its OWN list, not
|
|
23
|
+
* the registry's: an app that registers an internal model must not have it routed to Anthropic.
|
|
12
24
|
*/
|
|
13
|
-
export const
|
|
14
|
-
|
|
25
|
+
export const ANTHROPIC_MODEL_IDS = [
|
|
26
|
+
'claude-opus-5',
|
|
27
|
+
'claude-sonnet-5',
|
|
28
|
+
'claude-haiku-4-5',
|
|
29
|
+
] as const;
|
|
15
30
|
|
|
16
31
|
export const DEFAULT_MODEL: ModelId = 'claude-opus-5';
|
|
17
32
|
|
|
33
|
+
/** The built-in Anthropic rows' ladder. One `family` string, spelled once. */
|
|
34
|
+
const ANTHROPIC_FAMILY = 'anthropic';
|
|
35
|
+
|
|
18
36
|
/**
|
|
19
37
|
* Reasoning depth, shallowest first — the order is load-bearing, because a model that caps where
|
|
20
38
|
* thinking may be switched off compares against it. `xhigh` is the best setting for coding and
|
|
@@ -55,6 +73,19 @@ export interface ModelSpec {
|
|
|
55
73
|
/** Minimum cacheable prefix; a shorter prefix silently does not cache. */
|
|
56
74
|
readonly cacheMinimumTokens: number;
|
|
57
75
|
readonly reasoning: ModelReasoning;
|
|
76
|
+
/**
|
|
77
|
+
* Which ladder this model is a rung on. A capability comparison only means anything inside one
|
|
78
|
+
* — registration order across vendors is arrival order, not capability — so `moreCapableThan`
|
|
79
|
+
* walks up within a family and stops at its boundary. Optional, and absent is its own family:
|
|
80
|
+
* an app that registers its whole catalogue in the order it wants keeps comparing across all of
|
|
81
|
+
* it, exactly as before this field existed.
|
|
82
|
+
*
|
|
83
|
+
* Registering the OpenAI-format rows after the Anthropic ones is what made this load-bearing:
|
|
84
|
+
* the rung above `gpt-5.6-sol` was `claude-haiku-4-5`, so `X_LLM_REFUSED`'s fix line told an
|
|
85
|
+
* operator to paste the cheapest model in the catalogue, from a vendor their gateway may not
|
|
86
|
+
* serve at all.
|
|
87
|
+
*/
|
|
88
|
+
readonly family?: string;
|
|
58
89
|
}
|
|
59
90
|
|
|
60
91
|
/**
|
|
@@ -63,46 +94,100 @@ export interface ModelSpec {
|
|
|
63
94
|
*/
|
|
64
95
|
const usd = (minor: number): Money => ({ minor, currency: 'USD' });
|
|
65
96
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Insertion order IS the capability ladder WITHIN a `family`, most capable first —
|
|
99
|
+
* `moreCapableThan` is its only reader, exactly as it was when the ladder was a literal tuple.
|
|
100
|
+
* Across families it is arrival order and means nothing. A `Map` because re-registering
|
|
101
|
+
* an id REPLACES its spec in place without moving its rung, which is what makes a negotiated
|
|
102
|
+
* enterprise rate expressible: one call, same id, new prices, same position in the ladder.
|
|
103
|
+
*/
|
|
104
|
+
const registry = new Map<ModelId, ModelSpec>();
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Add a model to the catalogue, or restate one that is already in it. **The three built-ins
|
|
108
|
+
* register through this same call**, at the bottom of this file — so the default path is the
|
|
109
|
+
* app's path and there is exactly one way to put a model in the catalogue.
|
|
110
|
+
*
|
|
111
|
+
* Re-registering an id replaces its spec and keeps its rung. That is deliberate and it is the
|
|
112
|
+
* negotiated-rate mechanism: an app whose contract prices `claude-opus-5` below list registers it
|
|
113
|
+
* again with its own `inputPerMillion`/`outputPerMillion`, and every `costOf`, every budget
|
|
114
|
+
* reservation and every recorded cost is that number from then on. Boot registers after this
|
|
115
|
+
* module is imported, so the app always wins.
|
|
116
|
+
*
|
|
117
|
+
* A model appended after the built-ins is the LEAST capable rung, because that is what appending
|
|
118
|
+
* to a most-capable-first list means. An app that wants its own ladder registers its whole
|
|
119
|
+
* catalogue in the order it wants, re-registering the built-in ids it keeps.
|
|
120
|
+
*/
|
|
121
|
+
export function registerModel(spec: ModelSpec): ModelSpec {
|
|
122
|
+
registry.set(spec.id, spec);
|
|
123
|
+
return spec;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every registered id, in ladder order. */
|
|
127
|
+
export function modelIds(): readonly ModelId[] {
|
|
128
|
+
return [...registry.keys()];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every registered spec, in ladder order. Consumed by `x manifest` and by doctor-style checks. */
|
|
132
|
+
export function registeredModels(): readonly ModelSpec[] {
|
|
133
|
+
return [...registry.values()];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isModelRegistered(id: ModelId): boolean {
|
|
137
|
+
return registry.has(id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The spec behind an id. The ONE read path, and the guard the closed union used to be: an
|
|
142
|
+
* unregistered id throws here — at the pricing, request-building and streaming seams that all
|
|
143
|
+
* call it — rather than silently pricing a foreign model at somebody else's rates.
|
|
144
|
+
*/
|
|
145
|
+
export function modelSpec(id: ModelId): ModelSpec {
|
|
146
|
+
const spec = registry.get(id);
|
|
147
|
+
if (spec === undefined) throw new AiModelUnknownError({ model: id, registered: modelIds() });
|
|
148
|
+
return spec;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Refuse an unregistered id without needing its spec. For a boot-time or `x doctor`-style check
|
|
153
|
+
* AFTER registration has run; every request path reads `modelSpec` instead, and a declaration
|
|
154
|
+
* cannot check at all — an `llm()` is evaluated at module scope, before boot registers anything.
|
|
155
|
+
*/
|
|
156
|
+
export function assertModel(id: ModelId): void {
|
|
157
|
+
modelSpec(id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Test-only reset back to the built-in catalogue. Module state otherwise leaks between files. */
|
|
161
|
+
export function resetModels(): void {
|
|
162
|
+
registry.clear();
|
|
163
|
+
registerBuiltInModels();
|
|
164
|
+
}
|
|
103
165
|
|
|
104
166
|
const rankOf = (effort: Effort): number => EFFORTS.indexOf(effort);
|
|
105
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The model one rung ABOVE `model` IN ITS OWN FAMILY, or `undefined` when it is already the most
|
|
170
|
+
* capable one that family holds. Registration order is most-capable-first — "the others are
|
|
171
|
+
* explicit downgrades" — so the ladder needs no second list to walk; `family` is what stops the
|
|
172
|
+
* walk at the boundary between two vendors' lists, where order is arrival, not capability.
|
|
173
|
+
*
|
|
174
|
+
* A refusal is only worth retrying UPWARD. `MODEL_IDS.find((id) => id !== refused)` answered a
|
|
175
|
+
* refusal on the default model with the next entry DOWN, which is the one retry that cannot help:
|
|
176
|
+
* the fix line told an operator to buy the same refusal from a weaker model.
|
|
177
|
+
*/
|
|
178
|
+
export function moreCapableThan(model: ModelId): ModelId | undefined {
|
|
179
|
+
const spec = registry.get(model);
|
|
180
|
+
if (spec === undefined) return undefined;
|
|
181
|
+
const ids = modelIds();
|
|
182
|
+
// Up, but only within the model's own family: the entry before `gpt-5.6-sol` is
|
|
183
|
+
// `claude-haiku-4-5`, which is not a rung above anything — it is the previous vendor's list.
|
|
184
|
+
for (let at = ids.indexOf(model) - 1; at >= 0; at -= 1) {
|
|
185
|
+
const above = ids[at];
|
|
186
|
+
if (above !== undefined && registry.get(above)?.family === spec.family) return above;
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
106
191
|
/**
|
|
107
192
|
* The reasoning half of a Messages body, shaped for one model. Everything it refuses, it refuses
|
|
108
193
|
* LOCALLY with a real code — a round trip to learn a rule this file already states costs latency
|
|
@@ -117,7 +202,7 @@ export function reasoningBody(
|
|
|
117
202
|
effort: Effort | undefined,
|
|
118
203
|
thinking: ThinkingMode | undefined,
|
|
119
204
|
): Record<string, unknown> {
|
|
120
|
-
const rules =
|
|
205
|
+
const rules = modelSpec(model).reasoning;
|
|
121
206
|
const body: Record<string, unknown> = {};
|
|
122
207
|
|
|
123
208
|
if (effort !== undefined && !rules.effort) {
|
|
@@ -143,12 +228,16 @@ export function reasoningBody(
|
|
|
143
228
|
return body;
|
|
144
229
|
}
|
|
145
230
|
|
|
146
|
-
if (
|
|
231
|
+
if (thinking === 'disabled') {
|
|
147
232
|
assertDisableAllowed(model, rules, effort ?? 'high');
|
|
148
233
|
body['thinking'] = { type: 'disabled' };
|
|
149
234
|
return body;
|
|
150
235
|
}
|
|
151
|
-
|
|
236
|
+
// Nothing asked for, nothing sent — the rule this file states, now the rule it follows.
|
|
237
|
+
// Adaptive is the server's own default on every model that has it, so emitting the block
|
|
238
|
+
// unrequested bought nothing and made a defaulted control indistinguishable on the wire from
|
|
239
|
+
// a declared one.
|
|
240
|
+
if (thinking === 'adaptive') body['thinking'] = { type: 'adaptive', display: 'summarized' };
|
|
152
241
|
return body;
|
|
153
242
|
}
|
|
154
243
|
|
|
@@ -161,3 +250,51 @@ function assertDisableAllowed(model: ModelId, rules: ModelReasoning, effort: Eff
|
|
|
161
250
|
fix: `set effort: '${cap}' in definePrompt alongside thinking: 'disabled', or drop thinking from it`,
|
|
162
251
|
});
|
|
163
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The blessed models. Opus 5 is the default; the others are explicit downgrades. IDs are exact
|
|
256
|
+
* alias strings — never append a date suffix. Registered through the public `registerModel`, in
|
|
257
|
+
* ladder order, so nothing about the built-in path is a private door an app cannot use.
|
|
258
|
+
*/
|
|
259
|
+
function registerBuiltInModels(): void {
|
|
260
|
+
// $5 / $25 per MTok.
|
|
261
|
+
registerModel({
|
|
262
|
+
id: 'claude-opus-5',
|
|
263
|
+
family: ANTHROPIC_FAMILY,
|
|
264
|
+
contextWindow: 1_000_000,
|
|
265
|
+
maxOutput: 128_000,
|
|
266
|
+
inputPerMillion: usd(500),
|
|
267
|
+
outputPerMillion: usd(2_500),
|
|
268
|
+
cacheMinimumTokens: 512,
|
|
269
|
+
// Thinking is on by default here, and switching it OFF is legal only at `high` or below.
|
|
270
|
+
reasoning: { effort: true, adaptive: true, disableThinkingUpTo: 'high' },
|
|
271
|
+
});
|
|
272
|
+
// $3 / $15 per MTok. The introductory rate is deliberately NOT modelled: a price that lapses on
|
|
273
|
+
// a date makes every recorded cost depend on when it was read, and a budget that under-reports
|
|
274
|
+
// spend after the lapse is a budget that is not one. List price over-reserves, which is safe.
|
|
275
|
+
registerModel({
|
|
276
|
+
id: 'claude-sonnet-5',
|
|
277
|
+
family: ANTHROPIC_FAMILY,
|
|
278
|
+
contextWindow: 1_000_000,
|
|
279
|
+
maxOutput: 128_000,
|
|
280
|
+
inputPerMillion: usd(300),
|
|
281
|
+
outputPerMillion: usd(1_500),
|
|
282
|
+
cacheMinimumTokens: 1_024,
|
|
283
|
+
reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
|
|
284
|
+
});
|
|
285
|
+
// $1 / $5 per MTok. Pre-4.6, so it has neither knob: an `output_config.effort` or an adaptive
|
|
286
|
+
// `thinking` block sent here is a 400 on every request, which is what made the cheap tier
|
|
287
|
+
// uncallable while the request body was one shape for the whole catalogue.
|
|
288
|
+
registerModel({
|
|
289
|
+
id: 'claude-haiku-4-5',
|
|
290
|
+
family: ANTHROPIC_FAMILY,
|
|
291
|
+
contextWindow: 200_000,
|
|
292
|
+
maxOutput: 64_000,
|
|
293
|
+
inputPerMillion: usd(100),
|
|
294
|
+
outputPerMillion: usd(500),
|
|
295
|
+
cacheMinimumTokens: 4_096,
|
|
296
|
+
reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
registerBuiltInModels();
|
|
@@ -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();
|