@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,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;
package/src/provider.ts CHANGED
@@ -4,16 +4,63 @@
4
4
  // catalogue and the per-model rules, ./wire owns the response half.
5
5
 
6
6
  import type { Money } from '@ultimat3/money';
7
+ import { detailOf, withoutKey } from './error-body';
7
8
  import { AiKeyMissingError, AiTransportError } from './errors';
8
9
  import type { Effort, ModelId, ThinkingMode } from './models';
9
- import { DEFAULT_MODEL, MODEL_IDS, MODELS, reasoningBody } from './models';
10
+ import { ANTHROPIC_MODEL_IDS, DEFAULT_MODEL, modelIds, modelSpec, reasoningBody } from './models';
10
11
  import { readSse } from './sse';
11
12
  import type { LlmTool, LlmToolCall } from './tools';
12
- import { MessageStream, parseStopDetails, parseStopReason, parseUsage } from './wire';
13
+ import {
14
+ asToolInput,
15
+ MessageStream,
16
+ parseStopDetails,
17
+ parseStopReason,
18
+ parseUsage,
19
+ throwInBandError,
20
+ } from './wire';
21
+
22
+ /**
23
+ * One block of a structured message. A plain string message is still the common case and still
24
+ * legal; blocks exist because a tool loop cannot be expressed without them — a `tool_result` has
25
+ * to name the `tool_use` it answers, and a string has nowhere to put the id.
26
+ *
27
+ * The field names are the Messages API's, not a translated set, so `body()` passes a block
28
+ * through untouched. A second vocabulary here would be a mapping table to keep in step with a
29
+ * wire format we do not own.
30
+ */
31
+ export type AiContentBlock =
32
+ | { readonly type: 'text'; readonly text: string }
33
+ | {
34
+ readonly type: 'tool_use';
35
+ readonly id: string;
36
+ readonly name: string;
37
+ readonly input: Record<string, unknown>;
38
+ }
39
+ | {
40
+ readonly type: 'tool_result';
41
+ readonly tool_use_id: string;
42
+ readonly content: string;
43
+ readonly is_error?: boolean;
44
+ };
13
45
 
14
46
  export interface AiMessage {
15
47
  readonly role: 'user' | 'assistant';
16
- readonly content: string;
48
+ readonly content: string | readonly AiContentBlock[];
49
+ }
50
+
51
+ /**
52
+ * The readable text of a message, blocks flattened. For ESTIMATING and for the echo provider —
53
+ * never for building a request, which sends `content` as it stands.
54
+ */
55
+ export function messageText(message: AiMessage): string {
56
+ if (typeof message.content === 'string') return message.content;
57
+ return message.content
58
+ .map((block) => {
59
+ if (block.type === 'text') return block.text;
60
+ if (block.type === 'tool_result') return block.content;
61
+ return JSON.stringify(block.input);
62
+ })
63
+ .join(' ');
17
64
  }
18
65
 
