@ultimat3/ai 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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();
@@ -0,0 +1,260 @@
1
+ // Single responsibility: `openAiProvider()` — a `Provider` speaking the OpenAI chat-completions
2
+ // wire FORMAT, against whatever endpoint is configured.
3
+ //
4
+ // The format matters more than the vendor: Azure OpenAI, vLLM, Ollama, LiteLLM, OpenRouter,
5
+ // Together and most self-hosted company gateways serve it, so one provider plus a `baseUrl` is the
6
+ // difference between "Ultimate talks to models" and "Ultimate talks to OUR models". The request
7
+ // half is ./openai-body, the response half is ./openai-wire; this file owns the socket, the
8
+ // credential and the errors.
9
+
10
+ import type { Secret } from '@ultimat3/core';
11
+ import { isSecret, revealSecret } from '@ultimat3/core';
12
+ import { detailOf, withoutKey } from './error-body';
13
+ import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
14
+ import type { ModelId } from './models';
15
+ import { chatCompletionBody } from './openai-body';
16
+ // Imported for its registration side effect: a provider that cannot price what it serves throws
17
+ // X_AI_MODEL_UNKNOWN at the first call, and the specs belong with the format that names them.
18
+ import './openai-models';
19
+ import type { ChatAnswer } from './openai-wire';
20
+ import { ChatCompletionStream, parseChatCompletion } from './openai-wire';
21
+ import type {
22
+ GenerateRequest,
23
+ GenerateResult,
24
+ Provider,
25
+ StreamChunk,
26
+ TokenUsage,
27
+ } from './provider';
28
+ import { costOf, estimateInputTokens, estimateTextTokens, requiresStreaming } from './provider';
29
+ import { readSse } from './sse';
30
+
31
+ const API_KEY_ENV = 'OPENAI_API_KEY';
32
+ const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
33
+
34
+ export interface OpenAiProviderInput {
35
+ /**
36
+ * A `Secret` for preference — it redacts by value, so the same key is safe in a log line, an
37
+ * error `meta` and a snapshot. A plain string is accepted because an env var already is one.
38
+ * Reads `OPENAI_API_KEY` when omitted; absent at call time is a labelled throw.
39
+ */
40
+ readonly apiKey?: Secret | string;
41
+ /**
42
+ * The endpoint, minus `/chat/completions`. THIS is what makes the provider vendor-neutral —
43
+ * `http://localhost:11434/v1` is Ollama, `http://vllm:8000/v1` is vLLM, and an Azure deployment
44
+ * URL with its `?api-version=` query works as written: a query on the base is carried onto the
45
+ * request rather than swallowed by the path.
46
+ */
47
+ readonly baseUrl?: string;
48
+ /**
49
+ * The ids this endpoint serves. Required, and the provider's OWN list rather than the registry's,
50
+ * for the reason `AnthropicProvider.models` is: an app's internal model must not be routed
51
+ * somewhere that has never heard of it. On Azure these are DEPLOYMENT names, not model names.
52
+ */
53
+ readonly models: readonly ModelId[];
54
+ /** Extra headers — OpenRouter's attribution pair, a gateway's tenant header. Merged last. */
55
+ readonly headers?: Readonly<Record<string, string>>;
56
+ /**
57
+ * Which header carries the key. `bearer` is `Authorization: Bearer …`, the format's default;
58
+ * `api-key` is Azure's. One knob rather than two ways to pass a credential, so the key stays
59
+ * boxed in a `Secret` until the header is built.
60
+ */
61
+ readonly auth?: 'bearer' | 'api-key';
62
+ /**
63
+ * What this provider is called on a span and in a failure list. Default `openai`. Worth setting
64
+ * when two endpoints speaking this format serve one model — `provider` on the result is the only
65
+ * thing that says which of them answered.
66
+ */
67
+ readonly name?: string;
68
+ /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
69
+ readonly fetch?: typeof fetch;
70
+ }
71
+
72
+ /**
73
+ * A provider for any endpoint speaking the OpenAI chat-completions format.
74
+ *
75
+ * ```ts
76
+ * // OpenAI
77
+ * openAiProvider({ apiKey: openAiKey, models: [...OPENAI_MODEL_IDS] })
78
+ * // a company gateway, vLLM, Ollama — same class, one different field
79
+ * openAiProvider({ apiKey: gatewayKey, baseUrl: 'https://llm.acme.internal/v1', models: ['acme-70b'] })
80
+ * ```
81
+ */
82
+ export function openAiProvider(input: OpenAiProviderInput): Provider {
83
+ return new OpenAiProvider(input);
84
+ }
85
+
86
+ class OpenAiProvider implements Provider {
87
+ readonly name: string;
88
+ readonly models: readonly ModelId[];
89
+ private readonly defaultModel: ModelId;
90
+ private readonly config: OpenAiProviderInput;
91
+
92
+ constructor(config: OpenAiProviderInput) {
93
+ const [first] = config.models;
94
+ if (first === undefined) {
95
+ // A provider serving nothing can never be routed to, so it is a boot mistake that would
96
+ // otherwise surface as "no provider serves <model>" — a true statement about the wrong thing.
97
+ throw new AiRequestInvalidError({
98
+ detail:
99
+ 'openAiProvider was given an empty models list, so the gateway can never route to it',
100
+ fix: 'pass models: [...OPENAI_MODEL_IDS] to openAiProvider, or the ids your endpoint serves',
101
+ });
102
+ }
103
+ this.name = config.name ?? 'openai';
104
+ this.models = config.models;
105
+ this.defaultModel = first;
106
+ this.config = config;
107
+ }
108
+
109
+ /**
110
+ * Above `STREAM_ONLY_MAX_TOKENS` this runs the streaming transport and assembles the result,
111
+ * for the reason the Anthropic provider does: a non-streaming request that large sits on an
112
+ * open socket past the HTTP timeout and fails after the completion was generated and billed.
113
+ */
114
+ async generate(request: GenerateRequest): Promise<GenerateResult> {
115
+ if (requiresStreaming(request)) return this.assemble(request);
116
+ const model = this.modelOf(request);
117
+ const response = await this.send(chatCompletionBody({ request, model, stream: false }), false);
118
+ const answer = parseChatCompletion((await response.json()) as unknown, this.name);
119
+ return this.result(request, model, answer);
120
+ }
121
+
122
+ async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
123
+ const model = this.modelOf(request);
124
+ const response = await this.send(chatCompletionBody({ request, model, stream: true }), true);
125
+ if (response.body === null) {
126
+ throw new AiTransportError({
127
+ provider: this.name,
128
+ status: response.status,
129
+ detail: 'a streaming response arrived with no body',
130
+ });
131
+ }
132
+ const completion = new ChatCompletionStream(this.name);
133
+ for await (const frame of readSse(response.body)) {
134
+ for (const chunk of completion.push(frame)) yield chunk;
135
+ }
136
+ // A connection cut mid-answer must fail, not resolve: partial text reads as a complete answer,
137
+ // and `end_turn` would be a lie the caller has no way to detect.
138
+ if (!completion.isComplete()) {
139
+ throw new AiTransportError({
140
+ provider: this.name,
141
+ detail: 'the stream ended before a finish reason — the answer is truncated',
142
+ });
143
+ }
144
+ yield { type: 'done', result: this.result(request, model, completion.state()) };
145
+ }
146
+
147
+ /** Drive `stream()` to its `done` chunk. It throws on a cut stream, so a partial never lands. */
148
+ private async assemble(request: GenerateRequest): Promise<GenerateResult> {
149
+ for await (const chunk of this.stream(request)) {
150
+ if (chunk.type === 'done') return chunk.result;
151
+ }
152
+ throw new AiTransportError({
153
+ provider: this.name,
154
+ detail: 'the stream completed without a result',
155
+ });
156
+ }
157
+
158
+ /** One parsed answer, priced. The only place `cost` is applied — the provider owns prices. */
159
+ private result(request: GenerateRequest, model: ModelId, answer: ChatAnswer): GenerateResult {
160
+ const usage = answer.usage ?? estimatedUsage(request, answer.text);
161
+ return {
162
+ model,
163
+ text: answer.text,
164
+ toolCalls: answer.toolCalls,
165
+ stopReason: answer.stopReason,
166
+ stopDetails: answer.stopDetails,
167
+ usage,
168
+ cost: costOf(model, usage),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * The model this request is for. The gateway resolves one before it routes, so the fallback is
174
+ * only ever reached by a direct call — and it is this provider's first model, never the
175
+ * framework's `DEFAULT_MODEL`, which names a Claude id no OpenAI-format endpoint serves.
176
+ */
177
+ private modelOf(request: GenerateRequest): ModelId {
178
+ return request.model ?? this.defaultModel;
179
+ }
180
+
181
+ /**
182
+ * The one place a request leaves the process. A non-2xx becomes an `AiTransportError` carrying
183
+ * its status, because the gateway decides whether to retry from that status and a body parsed as
184
+ * if it were a message would read as an empty, successful answer.
185
+ */
186
+ private async send(body: Record<string, unknown>, streaming: boolean): Promise<Response> {
187
+ const apiKey = this.apiKey();
188
+ const doFetch = this.config.fetch ?? fetch;
189
+ const response = await doFetch(this.url(), {
190
+ method: 'POST',
191
+ headers: {
192
+ ...(this.config.auth === 'api-key'
193
+ ? { 'api-key': apiKey }
194
+ : { authorization: `Bearer ${apiKey}` }),
195
+ 'content-type': 'application/json',
196
+ accept: streaming ? 'text/event-stream' : 'application/json',
197
+ // Last, so a caller can replace the credential header outright for a gateway that wants
198
+ // its own scheme. Nothing here is ever logged.
199
+ ...this.config.headers,
200
+ },
201
+ body: JSON.stringify(body),
202
+ });
203
+ if (!response.ok) {
204
+ throw new AiTransportError({
205
+ provider: this.name,
206
+ status: response.status,
207
+ // The endpoint's own message, with the credential scrubbed out of it — `error-body.ts`
208
+ // says why, and both providers call the same pair.
209
+ detail: withoutKey(await detailOf(response), apiKey),
210
+ envVar: API_KEY_ENV,
211
+ });
212
+ }
213
+ return response;
214
+ }
215
+
216
+ /**
217
+ * The credential, revealed as late as possible and never stored on the instance. Returned rather
218
+ * than kept, so the only string that exists is the local one `send` puts in a header.
219
+ */
220
+ private apiKey(): string {
221
+ const configured = this.config.apiKey;
222
+ const value = isSecret(configured)
223
+ ? revealSecret(configured)
224
+ : (configured ?? Bun.env[API_KEY_ENV]);
225
+ if (value === undefined || value === '') {
226
+ throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
227
+ }
228
+ return value;
229
+ }
230
+
231
+ /**
232
+ * `<baseUrl>/chat/completions`, with any query on the base preserved. Azure's deployment URL
233
+ * carries `?api-version=…`, and appending the path after it would send the version as part of a
234
+ * path segment — a 404 whose cause reads like a wrong deployment name.
235
+ */
236
+ private url(): string {
237
+ const base = this.config.baseUrl ?? DEFAULT_BASE_URL;
238
+ const cut = base.indexOf('?');
239
+ const path = (cut === -1 ? base : base.slice(0, cut)).replace(/\/+$/, '');
240
+ return `${path}/chat/completions${cut === -1 ? '' : base.slice(cut)}`;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * What a call cost when the endpoint reported nothing.
246
+ *
247
+ * Every streamed request asks for usage (`stream_options.include_usage`), and every non-streamed
248
+ * response carries it — but a compatible server that ignores the field leaves the budget
249
+ * reconciling a real call against zero, which refunds the reservation in full and turns the ledger
250
+ * into a decoration. An estimate is wrong by a few percent in the safe direction; zero is wrong by
251
+ * all of it.
252
+ */
253
+ export function estimatedUsage(request: GenerateRequest, text: string): TokenUsage {
254
+ return {
255
+ inputTokens: estimateInputTokens(request),
256
+ outputTokens: estimateTextTokens(text),
257
+ cacheReadTokens: 0,
258
+ cacheWriteTokens: 0,
259
+ };
260
+ }