@ultimat3/ai 27.8.1 → 27.10.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.
@@ -9,14 +9,16 @@
9
9
 
10
10
  import type { Secret } from '@ultimat3/core';
11
11
  import { isSecret, revealSecret } from '@ultimat3/core';
12
+ import { costOf, exactCostOf } from './cost';
12
13
  import { detailOf, withoutKey } from './error-body';
13
14
  import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
14
15
  import type { AiFetch } from './fetch-seam';
15
16
  import { resolveModel } from './model-resolve';
16
17
  import type { ModelId } from './models';
17
- import { chatCompletionBody } from './openai-body';
18
+ import { chatCompletionBody, sessionHeaders } from './openai-body';
18
19
  import type { ChatAnswer } from './openai-wire';
19
20
  import { ChatCompletionStream, parseChatCompletion } from './openai-wire';
21
+ import { promptCacheRule } from './prompt-cache';
20
22
  import type {
21
23
  GenerateRequest,
22
24
  GenerateResult,
@@ -24,8 +26,9 @@ import type {
24
26
  StreamChunk,
25
27
  TokenUsage,
26
28
  } from './provider';
27
- import { costOf, estimateInputTokens, estimateTextTokens, requiresStreaming } from './provider';
29
+ import { estimateInputTokens, estimateTextTokens, requiresStreaming } from './provider';
28
30
  import { readSse } from './sse';
31
+ import { assertToolArguments } from './tool-arguments';
29
32
  import type { LlmToolCall } from './tools';
30
33
 
31
34
  const API_KEY_ENV = 'OPENAI_API_KEY';
@@ -84,6 +87,7 @@ export function openAiProvider(input: OpenAiProviderInput): Provider {
84
87
  }
85
88
 
86
89
  class OpenAiProvider implements Provider {
90
+ readonly wire = 'openai' as const;
87
91
  readonly name: string;
88
92
  readonly models: readonly ModelId[];
89
93
  private readonly config: OpenAiProviderInput;
@@ -105,6 +109,10 @@ class OpenAiProvider implements Provider {
105
109
  this.config = config;
106
110
  }
107
111
 
112
+ marksPromptCache(model: ModelId): boolean {
113
+ return promptCacheRule('openai', this.config.baseUrl, model).marks === 'parts';
114
+ }
115
+
108
116
  /**
109
117
  * Above `STREAM_ONLY_MAX_TOKENS` this runs the streaming transport and assembles the result,
110
118
  * for the reason the Anthropic provider does: a non-streaming request that large sits on an
@@ -115,10 +123,12 @@ class OpenAiProvider implements Provider {
115
123
  // this provider's first listed model — the app's three places are the only ones.
116
124
  const model = resolveModel('provider', request.model);
117
125
  if (requiresStreaming({ ...request, model })) return this.assemble(request);
126
+ const { baseUrl } = this.config;
118
127
  const response = await this.send(
119
- chatCompletionBody({ request, model, stream: false, provider: this.name }),
128
+ chatCompletionBody({ request, model, stream: false, provider: this.name, baseUrl }),
120
129
  false,
121
130
  request.signal,
131
+ sessionHeaders(request, model, baseUrl),
122
132
  );
123
133
  const answer = parseChatCompletion((await response.json()) as unknown, this.name);
124
134
  return this.result(request, model, answer);
@@ -126,10 +136,12 @@ class OpenAiProvider implements Provider {
126
136
 
127
137
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
128
138
  const model = resolveModel('provider', request.model);
139
+ const { baseUrl } = this.config;
129
140
  const response = await this.send(
130
- chatCompletionBody({ request, model, stream: true, provider: this.name }),
141
+ chatCompletionBody({ request, model, stream: true, provider: this.name, baseUrl }),
131
142
  true,
132
143
  request.signal,
144
+ sessionHeaders(request, model, baseUrl),
133
145
  );
134
146
  if (response.body === null) {
135
147
  throw new AiTransportError({
@@ -142,6 +154,8 @@ class OpenAiProvider implements Provider {
142
154
  for await (const frame of readSse(response.body, this.name)) {
143
155
  for (const chunk of completion.push(frame)) yield chunk;
144
156
  }
157
+ // The socket closed: the calls of a finished turn, for a server that never sends `[DONE]`.
158
+ for (const chunk of completion.end()) yield chunk;
145
159
  // A connection cut mid-answer must fail, not resolve: partial text reads as a complete answer,
146
160
  // and `end_turn` would be a lie the caller has no way to detect.
147
161
  if (!completion.isComplete()) {
@@ -167,6 +181,9 @@ class OpenAiProvider implements Provider {
167
181
  /** One parsed answer, priced. The only place `cost` is applied — the provider owns prices. */
168
182
  private result(request: GenerateRequest, model: ModelId, answer: ChatAnswer): GenerateResult {
169
183
  const usage = answer.usage ?? estimatedUsage(request, answer.text, answer.toolCalls);
184
+ const cost = costOf(model, usage);
185
+ // After usage is known, so a finished turn with unreadable arguments fails WITH its bill.
186
+ assertToolArguments({ provider: this.name, model, ...answer, usage, cost });
170
187
  return {
171
188
  model,
172
189
  text: answer.text,
@@ -174,7 +191,8 @@ class OpenAiProvider implements Provider {
174
191
  stopReason: answer.stopReason,
175
192
  stopDetails: answer.stopDetails,
176
193
  usage,
177
- cost: costOf(model, usage),
194
+ cost,
195
+ costExact: exactCostOf(model, usage),
178
196
  };
179
197
  }
180
198
 
@@ -187,6 +205,7 @@ class OpenAiProvider implements Provider {
187
205
  body: Record<string, unknown>,
188
206
  streaming: boolean,
189
207
  signal: AbortSignal | undefined,
208
+ extra: Readonly<Record<string, string>>,
190
209
  ): Promise<Response> {
191
210
  const apiKey = this.apiKey();
192
211
  const doFetch: AiFetch = this.config.fetch ?? fetch;
@@ -198,6 +217,8 @@ class OpenAiProvider implements Provider {
198
217
  : { authorization: `Bearer ${apiKey}` }),
199
218
  'content-type': 'application/json',
200
219
  accept: streaming ? 'text/event-stream' : 'application/json',
220
+ // The prompt-cache session, when the request carries one and this host routes by it.
221
+ ...extra,
201
222
  // Last, so a caller can replace the credential header outright for a gateway that wants
202
223
  // its own scheme. Nothing here is ever logged.
203
224
  ...this.config.headers,
@@ -10,6 +10,8 @@ import { renderThrowable } from '@ultimat3/core';
10
10
  import { AiTransportError } from './errors';
11
11
  import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
12
12
  import type { SseFrame } from './sse';
13
+ import type { MalformedToolCall } from './tool-arguments';
14
+ import { parseToolArguments } from './tool-arguments';
13
15
  import type { LlmToolCall } from './tools';
14
16
 
15
17
  /**
@@ -20,9 +22,16 @@ import type { LlmToolCall } from './tools';
20
22
  export interface ChatAnswer {
21
23
  readonly text: string;
22
24
  readonly toolCalls: readonly LlmToolCall[];
25
+ /**
26
+ * Calls whose arguments were not JSON. Never among `toolCalls` — a fragment is never a call —
27
+ * and what they mean is the provider's decision (`assertToolArguments`), once usage is known.
28
+ */
29
+ readonly malformed: readonly MalformedToolCall[];
23
30
  readonly stopReason: StopReason;
24
31
  readonly stopDetails: StopDetails | undefined;
25
32
  readonly usage: TokenUsage | undefined;
33
+ /** The raw finish reason when this build does not know it — `stopReason` is then read off the content. */
34
+ readonly unknownFinish?: string;
26
35
  }
27
36
 
28
37
  // A `Map`, not an object literal: `raw` is the PROVIDER's string on every read, and
@@ -33,14 +42,46 @@ export interface ChatAnswer {
33
42
  const FINISH_REASONS: ReadonlyMap<string, StopReason> = new Map(
34
43
  Object.entries({
35
44
  stop: 'end_turn',
45
+ // Synonyms for "the model finished", as endpoints in the family spell it: the Messages API's
46
+ // word passed through by a gateway, a tokenizer's end-of-sequence, Gemini's `STOP`.
47
+ end_turn: 'end_turn',
48
+ eos: 'end_turn',
49
+ eos_token: 'end_turn',
50
+ stop_sequence: 'stop_sequence',
36
51
  length: 'max_tokens',
52
+ max_tokens: 'max_tokens',
53
+ model_context_window_exceeded: 'model_context_window_exceeded',
37
54
  tool_calls: 'tool_use',
38
55
  // The legacy name for the same event; LiteLLM and older self-hosted servers still send it.
39
56
  function_call: 'tool_use',
57
+ tool_use: 'tool_use',
40
58
  content_filter: 'refusal',
41
59
  } as const),
42
60
  );
43
61
 
62
+ /** What a `finish_reason` field said: nothing yet, a reason this build knows, or one it does not. */
63
+ export type FinishReading =
64
+ | { readonly kind: 'none' }
65
+ | { readonly kind: 'known'; readonly reason: StopReason }
66
+ | { readonly kind: 'unknown'; readonly raw: string };
67
+
68
+ /**
69
+ * Read a finish reason, case-insensitively (`STOP`, `MAX_TOKENS`). A non-string (`null` on a
70
+ * delta) is "still going". A string this table does not list is UNKNOWN, and deliberately not
71
+ * truncation: `llm()` and `agent()` refuse a truncated turn before reading it, so guessing
72
+ * "cut off" fails every call of an endpoint that merely spells "done" its own way. What an unknown
73
+ * reason amounts to is decided from the answer itself (`unknownFinishReason`), by the provider.
74
+ */
75
+ export function readFinishReason(raw: unknown): FinishReading {
76
+ if (typeof raw !== 'string') return { kind: 'none' };
77
+ const reason = FINISH_REASONS.get(raw) ?? FINISH_REASONS.get(raw.toLowerCase());
78
+ return reason === undefined ? { kind: 'unknown', raw } : { kind: 'known', reason };
79
+ }
80
+
81
+ /** The stop reason of a turn whose finish reason is unknown: what its content shows it to be. */
82
+ const settledAs = (calls: readonly LlmToolCall[]): StopReason =>
83
+ calls.length > 0 ? 'tool_use' : 'end_turn';
84
+
44
85
  /**
45
86
  * In-band error frames carry a type, not a status, and the gateway's retry rule reads a status.
46
87
  * Same mapping job as wire.ts's, over this format's own vocabulary.
@@ -63,21 +104,26 @@ const ERROR_STATUS: ReadonlyMap<string, number> = new Map(
63
104
  );
64
105
 
65
106
  /**
66
- * The finish reason, or `undefined` for a non-string (`null` on a delta means "still going").
67
- * A string this table does not know FAILS CLOSED as `max_tokens` — `wire.ts`'s `parseStopReason`
68
- * rule, so one unknown reason reads the same on both formats: a provider's new reason is far
69
- * likelier to be a way of stopping early than of finishing.
107
+ * The finish reason as a `StopReason`, or `undefined` for a non-string. An UNKNOWN string reads
108
+ * `end_turn` here — the bare lookup has no answer to judge it by; the two readers below do, and
109
+ * report the raw reason as `unknownFinish` so the provider can refuse a turn that is not whole.
70
110
  */
71
111
  export function parseFinishReason(raw: unknown): StopReason | undefined {
72
- return typeof raw === 'string' ? (FINISH_REASONS.get(raw) ?? 'max_tokens') : undefined;
112
+ const read = readFinishReason(raw);
113
+ return read.kind === 'none' ? undefined : read.kind === 'known' ? read.reason : 'end_turn';
73
114
  }
74
115
 
75
116
  /**
76
117
  * `usage`, or `undefined` when the payload carried none.
77
118
  *
78
- * `prompt_tokens` INCLUDES the cached prefix on this wire, where Anthropic's `input_tokens`
79
- * excludes it — so the cached half is subtracted out before it is reported as `cacheReadTokens`.
80
- * Left in place it would be billed twice: once at the full input rate and again at the cache rate.
119
+ * `prompt_tokens` is the WHOLE prompt on this wire — the part read from a cache and the part
120
+ * written to one included — where Anthropic's `input_tokens` excludes both. So both are subtracted
121
+ * out before they are reported under their own counters. Left in place a cached token would be
122
+ * billed twice: once at the full input rate and again at the cache rate.
123
+ *
124
+ * `cache_write_tokens` is OpenRouter's (`prompt_tokens_details.cache_write_tokens`, "Number of
125
+ * tokens written to the cache"), reported for a model whose vendor bills a write. An endpoint that
126
+ * never sends it — OpenAI's own, where a write is free — reads as zero.
81
127
  */
82
128
  export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
83
129
  const record = asRecord(raw);
@@ -85,14 +131,18 @@ export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
85
131
  const prompt = countOf(record['prompt_tokens']);
86
132
  const completion = countOf(record['completion_tokens']);
87
133
  if (prompt === undefined && completion === undefined) return undefined;
88
- const cached = countOf(asRecord(record['prompt_tokens_details'])?.['cached_tokens']) ?? 0;
134
+ const details = asRecord(record['prompt_tokens_details']);
135
+ const total = prompt ?? 0;
136
+ // Each clamped to what is left of the prompt: a counter larger than the prompt it is part of is
137
+ // the endpoint's mistake, and an unclamped one bills tokens that were never sent.
138
+ const cached = Math.min(countOf(details?.['cached_tokens']) ?? 0, total);
139
+ const written = Math.min(countOf(details?.['cache_write_tokens']) ?? 0, total - cached);
89
140
  return {
90
- inputTokens: Math.max((prompt ?? 0) - cached, 0),
141
+ inputTokens: total - cached - written,
91
142
  // `completion_tokens` already contains `reasoning_tokens`; adding them is a double count.
92
143
  outputTokens: completion ?? 0,
93
144
  cacheReadTokens: cached,
94
- // Caching is automatic here and carries no write surcharge, so there is nothing to report.
95
- cacheWriteTokens: 0,
145
+ cacheWriteTokens: written,
96
146
  };
97
147
  }
98
148
 
@@ -108,15 +158,24 @@ export function parseChatCompletion(raw: unknown, provider: string): ChatAnswer
108
158
  // `"refusal": ""` beside a real answer, and that body must not refuse here and answer streamed.
109
159
  const declined = message['refusal'];
110
160
  const refusal = typeof declined === 'string' && declined !== '' ? declined : undefined;
111
- const finish = parseFinishReason(choice['finish_reason']);
161
+ const read = parseToolCalls(message['tool_calls']);
162
+ const reading = readFinishReason(choice['finish_reason']);
163
+ const finish =
164
+ reading.kind === 'none'
165
+ ? undefined
166
+ : reading.kind === 'known'
167
+ ? reading.reason
168
+ : settledAs(read.calls);
112
169
  return {
113
170
  text: typeof message['content'] === 'string' ? message['content'] : '',
114
- toolCalls: parseToolCalls(message['tool_calls'], provider),
171
+ toolCalls: read.calls,
172
+ malformed: read.malformed,
115
173
  // A refusal string is a refusal whatever the finish reason says: the field only ever appears
116
174
  // when the model declined, and `stop` beside it would read downstream as an empty answer.
117
175
  stopReason: refusal === undefined ? (finish ?? 'end_turn') : 'refusal',
118
176
  stopDetails: refusalDetails(finish, refusal),
119
177
  usage: parseOpenAiUsage(record['usage']),
178
+ ...(reading.kind === 'unknown' ? { unknownFinish: reading.raw } : {}),
120
179
  };
121
180
  }
122
181
 
@@ -131,41 +190,52 @@ function refusalDetails(
131
190
  return { type: 'refusal', category: 'content_filter', explanation: undefined };
132
191
  }
133
192
 
134
- function parseToolCalls(raw: unknown, provider: string): readonly LlmToolCall[] {
135
- if (!Array.isArray(raw)) return [];
136
- const calls: LlmToolCall[] = [];
193
+ interface ReadCalls {
194
+ readonly calls: LlmToolCall[];
195
+ readonly malformed: MalformedToolCall[];
196
+ }
197
+
198
+ function parseToolCalls(raw: unknown): ReadCalls {
199
+ const read: ReadCalls = { calls: [], malformed: [] };
200
+ if (!Array.isArray(raw)) return read;
137
201
  for (const entry of raw) {
138
202
  const record = asRecord(entry);
139
203
  const fn = asRecord(record?.['function']);
140
204
  if (record === undefined || fn === undefined) continue;
141
205
  const name = typeof fn['name'] === 'string' ? fn['name'] : '';
142
206
  if (name === '') continue;
143
- calls.push({
144
- id: typeof record['id'] === 'string' ? record['id'] : '',
145
- name,
146
- input: parseArguments(fn['arguments'], name, provider),
147
- });
207
+ const args = fn['arguments'];
208
+ collect(read, typeof record['id'] === 'string' ? record['id'] : '', name, args);
148
209
  }
149
- return calls;
210
+ return read;
150
211
  }
151
212
 
152
- /** Arguments are a JSON string. A tool that takes none sends `''` or `'{}'`; neither is a fault. */
153
- function parseArguments(raw: unknown, name: string, provider: string): Record<string, unknown> {
154
- if (typeof raw !== 'string' || raw.trim() === '') return {};
155
- try {
156
- return asRecord(JSON.parse(raw)) ?? {};
157
- } catch (error) {
158
- throw malformed(
159
- provider,
160
- `tool "${name}" returned arguments that are not JSON: ${renderThrowable(error)}`,
161
- );
213
+ /** One call, read: among the calls when its arguments parse, among the malformed when not. */
214
+ function collect(
215
+ read: ReadCalls,
216
+ id: string,
217
+ name: string,
218
+ args: unknown,
219
+ ): LlmToolCall | undefined {
220
+ const raw = typeof args === 'string' ? args : '';
221
+ const input = parseToolArguments(raw);
222
+ if (input === undefined) {
223
+ read.malformed.push({ name, chars: raw.length });
224
+ return undefined;
162
225
  }
226
+ const call: LlmToolCall = { id, name, input };
227
+ read.calls.push(call);
228
+ return call;
163
229
  }
164
230
 
165
231
  interface PendingCall {
232
+ /** Where the call sorts among its turn's: the wire's `index`, else its order of arrival. */
233
+ readonly index: number;
166
234
  id: string;
167
235
  name: string;
168
236
  args: string;
237
+ /** A second NAMED fragment arrived with no index and no id: two calls, indistinguishable. */
238
+ unkeyed?: boolean;
169
239
  }
170
240
 
171
241
  /**
@@ -180,11 +250,14 @@ export class ChatCompletionStream {
180
250
  private text = '';
181
251
  private refusal = '';
182
252
  private stopReason: StopReason = 'end_turn';
253
+ private unknownFinish: string | undefined;
183
254
  private finished = false;
184
255
  private done = false;
185
256
  private usage: TokenUsage | undefined;
186
- private readonly pending = new Map<number, PendingCall>();
187
- private readonly toolCalls: LlmToolCall[] = [];
257
+ /** Every open call, in order of arrival; `byIndex` is which one a wire index currently means. */
258
+ private readonly pending: PendingCall[] = [];
259
+ private readonly byIndex = new Map<number, PendingCall>();
260
+ private readonly read: ReadCalls = { calls: [], malformed: [] };
188
261
  private readonly provider: string;
189
262
 
190
263
  constructor(provider: string) {
@@ -196,7 +269,7 @@ export class ChatCompletionStream {
196
269
  // The sentinel is not JSON, and parsing it is how a stream reader ends in a syntax error.
197
270
  if (frame.data.trim() === '[DONE]') {
198
271
  this.done = true;
199
- return [];
272
+ return this.end();
200
273
  }
201
274
  const payload = this.payloadOf(frame);
202
275
  throwInBandError(payload, this.provider);
@@ -205,7 +278,34 @@ export class ChatCompletionStream {
205
278
  const choice = asRecord(Array.isArray(payload['choices']) ? payload['choices'][0] : undefined);
206
279
  if (choice === undefined) return [];
207
280
  const chunks = this.onDelta(asRecord(choice['delta']) ?? {});
208
- return [...chunks, ...this.onFinish(choice['finish_reason'])];
281
+ this.onFinish(choice['finish_reason']);
282
+ return chunks;
283
+ }
284
+
285
+ /**
286
+ * The stream is over: the calls a finished turn made, whole, in index order. Called by the
287
+ * provider when the socket closes, and by `[DONE]` — whichever comes first; the second is empty.
288
+ *
289
+ * Here, and not when the finish reason arrives: this format has no per-call stop event, and
290
+ * nothing in it orders the finish reason against the last argument fragment — a call read at the
291
+ * reason is read half way whenever a gateway sends the reason first. A turn with no finish
292
+ * reason drains nothing: its fragments stay open, and `isComplete()` refuses the stream.
293
+ */
294
+ end(): readonly StreamChunk[] {
295
+ if (!this.finished) return [];
296
+ const chunks: StreamChunk[] = [];
297
+ for (const pending of this.pending.splice(0).sort((a, b) => a.index - b.index)) {
298
+ if (pending.name === '') continue;
299
+ if (pending.unkeyed === true) {
300
+ // Deliberate: merged arguments are never parsed, even when they happen to be JSON.
301
+ this.read.malformed.push({ name: pending.name, chars: pending.args.length, unkeyed: true });
302
+ continue;
303
+ }
304
+ const call = collect(this.read, pending.id, pending.name, pending.args);
305
+ if (call !== undefined) chunks.push({ type: 'tool-call', call });
306
+ }
307
+ this.byIndex.clear();
308
+ return chunks;
209
309
  }
210
310
 
211
311
  /** True once the answer is accounted for. False means the connection died mid-answer. */
@@ -223,20 +323,29 @@ export class ChatCompletionStream {
223
323
  // half a JSON object, and would report `end_turn` for a turn that stopped to call one. So the
224
324
  // stream is refused exactly as the Anthropic half refuses a missing `message_stop`
225
325
  // (`provider.ts`), and `openai-provider.ts`'s existing truncation guard is what raises it.
226
- return this.finished || (this.done && this.pending.size === 0);
326
+ return this.finished || (this.done && this.pending.length === 0);
227
327
  }
228
328
 
229
329
  /** What the stream accumulated. `cost` is applied by the provider, which owns prices. */
230
330
  state(): ChatAnswer {
331
+ // Idempotent, so a reader that never called `end()` still sees a finished turn's calls.
332
+ this.end();
231
333
  const details = this.refusalDetails();
232
334
  return {
233
335
  text: this.text,
234
- toolCalls: [...this.toolCalls],
336
+ toolCalls: [...this.read.calls],
337
+ malformed: [...this.read.malformed],
235
338
  // A refusal that arrived as a `refusal` delta finishes with `stop`, so the reason alone would
236
339
  // read as a complete answer that happens to be empty.
237
- stopReason: details === undefined ? this.stopReason : 'refusal',
340
+ stopReason:
341
+ details !== undefined
342
+ ? 'refusal'
343
+ : this.unknownFinish === undefined
344
+ ? this.stopReason
345
+ : settledAs(this.read.calls),
238
346
  stopDetails: details,
239
347
  usage: this.usage,
348
+ ...(this.unknownFinish === undefined ? {} : { unknownFinish: this.unknownFinish }),
240
349
  };
241
350
  }
242
351
 
@@ -270,54 +379,60 @@ export class ChatCompletionStream {
270
379
  }
271
380
 
272
381
  /**
273
- * Merge one chunk's tool-call fragments into the calls they belong to. `index` is the only key —
382
+ * Merge one chunk's tool-call fragments into the calls they belong to. `index` is the key —
274
383
  * `id` and `name` arrive on the first fragment and are absent from every later one, so appending
275
384
  * by array position instead would build one call per chunk and lose every argument but the last.
385
+ *
386
+ * It is not the WHOLE key. A gateway translating another vendor's stream may number every call
387
+ * of a turn `0` (or send no index) and tell them apart by id alone; merged by index, two
388
+ * well-formed calls became one string — `{…}{…}` — that is not JSON. So a fragment carrying an id
389
+ * that is not the open call's starts a new call. An id repeated on every fragment, or absent
390
+ * from all but the first, is still one call.
276
391
  */
277
392
  private accumulate(raw: unknown): void {
278
393
  if (!Array.isArray(raw)) return;
279
394
  for (const entry of raw) {
280
395
  const record = asRecord(entry);
281
396
  if (record === undefined) continue;
282
- const index = numberOf(record['index']) ?? 0;
283
- const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
284
- const id = record['id'];
285
- if (typeof id === 'string' && id !== '') call.id = id;
397
+ const index = numberOf(record['index']);
398
+ const rawId = record['id'];
399
+ const id = typeof rawId === 'string' && rawId !== '' ? rawId : undefined;
400
+ let call = index === undefined ? this.pending.at(-1) : this.byIndex.get(index);
401
+ if (call === undefined || (id !== undefined && call.id !== '' && call.id !== id)) {
402
+ call = { index: index ?? this.pending.length, id: '', name: '', args: '' };
403
+ this.pending.push(call);
404
+ }
405
+ if (index !== undefined) this.byIndex.set(index, call);
406
+ if (id !== undefined) call.id = id;
286
407
  const fn = asRecord(record['function']);
287
408
  const name = fn?.['name'];
409
+ // No index, no id, and a tool NAMED again after arguments already arrived: a second call
410
+ // this stream gives no way to tell from the first. A name split across frames arrives
411
+ // before any argument, so that legal case is not this one.
412
+ if (
413
+ index === undefined &&
414
+ id === undefined &&
415
+ call.args !== '' &&
416
+ name !== undefined &&
417
+ name !== ''
418
+ ) {
419
+ call.unkeyed = true;
420
+ }
288
421
  // Concatenated, not assigned: a server that splits the name across two frames is rare and
289
422
  // legal, and assigning would keep only the tail.
290
423
  if (typeof name === 'string') call.name += name;
291
424
  const args = fn?.['arguments'];
292
425
  if (typeof args === 'string') call.args += args;
293
- this.pending.set(index, call);
294
426
  }
295
427
  }
296
428
 
297
- /**
298
- * The finish reason closes the turn, and it is the only close this format has: there is no
299
- * per-block stop event, so pending tool calls are emitted here — whole, in index order, exactly
300
- * as the Anthropic path emits them at `content_block_stop`. A fragment is never a call.
301
- */
302
- private onFinish(raw: unknown): readonly StreamChunk[] {
303
- const reason = parseFinishReason(raw);
304
- if (reason === undefined) return [];
305
- this.stopReason = reason;
429
+ /** The finish reason closes the TURN. The calls are read by `end()`, when the stream is over. */
430
+ private onFinish(raw: unknown): void {
431
+ const reading = readFinishReason(raw);
432
+ if (reading.kind === 'none') return;
433
+ if (reading.kind === 'known') this.stopReason = reading.reason;
434
+ else this.unknownFinish = reading.raw;
306
435
  this.finished = true;
307
- const chunks: StreamChunk[] = [];
308
- for (const index of [...this.pending.keys()].sort((a, b) => a - b)) {
309
- const pending = this.pending.get(index);
310
- if (pending === undefined || pending.name === '') continue;
311
- const call: LlmToolCall = {
312
- id: pending.id,
313
- name: pending.name,
314
- input: parseArguments(pending.args, pending.name, this.provider),
315
- };
316
- this.toolCalls.push(call);
317
- chunks.push({ type: 'tool-call', call });
318
- }
319
- this.pending.clear();
320
- return chunks;
321
436
  }
322
437
 
323
438
  private payloadOf(frame: SseFrame): Record<string, unknown> {
@@ -0,0 +1,114 @@
1
+ // Single responsibility: the Anthropic Messages wire's half of prompt caching — `system` as text
2
+ // blocks, and `cache_control` on the blocks ./prompt-cache's `blocks` rule names. Pure, so the body
3
+ // a test asserts is the body the socket gets.
4
+
5
+ import type { SystemPrompt } from './prompt-cache';
6
+ import { CACHE_MARK, systemParts } from './prompt-cache';
7
+ import type { AiMessage, GenerateRequest } from './provider';
8
+
9
+ type Marked<T> = T & { readonly cache_control?: typeof CACHE_MARK };
10
+ type TextBlock = Marked<{ readonly type: 'text'; readonly text: string }>;
11
+ type WireMessage = { readonly role: AiMessage['role']; readonly content: unknown };
12
+
13
+ /** The vendor's ceiling on breakpoints in one request; a fifth is a 400. */
14
+ const MAX_BREAKPOINTS = 4;
15
+
16
+ /** An empty or whitespace-only text block cannot carry a mark (and is a 400 with one). */
17
+ const blank = (text: string): boolean => text.trim() === '';
18
+
19
+ export interface AnthropicPrompt {
20
+ readonly system: string | readonly TextBlock[] | undefined;
21
+ readonly messages: readonly WireMessage[];
22
+ }
23
+
24
+ /**
25
+ * `system` and `messages` as the wire takes them. Unmarked, a string system stays the string every
26
+ * existing request sent and the messages pass through untouched.
27
+ *
28
+ * Marked, at most three breakpoints, spent in this order: the transcript's TAIL (what the next
29
+ * request reads back), the stable system block (what other runs share), then the message before
30
+ * the tail (a second start for the 20-block lookback). Marks the caller already placed — a raw
31
+ * block or tool carrying its own `cache_control` — count against the four first, so this never
32
+ * turns a legal request into a 400; ours are the ones dropped.
33
+ */
34
+ export function anthropicPrompt(request: GenerateRequest, marked: boolean): AnthropicPrompt {
35
+ const { messages } = request;
36
+ if (!marked) {
37
+ return { system: systemOf(request.system, false), messages: messages.map(plain) };
38
+ }
39
+ let left = MAX_BREAKPOINTS - placedBy(request);
40
+ const take = (wanted: boolean): boolean => {
41
+ if (!wanted || left <= 0) return false;
42
+ left -= 1;
43
+ return true;
44
+ };
45
+ const tail = messages.length - 1;
46
+ const markTail = take(tail >= 0 && markableAt(messages[tail]) !== undefined);
47
+ const markSystem = take(!blank(systemParts(request.system).stable));
48
+ const markPrevious = take(tail >= 1 && markableAt(messages[tail - 1]) !== undefined);
49
+ return {
50
+ system: systemOf(request.system, markSystem),
51
+ messages: messages.map((message, index) =>
52
+ (index === tail && markTail) || (index === tail - 1 && markPrevious)
53
+ ? { role: message.role, content: markedContent(message.content) }
54
+ : plain(message),
55
+ ),
56
+ };
57
+ }
58
+
59
+ const plain = (message: AiMessage): WireMessage => ({
60
+ role: message.role,
61
+ content: message.content,
62
+ });
63
+
64
+ /** The stable block first and the only one marked; an empty part is dropped, never sent. */
65
+ function systemOf(
66
+ system: SystemPrompt | undefined,
67
+ marked: boolean,
68
+ ): string | readonly TextBlock[] | undefined {
69
+ if (system === undefined) return undefined;
70
+ if (typeof system === 'string' && !marked) return system;
71
+ const { stable, run } = systemParts(system);
72
+ const blocks: TextBlock[] = [];
73
+ if (stable !== '') {
74
+ blocks.push({ type: 'text', text: stable, ...(marked ? { cache_control: CACHE_MARK } : {}) });
75
+ }
76
+ if (run !== '') blocks.push({ type: 'text', text: run });
77
+ return blocks.length === 0 ? undefined : blocks;
78
+ }
79
+
80
+ /**
81
+ * Where one message's mark goes: its LAST block that is not blank text, so the mark moves back
82
+ * past a trailing empty block rather than landing on it. `undefined` when no block can carry one.
83
+ */
84
+ function markableAt(message: AiMessage | undefined): number | undefined {
85
+ if (message === undefined) return undefined;
86
+ const { content } = message;
87
+ if (typeof content === 'string') return blank(content) ? undefined : 0;
88
+ for (let index = content.length - 1; index >= 0; index -= 1) {
89
+ const block = content[index];
90
+ if (block !== undefined && !(block.type === 'text' && blank(block.text))) return index;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function markedContent(content: AiMessage['content']): unknown {
96
+ if (typeof content === 'string') {
97
+ return [{ type: 'text', text: content, cache_control: CACHE_MARK }];
98
+ }
99
+ const at = markableAt({ role: 'user', content });
100
+ return content.map((block, index) =>
101
+ index === at ? { ...block, cache_control: CACHE_MARK } : block,
102
+ );
103
+ }
104
+
105
+ /** Breakpoints the caller placed itself: the types do not offer the field, a cast still sends it. */
106
+ function placedBy(request: GenerateRequest): number {
107
+ const carries = (value: unknown): boolean =>
108
+ typeof value === 'object' && value !== null && Object.hasOwn(value, 'cache_control');
109
+ let count = (request.tools ?? []).filter(carries).length;
110
+ for (const { content } of request.messages) {
111
+ if (typeof content !== 'string') count += content.filter(carries).length;
112
+ }
113
+ return count;
114
+ }