@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.
@@ -0,0 +1,274 @@
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(
118
+ chatCompletionBody({ request, model, stream: false }),
119
+ false,
120
+ request.signal,
121
+ );
122
+ const answer = parseChatCompletion((await response.json()) as unknown, this.name);
123
+ return this.result(request, model, answer);
124
+ }
125
+
126
+ async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
127
+ const model = this.modelOf(request);
128
+ const response = await this.send(
129
+ chatCompletionBody({ request, model, stream: true }),
130
+ true,
131
+ request.signal,
132
+ );
133
+ if (response.body === null) {
134
+ throw new AiTransportError({
135
+ provider: this.name,
136
+ status: response.status,
137
+ detail: 'a streaming response arrived with no body',
138
+ });
139
+ }
140
+ const completion = new ChatCompletionStream(this.name);
141
+ for await (const frame of readSse(response.body)) {
142
+ for (const chunk of completion.push(frame)) yield chunk;
143
+ }
144
+ // A connection cut mid-answer must fail, not resolve: partial text reads as a complete answer,
145
+ // and `end_turn` would be a lie the caller has no way to detect.
146
+ if (!completion.isComplete()) {
147
+ throw new AiTransportError({
148
+ provider: this.name,
149
+ detail: 'the stream ended before a finish reason — the answer is truncated',
150
+ });
151
+ }
152
+ yield { type: 'done', result: this.result(request, model, completion.state()) };
153
+ }
154
+
155
+ /** Drive `stream()` to its `done` chunk. It throws on a cut stream, so a partial never lands. */
156
+ private async assemble(request: GenerateRequest): Promise<GenerateResult> {
157
+ for await (const chunk of this.stream(request)) {
158
+ if (chunk.type === 'done') return chunk.result;
159
+ }
160
+ throw new AiTransportError({
161
+ provider: this.name,
162
+ detail: 'the stream completed without a result',
163
+ });
164
+ }
165
+
166
+ /** One parsed answer, priced. The only place `cost` is applied — the provider owns prices. */
167
+ private result(request: GenerateRequest, model: ModelId, answer: ChatAnswer): GenerateResult {
168
+ const usage = answer.usage ?? estimatedUsage(request, answer.text);
169
+ return {
170
+ model,
171
+ text: answer.text,
172
+ toolCalls: answer.toolCalls,
173
+ stopReason: answer.stopReason,
174
+ stopDetails: answer.stopDetails,
175
+ usage,
176
+ cost: costOf(model, usage),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * The model this request is for. The gateway resolves one before it routes, so the fallback is
182
+ * only ever reached by a direct call — and it is this provider's first model, never the
183
+ * framework's `DEFAULT_MODEL`, which names a Claude id no OpenAI-format endpoint serves.
184
+ */
185
+ private modelOf(request: GenerateRequest): ModelId {
186
+ return request.model ?? this.defaultModel;
187
+ }
188
+
189
+ /**
190
+ * The one place a request leaves the process. A non-2xx becomes an `AiTransportError` carrying
191
+ * its status, because the gateway decides whether to retry from that status and a body parsed as
192
+ * if it were a message would read as an empty, successful answer.
193
+ */
194
+ private async send(
195
+ body: Record<string, unknown>,
196
+ streaming: boolean,
197
+ signal: AbortSignal | undefined,
198
+ ): Promise<Response> {
199
+ const apiKey = this.apiKey();
200
+ const doFetch = this.config.fetch ?? fetch;
201
+ const response = await doFetch(this.url(), {
202
+ method: 'POST',
203
+ headers: {
204
+ ...(this.config.auth === 'api-key'
205
+ ? { 'api-key': apiKey }
206
+ : { authorization: `Bearer ${apiKey}` }),
207
+ 'content-type': 'application/json',
208
+ accept: streaming ? 'text/event-stream' : 'application/json',
209
+ // Last, so a caller can replace the credential header outright for a gateway that wants
210
+ // its own scheme. Nothing here is ever logged.
211
+ ...this.config.headers,
212
+ },
213
+ body: JSON.stringify(body),
214
+ // Attached only when the caller has one — same rule, same reason, as the Anthropic half.
215
+ ...(signal === undefined ? {} : { signal }),
216
+ });
217
+ if (!response.ok) {
218
+ throw new AiTransportError({
219
+ provider: this.name,
220
+ status: response.status,
221
+ // The endpoint's own message, with the credential scrubbed out of it — `error-body.ts`
222
+ // says why, and both providers call the same pair.
223
+ detail: withoutKey(await detailOf(response), apiKey),
224
+ envVar: API_KEY_ENV,
225
+ });
226
+ }
227
+ return response;
228
+ }
229
+
230
+ /**
231
+ * The credential, revealed as late as possible and never stored on the instance. Returned rather
232
+ * than kept, so the only string that exists is the local one `send` puts in a header.
233
+ */
234
+ private apiKey(): string {
235
+ const configured = this.config.apiKey;
236
+ const value = isSecret(configured)
237
+ ? revealSecret(configured)
238
+ : (configured ?? Bun.env[API_KEY_ENV]);
239
+ if (value === undefined || value === '') {
240
+ throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
241
+ }
242
+ return value;
243
+ }
244
+
245
+ /**
246
+ * `<baseUrl>/chat/completions`, with any query on the base preserved. Azure's deployment URL
247
+ * carries `?api-version=…`, and appending the path after it would send the version as part of a
248
+ * path segment — a 404 whose cause reads like a wrong deployment name.
249
+ */
250
+ private url(): string {
251
+ const base = this.config.baseUrl ?? DEFAULT_BASE_URL;
252
+ const cut = base.indexOf('?');
253
+ const path = (cut === -1 ? base : base.slice(0, cut)).replace(/\/+$/, '');
254
+ return `${path}/chat/completions${cut === -1 ? '' : base.slice(cut)}`;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * What a call cost when the endpoint reported nothing.
260
+ *
261
+ * Every streamed request asks for usage (`stream_options.include_usage`), and every non-streamed
262
+ * response carries it — but a compatible server that ignores the field leaves the budget
263
+ * reconciling a real call against zero, which refunds the reservation in full and turns the ledger
264
+ * into a decoration. An estimate is wrong by a few percent in the safe direction; zero is wrong by
265
+ * all of it.
266
+ */
267
+ export function estimatedUsage(request: GenerateRequest, text: string): TokenUsage {
268
+ return {
269
+ inputTokens: estimateInputTokens(request),
270
+ outputTokens: estimateTextTokens(text),
271
+ cacheReadTokens: 0,
272
+ cacheWriteTokens: 0,
273
+ };
274
+ }
@@ -0,0 +1,339 @@
1
+ // Single responsibility: reading what an OpenAI-format endpoint sends back — one chat completion,
2
+ // and the SSE stream of the same answer arriving in pieces.
3
+ //
4
+ // Split from the provider for the reason wire.ts is: the assembler can then be driven frame by
5
+ // frame with no socket, which is the only way to cover a stream that arrives fragmented, out of
6
+ // order, or stops half way. An LLM response is untrusted input, so every field is parsed and
7
+ // nothing is cast.
8
+
9
+ import { AiTransportError } from './errors';
10
+ import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
11
+ import type { SseFrame } from './sse';
12
+ import type { LlmToolCall } from './tools';
13
+
14
+ /**
15
+ * What one answer amounts to. `usage` is optional and that is the format's fault, not a laxity:
16
+ * a streamed answer reports usage only in a final chunk, and only when the request asked for it.
17
+ * The provider decides what to do with an absent one — never this file, which reports what arrived.
18
+ */
19
+ export interface ChatAnswer {
20
+ readonly text: string;
21
+ readonly toolCalls: readonly LlmToolCall[];
22
+ readonly stopReason: StopReason;
23
+ readonly stopDetails: StopDetails | undefined;
24
+ readonly usage: TokenUsage | undefined;
25
+ }
26
+
27
+ const FINISH_REASONS: Readonly<Record<string, StopReason>> = {
28
+ stop: 'end_turn',
29
+ length: 'max_tokens',
30
+ tool_calls: 'tool_use',
31
+ // The legacy name for the same event; LiteLLM and older self-hosted servers still send it.
32
+ function_call: 'tool_use',
33
+ content_filter: 'refusal',
34
+ };
35
+
36
+ /**
37
+ * In-band error frames carry a type, not a status, and the gateway's retry rule reads a status.
38
+ * Same mapping job as wire.ts's, over this format's own vocabulary.
39
+ */
40
+ const ERROR_STATUS: Readonly<Record<string, number>> = {
41
+ invalid_request_error: 400,
42
+ authentication_error: 401,
43
+ permission_error: 403,
44
+ not_found_error: 404,
45
+ rate_limit_exceeded: 429,
46
+ insufficient_quota: 429,
47
+ server_error: 500,
48
+ api_error: 500,
49
+ overloaded_error: 503,
50
+ };
51
+
52
+ /** A finish reason this format knows, or `undefined` for `null` — which means "still going". */
53
+ export function parseFinishReason(raw: unknown): StopReason | undefined {
54
+ return typeof raw === 'string' ? FINISH_REASONS[raw] : undefined;
55
+ }
56
+
57
+ /**
58
+ * `usage`, or `undefined` when the payload carried none.
59
+ *
60
+ * `prompt_tokens` INCLUDES the cached prefix on this wire, where Anthropic's `input_tokens`
61
+ * excludes it — so the cached half is subtracted out before it is reported as `cacheReadTokens`.
62
+ * Left in place it would be billed twice: once at the full input rate and again at the cache rate.
63
+ */
64
+ export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
65
+ const record = asRecord(raw);
66
+ if (record === undefined) return undefined;
67
+ const prompt = numberOf(record['prompt_tokens']);
68
+ const completion = numberOf(record['completion_tokens']);
69
+ if (prompt === undefined && completion === undefined) return undefined;
70
+ const cached = numberOf(asRecord(record['prompt_tokens_details'])?.['cached_tokens']) ?? 0;
71
+ return {
72
+ inputTokens: Math.max((prompt ?? 0) - cached, 0),
73
+ // `completion_tokens` already contains `reasoning_tokens`; adding them is a double count.
74
+ outputTokens: completion ?? 0,
75
+ cacheReadTokens: cached,
76
+ // Caching is automatic here and carries no write surcharge, so there is nothing to report.
77
+ cacheWriteTokens: 0,
78
+ };
79
+ }
80
+
81
+ /** One non-streamed chat completion. Refusal is read BEFORE anything else trusts the content. */
82
+ export function parseChatCompletion(raw: unknown, provider: string): ChatAnswer {
83
+ const record = asRecord(raw);
84
+ if (record === undefined) throw malformed(provider, 'the response body is not a JSON object');
85
+ throwInBandError(record, provider);
86
+ const choice = asRecord(Array.isArray(record['choices']) ? record['choices'][0] : undefined);
87
+ if (choice === undefined) throw malformed(provider, 'the response carried no choices');
88
+ const message = asRecord(choice['message']) ?? {};
89
+ const refusal = typeof message['refusal'] === 'string' ? message['refusal'] : undefined;
90
+ const finish = parseFinishReason(choice['finish_reason']);
91
+ return {
92
+ text: typeof message['content'] === 'string' ? message['content'] : '',
93
+ toolCalls: parseToolCalls(message['tool_calls'], provider),
94
+ // A refusal string is a refusal whatever the finish reason says: the field only ever appears
95
+ // when the model declined, and `stop` beside it would read downstream as an empty answer.
96
+ stopReason: refusal === undefined ? (finish ?? 'end_turn') : 'refusal',
97
+ stopDetails: refusalDetails(finish, refusal),
98
+ usage: parseOpenAiUsage(record['usage']),
99
+ };
100
+ }
101
+
102
+ function refusalDetails(
103
+ finish: StopReason | undefined,
104
+ refusal: string | undefined,
105
+ ): StopDetails | undefined {
106
+ if (refusal !== undefined) return { type: 'refusal', category: 'refusal', explanation: refusal };
107
+ if (finish !== 'refusal') return undefined;
108
+ // `content_filter` is all the endpoint says. Carried as the category rather than dropped: it is
109
+ // the only thing that distinguishes a policy stop from a model that chose not to answer.
110
+ return { type: 'refusal', category: 'content_filter', explanation: undefined };
111
+ }
112
+
113
+ function parseToolCalls(raw: unknown, provider: string): readonly LlmToolCall[] {
114
+ if (!Array.isArray(raw)) return [];
115
+ const calls: LlmToolCall[] = [];
116
+ for (const entry of raw) {
117
+ const record = asRecord(entry);
118
+ const fn = asRecord(record?.['function']);
119
+ if (record === undefined || fn === undefined) continue;
120
+ const name = typeof fn['name'] === 'string' ? fn['name'] : '';
121
+ if (name === '') continue;
122
+ calls.push({
123
+ id: typeof record['id'] === 'string' ? record['id'] : '',
124
+ name,
125
+ input: parseArguments(fn['arguments'], name, provider),
126
+ });
127
+ }
128
+ return calls;
129
+ }
130
+
131
+ /** Arguments are a JSON string. A tool that takes none sends `''` or `'{}'`; neither is a fault. */
132
+ function parseArguments(raw: unknown, name: string, provider: string): Record<string, unknown> {
133
+ if (typeof raw !== 'string' || raw.trim() === '') return {};
134
+ try {
135
+ return asRecord(JSON.parse(raw)) ?? {};
136
+ } catch (error) {
137
+ throw malformed(
138
+ provider,
139
+ `tool "${name}" returned arguments that are not JSON: ${
140
+ error instanceof Error ? error.message : 'unreadable'
141
+ }`,
142
+ );
143
+ }
144
+ }
145
+
146
+ interface PendingCall {
147
+ id: string;
148
+ name: string;
149
+ args: string;
150
+ }
151
+
152
+ /**
153
+ * One streamed chat completion, assembled frame by frame.
154
+ *
155
+ * Two things this format does that the Anthropic one does not, and both are easy to get subtly
156
+ * wrong: a tool call arrives FRAGMENTED and INDEXED — its id, its name and successive slices of its
157
+ * arguments spread across chunks, keyed only by `tool_calls[].index` — and `usage` arrives once, in
158
+ * a trailing chunk whose `choices` array is empty, long after the finish reason.
159
+ */
160
+ export class ChatCompletionStream {
161
+ private text = '';
162
+ private refusal = '';
163
+ private stopReason: StopReason = 'end_turn';
164
+ private finished = false;
165
+ private done = false;
166
+ private usage: TokenUsage | undefined;
167
+ private readonly pending = new Map<number, PendingCall>();
168
+ private readonly toolCalls: LlmToolCall[] = [];
169
+ private readonly provider: string;
170
+
171
+ constructor(provider: string) {
172
+ this.provider = provider;
173
+ }
174
+
175
+ /** Chunks this frame produced, in order. An unknown field yields nothing — the format grows. */
176
+ push(frame: SseFrame): readonly StreamChunk[] {
177
+ // The sentinel is not JSON, and parsing it is how a stream reader ends in a syntax error.
178
+ if (frame.data.trim() === '[DONE]') {
179
+ this.done = true;
180
+ return [];
181
+ }
182
+ const payload = this.payloadOf(frame);
183
+ throwInBandError(payload, this.provider);
184
+ const usage = parseOpenAiUsage(payload['usage']);
185
+ if (usage !== undefined) this.usage = usage;
186
+ const choice = asRecord(Array.isArray(payload['choices']) ? payload['choices'][0] : undefined);
187
+ if (choice === undefined) return [];
188
+ const chunks = this.onDelta(asRecord(choice['delta']) ?? {});
189
+ return [...chunks, ...this.onFinish(choice['finish_reason'])];
190
+ }
191
+
192
+ /** True once the answer is accounted for. False means the connection died mid-answer. */
193
+ isComplete(): boolean {
194
+ // Either sentinel counts. `[DONE]` is the format's own end marker, but plenty of servers in
195
+ // the family close the socket straight after the finish-reason chunk — and a finish reason IS
196
+ // the model saying why it stopped, which is the fact a truncated stream cannot produce.
197
+ return this.done || this.finished;
198
+ }
199
+
200
+ /** What the stream accumulated. `cost` is applied by the provider, which owns prices. */
201
+ state(): ChatAnswer {
202
+ const details = this.refusalDetails();
203
+ return {
204
+ text: this.text,
205
+ toolCalls: [...this.toolCalls],
206
+ // A refusal that arrived as a `refusal` delta finishes with `stop`, so the reason alone would
207
+ // read as a complete answer that happens to be empty.
208
+ stopReason: details === undefined ? this.stopReason : 'refusal',
209
+ stopDetails: details,
210
+ usage: this.usage,
211
+ };
212
+ }
213
+
214
+ private refusalDetails(): StopDetails | undefined {
215
+ if (this.refusal !== '') {
216
+ return { type: 'refusal', category: 'refusal', explanation: this.refusal };
217
+ }
218
+ return this.stopReason === 'refusal'
219
+ ? { type: 'refusal', category: 'content_filter', explanation: undefined }
220
+ : undefined;
221
+ }
222
+
223
+ private onDelta(delta: Record<string, unknown>): readonly StreamChunk[] {
224
+ const chunks: StreamChunk[] = [];
225
+ const content = delta['content'];
226
+ if (typeof content === 'string' && content !== '') {
227
+ this.text += content;
228
+ chunks.push({ type: 'text', text: content });
229
+ }
230
+ // `reasoning_content` is vLLM's and DeepSeek's; `reasoning` is the OpenRouter spelling. Neither
231
+ // is ever appended to `text`, for the reason thinking deltas are not: a consumer concatenating
232
+ // every chunk must not end up shipping the reasoning to the user.
233
+ const thinking = delta['reasoning_content'] ?? delta['reasoning'];
234
+ if (typeof thinking === 'string' && thinking !== '') {
235
+ chunks.push({ type: 'thinking', text: thinking });
236
+ }
237
+ const refusal = delta['refusal'];
238
+ if (typeof refusal === 'string') this.refusal += refusal;
239
+ this.accumulate(delta['tool_calls']);
240
+ return chunks;
241
+ }
242
+
243
+ /**
244
+ * Merge one chunk's tool-call fragments into the calls they belong to. `index` is the only key —
245
+ * `id` and `name` arrive on the first fragment and are absent from every later one, so appending
246
+ * by array position instead would build one call per chunk and lose every argument but the last.
247
+ */
248
+ private accumulate(raw: unknown): void {
249
+ if (!Array.isArray(raw)) return;
250
+ for (const entry of raw) {
251
+ const record = asRecord(entry);
252
+ if (record === undefined) continue;
253
+ const index = numberOf(record['index']) ?? 0;
254
+ const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
255
+ const id = record['id'];
256
+ if (typeof id === 'string' && id !== '') call.id = id;
257
+ const fn = asRecord(record['function']);
258
+ const name = fn?.['name'];
259
+ // Concatenated, not assigned: a server that splits the name across two frames is rare and
260
+ // legal, and assigning would keep only the tail.
261
+ if (typeof name === 'string') call.name += name;
262
+ const args = fn?.['arguments'];
263
+ if (typeof args === 'string') call.args += args;
264
+ this.pending.set(index, call);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * The finish reason closes the turn, and it is the only close this format has: there is no
270
+ * per-block stop event, so pending tool calls are emitted here — whole, in index order, exactly
271
+ * as the Anthropic path emits them at `content_block_stop`. A fragment is never a call.
272
+ */
273
+ private onFinish(raw: unknown): readonly StreamChunk[] {
274
+ const reason = parseFinishReason(raw);
275
+ if (reason === undefined) return [];
276
+ this.stopReason = reason;
277
+ this.finished = true;
278
+ const chunks: StreamChunk[] = [];
279
+ for (const index of [...this.pending.keys()].sort((a, b) => a - b)) {
280
+ const pending = this.pending.get(index);
281
+ if (pending === undefined || pending.name === '') continue;
282
+ const call: LlmToolCall = {
283
+ id: pending.id,
284
+ name: pending.name,
285
+ input: parseArguments(pending.args, pending.name, this.provider),
286
+ };
287
+ this.toolCalls.push(call);
288
+ chunks.push({ type: 'tool-call', call });
289
+ }
290
+ this.pending.clear();
291
+ return chunks;
292
+ }
293
+
294
+ private payloadOf(frame: SseFrame): Record<string, unknown> {
295
+ try {
296
+ const record = asRecord(JSON.parse(frame.data));
297
+ if (record === undefined) throw new SyntaxError('frame data is not an object');
298
+ return record;
299
+ } catch (error) {
300
+ throw malformed(
301
+ this.provider,
302
+ `unreadable "${frame.event}" frame: ${
303
+ error instanceof Error ? error.message : 'unreadable'
304
+ }`,
305
+ );
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * A 200 that carries an `error` object instead of an answer — how a gateway in front of a model
312
+ * reports a fault it noticed after the headers were sent. Parsed as a message it would read as an
313
+ * empty, successful answer, which is the one outcome nothing downstream can detect.
314
+ */
315
+ function throwInBandError(payload: Record<string, unknown>, provider: string): void {
316
+ const error = asRecord(payload['error']);
317
+ if (error === undefined) return;
318
+ const type = typeof error['type'] === 'string' ? error['type'] : 'api_error';
319
+ const code = typeof error['code'] === 'string' ? error['code'] : undefined;
320
+ const message = typeof error['message'] === 'string' ? error['message'] : type;
321
+ throw new AiTransportError({
322
+ provider,
323
+ status: ERROR_STATUS[type] ?? (code === undefined ? undefined : ERROR_STATUS[code]) ?? 500,
324
+ detail: message,
325
+ });
326
+ }
327
+
328
+ function malformed(provider: string, detail: string): AiTransportError {
329
+ return new AiTransportError({ provider, detail });
330
+ }
331
+
332
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
333
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
334
+ return value as Record<string, unknown>;
335
+ }
336
+
337
+ function numberOf(value: unknown): number | undefined {
338
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
339
+ }
@@ -45,7 +45,11 @@ export function conditionsSql(scope: VectorScope, filter?: MetadataFilter): SqlF
45
45
  }
46
46
 
47
47
  /**
48
- * `x db gen` emits this. The primary key is `(tenant, id)`, not `id`: it makes a cross-tenant
48
+ * The store's whole schema as one string, for an app to split across migration files. **No
49
+ * command emits it** — `x db gen` diffs `describeEntities()` and a vector store is not an
50
+ * `entity()`, so no CLI file references this at all.
51
+ *
52
+ * The primary key is `(tenant, id)`, not `id`: it makes a cross-tenant
49
53
  * overwrite impossible at the storage layer instead of relying on every upsert remembering to
50
54
  * check. An unscoped store writes the empty tenant, which is a tenant like any other.
51
55
  */
package/src/pg-vector.ts CHANGED
@@ -63,7 +63,8 @@ export class PgVectorStore implements VectorStore {
63
63
  };
64
64
  }
65
65
 
66
- /** `x db gen` emits this; kept next to the queries so the index choice is reviewable. */
66
+ /** An app pastes this into migrations no command emits it. Beside the queries so the index
67
+ * choice is reviewable against the reads that depend on it. */
67
68
  ddl(): string {
68
69
  return ddlSql(this.target);
69
70
  }
package/src/prompt.ts CHANGED
@@ -24,7 +24,7 @@ export interface DefinePromptInput<V extends PromptVars> {
24
24
  readonly template: string;
25
25
  /** Optional system prompt. Part of the hash — it changes behaviour. */
26
26
  readonly system?: string;
27
- /** Schema of the variables, for the manifest and for `x ai prompts`. */
27
+ /** Schema of the variables, for the manifest. */
28
28
  readonly input?: JsonSchema;
29
29
  /** Expected output shape, fed to `output_config.format` when the caller opts in. */
30
30
  readonly output?: JsonSchema;