19
66
  export interface GenerateRequest {
@@ -61,6 +108,14 @@ export interface StopDetails {
61
108
 
62
109
  export interface GenerateResult {
63
110
  readonly model: ModelId;
111
+ /**
112
+ * Which provider actually answered. Stamped by the GATEWAY, not by the provider: falling back
113
+ * from one provider to the next is a gateway concept, and a `Provider` implementation an app
114
+ * wrote cannot be asked to report on a decision it did not make. Optional for exactly that
115
+ * reason — a provider's own result has not been through the router yet. `llm()` puts it on the
116
+ * span as `llm.provider`, which is what makes a fallback visible rather than silent.
117
+ */
118
+ readonly provider?: string;
64
119
  readonly text: string;
65
120
  readonly toolCalls: readonly LlmToolCall[];
66
121
  readonly stopReason: StopReason;
@@ -91,7 +146,7 @@ export interface Provider {
91
146
  * away is money the framework silently absorbs, and under-reporting spend defeats a budget.
92
147
  */
93
148
  export function costOf(model: ModelId, usage: TokenUsage): Money {
94
- const spec = MODELS[model];
149
+ const spec = modelSpec(model);
95
150
  // Cache reads are ~0.1x input; cache writes ~1.25x. Scaled by 10 to stay integral.
96
151
  const inputUnits =
97
152
  usage.inputTokens * 10 + usage.cacheReadTokens * 1 + Math.ceil(usage.cacheWriteTokens * 12.5);
@@ -121,8 +176,6 @@ export interface AnthropicProviderInput {
121
176
 
122
177
  const ANTHROPIC_VERSION = '2023-06-01';
123
178
  const API_KEY_ENV = 'ANTHROPIC_API_KEY';
124
- /** Enough of an error body to name the field that was wrong, not enough to fill a log. */
125
- const DETAIL_LIMIT = 300;
126
179
 
127
180
  /**
128
181
  * Above this ceiling a non-streaming request sits on an open socket past the HTTP timeout and
@@ -134,7 +187,7 @@ export const STREAM_ONLY_MAX_TOKENS = 16_000;
134
187
  /** Whether this request has to go over the streaming transport to arrive at all. */
135
188
  export function requiresStreaming(request: GenerateRequest): boolean {
136
189
  const model = request.model ?? DEFAULT_MODEL;
137
- return Math.min(request.maxTokens, MODELS[model].maxOutput) > STREAM_ONLY_MAX_TOKENS;
190
+ return Math.min(request.maxTokens, modelSpec(model).maxOutput) > STREAM_ONLY_MAX_TOKENS;
138
191
  }
139
192
 
140
193
  /**
@@ -146,7 +199,8 @@ export function requiresStreaming(request: GenerateRequest): boolean {
146
199
  */
147
200
  export class AnthropicProvider implements Provider {
148
201
  readonly name = 'anthropic';
149
- readonly models = MODEL_IDS;
202
+ /** Its own list, never the registry's: an app's internal model must not be routed here. */
203
+ readonly models: readonly ModelId[] = ANTHROPIC_MODEL_IDS;
150
204
  private readonly config: AnthropicProviderInput;
151
205
 
152
206
  constructor(config: AnthropicProviderInput = {}) {
@@ -217,7 +271,7 @@ export class AnthropicProvider implements Provider {
217
271
  const model = request.model ?? DEFAULT_MODEL;
218
272
  const body: Record<string, unknown> = {
219
273
  model,
220
- max_tokens: Math.min(request.maxTokens, MODELS[model].maxOutput),
274
+ max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
221
275
  messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
222
276
  ...reasoningBody(model, request.effort, request.thinking),
223
277
  };
@@ -253,33 +307,24 @@ export class AnthropicProvider implements Provider {
253
307
  throw new AiTransportError({
254
308
  provider: this.name,
255
309
  status: response.status,
256
- detail: await detailOf(response),
310
+ // The endpoint's own message, with the credential scrubbed out of it — the same rule the
311
+ // OpenAI-format provider follows, and for the same reason: a proxy echoing the request
312
+ // headers into its 4xx body is the one path by which `x-api-key` reaches an error.
313
+ detail: withoutKey(await detailOf(response), apiKey),
314
+ envVar: API_KEY_ENV,
257
315
  });
258
316
  }
259
317
  return response;
260
318
  }
261
319
  }
262
320
 
263
- /** The provider's own message, when it sent one — it names the offending field, we name the fix. */
264
- async function detailOf(response: Response): Promise<string> {
265
- const body = await response.text().catch(() => '');
266
- try {
267
- const parsed: unknown = JSON.parse(body);
268
- if (typeof parsed === 'object' && parsed !== null) {
269
- const error = (parsed as Record<string, unknown>)['error'];
270
- if (typeof error === 'object' && error !== null) {
271
- const message = (error as Record<string, unknown>)['message'];
272
- if (typeof message === 'string') return message.slice(0, DETAIL_LIMIT);
273
- }
274
- }
275
- } catch {
276
- // Not JSON — a proxy or a gateway timeout page. The raw text is still the best evidence.
277
- }
278
- return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
279
- }
280
-
281
321
  /** Map a Messages API response onto `GenerateResult`. Exported so tests can drive it. */
282
322
  export function parseMessage(model: ModelId, raw: Record<string, unknown>): GenerateResult {
323
+ // A 200 carrying an `error` object instead of an answer — how a gateway in front of a model
324
+ // reports a fault it noticed after the headers were sent. Read as a message it is an empty,
325
+ // successful answer, which is the one outcome nothing downstream can detect; the STREAMED half
326
+ // of this same provider has always refused it.
327
+ throwInBandError(raw);
283
328
  const content = Array.isArray(raw['content']) ? raw['content'] : [];
284
329
  let text = '';
285
330
  const toolCalls: LlmToolCall[] = [];
@@ -291,17 +336,24 @@ export function parseMessage(model: ModelId, raw: Record<string, unknown>): Gene
291
336
  toolCalls.push({
292
337
  id: b['id'],
293
338
  name: b['name'],
294
- input: (b['input'] ?? {}) as Record<string, unknown>,
339
+ // Parsed, never cast: `input` is untrusted, and a string under `Record<string, unknown>`
340
+ // is a lie every later reader indexes into. The streamed half already parses it.
341
+ input: asToolInput(b['input']),
295
342
  });
296
343
  }
297
344
  }
298
345
  const usage = parseUsage(raw['usage']);
346
+ const stopDetails = parseStopDetails(raw['stop_details']);
299
347
  return {
300
348
  model,
301
349
  text,
302
350
  toolCalls,
303
- stopReason: parseStopReason(raw['stop_reason']),
304
- stopDetails: parseStopDetails(raw['stop_details']),
351
+ // A refusal detail is a refusal whatever the stop field says: `parseStopReason` answers
352
+ // `end_turn` for a spelling this build has never seen, and `llm()` branches on the REASON —
353
+ // `stopDetails` has no reader that can refuse — so the pair would arrive as a complete answer
354
+ // that happens to be empty. The OpenAI-format read has always forced it.
355
+ stopReason: stopDetails === undefined ? parseStopReason(raw['stop_reason']) : 'refusal',
356
+ stopDetails,
305
357
  usage,
306
358
  cost: costOf(model, usage),
307
359
  };
@@ -324,7 +376,14 @@ export interface EchoProviderInput {
324
376
  */
325
377
  export class EchoProvider implements Provider {
326
378
  readonly name = 'echo';
327
- readonly models = MODEL_IDS;
379
+ /**
380
+ * A getter over the whole registry, not a snapshot: the test double has to serve whatever the
381
+ * test registered, and a field read at construction time would miss a model registered after.
382
+ */
383
+ get models(): readonly ModelId[] {
384
+ return modelIds();
385
+ }
386
+
328
387
  private readonly config: EchoProviderInput;
329
388
 
330
389
  constructor(config: EchoProviderInput = {}) {
@@ -365,7 +424,7 @@ export class EchoProvider implements Provider {
365
424
  function lastUserMessage(messages: readonly AiMessage[]): string {
366
425
  for (let i = messages.length - 1; i >= 0; i -= 1) {
367
426
  const message = messages[i];
368
- if (message !== undefined && message.role === 'user') return message.content;
427
+ if (message !== undefined && message.role === 'user') return messageText(message);
369
428
  }
370
429
  return '';
371
430
  }
@@ -381,7 +440,7 @@ export function estimateTokens(request: GenerateRequest): number {
381
440
 
382
441
  /** The prompt half alone — what the provider bills at the input rate. */
383
442
  export function estimateInputTokens(request: GenerateRequest): number {
384
- const body = request.messages.map((m) => m.content).join(' ');
443
+ const body = request.messages.map(messageText).join(' ');
385
444
  return estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
386
445
  }
387
446
 
@@ -394,7 +453,7 @@ export function estimateCost(request: GenerateRequest): Money {
394
453
  const model = request.model ?? DEFAULT_MODEL;
395
454
  return costOf(model, {
396
455
  inputTokens: estimateInputTokens(request),
397
- outputTokens: Math.min(request.maxTokens, MODELS[model].maxOutput),
456
+ outputTokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
398
457
  cacheReadTokens: 0,
399
458
  cacheWriteTokens: 0,
400
459
  });
package/src/rag.ts CHANGED
@@ -156,6 +156,28 @@ export interface AssembledContext {
156
156
  readonly dropped: readonly string[];
157
157
  }
158
158
 
159
+ const BLOCK_OPEN = '<document id=';
160
+ const BLOCK_CLOSE = '</document>';
161
+
162
+ /**
163
+ * One retrieved document, fenced and labelled with the id it came from. A bare separator was not
164
+ * a boundary: it is a string a document can simply contain, and the assembled text lands in the
165
+ * `user` message indistinguishable from the author's own instructions — while a tool RESULT
166
+ * carries provenance and this carried none. So the fence is neutralised inside the payload, the
167
+ * shape `cdata()` uses in `@ultimat3/seo`'s `xml.ts`: the marker is broken, never deleted, so the
168
+ * model still reads every word the document actually said.
169
+ *
170
+ * Influence only, and deliberately not sold as more: the actor is `ctx.actor` and tool dispatch is
171
+ * matched against `def.tools`, so retrieved text can persuade a model and can never authorise it.
172
+ */
173
+ function documentBlock(id: string, text: string): string {
174
+ const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
175
+ const body = text
176
+ .replaceAll(BLOCK_CLOSE, '<\\/document>')
177
+ .replaceAll(BLOCK_OPEN, '<\\document id=');
178
+ return `${BLOCK_OPEN}"${label}">\n${body}\n${BLOCK_CLOSE}`;
179
+ }
180
+
159
181
  /**
160
182
  * Fill a context window in rank order until the budget is reached. Skips oversized hits
161
183
  * rather than stopping, so one long chunk does not starve every shorter one behind it.
@@ -163,9 +185,10 @@ export interface AssembledContext {
163
185
  export function assembleContext(input: {
164
186
  readonly hits: readonly SearchHit[];
165
187
  readonly maxTokens: number;
188
+ /** Between BLOCKS, never inside one — the block fence is what separates documents. */
166
189
  readonly separator?: string;
167
190
  }): AssembledContext {
168
- const separator = input.separator ?? '\n\n---\n\n';
191
+ const separator = input.separator ?? '\n\n';
169
192
  const separatorTokens = estimateChunkTokens(separator);
170
193
  const parts: string[] = [];
171
194
  const used: string[] = [];
@@ -173,12 +196,13 @@ export function assembleContext(input: {
173
196
  let tokens = 0;
174
197
 
175
198
  for (const hit of input.hits) {
176
- const cost = estimateChunkTokens(hit.text) + (parts.length === 0 ? 0 : separatorTokens);
199
+ const block = documentBlock(hit.id, hit.text);
200
+ const cost = estimateChunkTokens(block) + (parts.length === 0 ? 0 : separatorTokens);
177
201
  if (tokens + cost > input.maxTokens) {
178
202
  dropped.push(hit.id);
179
203
  continue;
180
204
  }
181
- parts.push(hit.text);
205
+ parts.push(block);
182
206
  used.push(hit.id);
183
207
  tokens += cost;
184
208
  }