@providerkit/core 0.15.0 → 0.16.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.
Files changed (56) hide show
  1. package/README.md +3 -0
  2. package/dist/errors.d.ts +17 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +55 -6
  5. package/dist/errors.js.map +1 -1
  6. package/dist/presets.d.ts +37 -2
  7. package/dist/presets.d.ts.map +1 -1
  8. package/dist/presets.js +37 -2
  9. package/dist/presets.js.map +1 -1
  10. package/dist/providers/anthropic.d.ts.map +1 -1
  11. package/dist/providers/anthropic.js +84 -33
  12. package/dist/providers/anthropic.js.map +1 -1
  13. package/dist/providers/gemini.d.ts.map +1 -1
  14. package/dist/providers/gemini.js +25 -1
  15. package/dist/providers/gemini.js.map +1 -1
  16. package/dist/providers/openai.d.ts.map +1 -1
  17. package/dist/providers/openai.js +44 -16
  18. package/dist/providers/openai.js.map +1 -1
  19. package/dist/providers/responses.d.ts.map +1 -1
  20. package/dist/providers/responses.js +131 -24
  21. package/dist/providers/responses.js.map +1 -1
  22. package/dist/rate-limit.d.ts.map +1 -1
  23. package/dist/rate-limit.js +53 -22
  24. package/dist/rate-limit.js.map +1 -1
  25. package/dist/schema.d.ts +10 -0
  26. package/dist/schema.d.ts.map +1 -1
  27. package/dist/schema.js +49 -0
  28. package/dist/schema.js.map +1 -1
  29. package/dist/transport.d.ts +30 -3
  30. package/dist/transport.d.ts.map +1 -1
  31. package/dist/transport.js +67 -28
  32. package/dist/transport.js.map +1 -1
  33. package/dist/types.d.ts +26 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. package/dist/usage.d.ts +5 -4
  37. package/dist/usage.d.ts.map +1 -1
  38. package/dist/usage.js +14 -5
  39. package/dist/usage.js.map +1 -1
  40. package/dist/watchdog.d.ts +22 -8
  41. package/dist/watchdog.d.ts.map +1 -1
  42. package/dist/watchdog.js +77 -27
  43. package/dist/watchdog.js.map +1 -1
  44. package/package.json +3 -2
  45. package/src/errors.ts +60 -5
  46. package/src/presets.ts +37 -2
  47. package/src/providers/anthropic.ts +94 -36
  48. package/src/providers/gemini.ts +30 -1
  49. package/src/providers/openai.ts +42 -16
  50. package/src/providers/responses.ts +158 -26
  51. package/src/rate-limit.ts +55 -24
  52. package/src/schema.ts +51 -0
  53. package/src/transport.ts +95 -24
  54. package/src/types.ts +26 -2
  55. package/src/usage.ts +14 -5
  56. package/src/watchdog.ts +89 -32
@@ -1,5 +1,5 @@
1
1
  // Anthropic-shape adapter — SSE from POST /v1/messages.
2
- import { fileRefused, streamError } from "../errors.ts";
2
+ import { fileRefused, streamCut, streamError } from "../errors.ts";
3
3
  import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
4
4
  import { parseToolArgs } from "../tool-args.ts";
5
5
  import { streamSse, apiUrl } from "../transport.ts";
@@ -14,6 +14,7 @@ import type {
14
14
  Provider,
15
15
  ProviderChunk,
16
16
  StreamOptions,
17
+ ToolChoice,
17
18
  ToolDefinition,
18
19
  } from "../types.ts";
19
20
 
@@ -82,14 +83,19 @@ const THINKING_BUDGET: Record<Exclude<Effort, "none">, number> = {
82
83
 
83
84
  /** How one Claude model that thinks adaptively takes `effort`. */
84
85
  interface ClaudeThinking {
85
- /** How `effort: "none"` is said: nothing, an explicit off, or the lowest
86
- * effort where there is no off to ask for. */
87
- none: "omit" | "disabled" | "low";
86
+ /** How `effort: "none"` is said: nothing, an explicit off, an off for
87
+ * up-front thinking only, or the lowest effort where there is no off. */
88
+ none: "omit" | "disabled" | "between_tools" | "low";
88
89
  /** Whether a non-default temperature or top_p survives while thinking is off. */
89
90
  sampling: boolean;
91
+ /** Whether a tool choice that forces a tool (`any`, or one named tool) is
92
+ * accepted. Where it is not, it is a 400 on every request. */
93
+ forcedTool: boolean;
90
94
  }
91
95
 
92
- const ALWAYS_ON: ClaudeThinking = { none: "low", sampling: false };
96
+ const ALWAYS_ON: ClaudeThinking = { none: "low", sampling: false, forcedTool: true };
97
+ /** Always on, and a forced tool choice is a 400: the 5.1 and 5.5 models. */
98
+ const LATEST: ClaudeThinking = { ...ALWAYS_ON, forcedTool: false };
93
99
 
94
100
  /**
95
101
  * Claude models that take adaptive thinking, keyed by id. A graded effort is
@@ -103,6 +109,10 @@ const ALWAYS_ON: ClaudeThinking = { none: "low", sampling: false };
103
109
  * `sampling: false` marks the models where a non-default temperature, top_p or
104
110
  * top_k is a 400 on every request, thinking or not. Documented on the same date
105
111
  * (…/build-with-claude/thinking, "Sampling parameters").
112
+ *
113
+ * `forcedTool: false` marks the models that "reject forced tool use on every
114
+ * request with a 400 error" (same page, "Response prefill and forced tool use",
115
+ * read 2026-10-01). Every other model here takes it with adaptive thinking.
106
116
  */
107
117
  const CLAUDE_THINKING: ReadonlyMap<string, ClaudeThinking> = new Map([
108
118
  // Always on: `enabled` and `disabled` are both 400s (Mythos Preview still
@@ -110,12 +120,17 @@ const CLAUDE_THINKING: ReadonlyMap<string, ClaudeThinking> = new Map([
110
120
  // effort rather than silence, because silence runs the model's own default —
111
121
  // `high`, or `medium` on Opus 5.5 — which is the request `none` exists to
112
122
  // refuse (invariant 12).
113
- ["claude-fable-5-1", ALWAYS_ON],
114
- ["claude-mythos-5-1", ALWAYS_ON],
123
+ ["claude-fable-5-1", LATEST],
124
+ ["claude-mythos-5-1", LATEST],
125
+ ["claude-opus-5-5", LATEST],
115
126
  ["claude-fable-5", ALWAYS_ON],
116
127
  ["claude-mythos-5", ALWAYS_ON],
117
128
  ["claude-mythos-preview", ALWAYS_ON],
118
- ["claude-opus-5-5", ALWAYS_ON],
129
+ // On by default, and `disabled` is a 400. `between_tools` is its lowest
130
+ // setting: no thinking up front, at effort high or below — and high is this
131
+ // model's default, so it needs no effort beside it (effort page, read
132
+ // 2026-10-01). It is also the one field it takes.
133
+ ["claude-sonnet-5-5", { ...LATEST, none: "between_tools" }],
119
134
  // On by default, and `disabled` is accepted at effort high or below. `none`
120
135
  // still says `low`: with thinking disabled, Opus 5 occasionally writes a tool
121
136
  // call into its visible text instead of a tool_use block, most often on
@@ -126,16 +141,16 @@ const CLAUDE_THINKING: ReadonlyMap<string, ClaudeThinking> = new Map([
126
141
  // thinking on a turn it judges simple.
127
142
  ["claude-opus-5", ALWAYS_ON],
128
143
  // On by default; `disabled` is accepted, with no such warning attached.
129
- ["claude-sonnet-5", { none: "disabled", sampling: false }],
144
+ ["claude-sonnet-5", { none: "disabled", sampling: false, forcedTool: true }],
130
145
  // Off by default, so saying nothing already is none.
131
- ["claude-opus-4-8", { none: "omit", sampling: false }],
132
- ["claude-opus-4-7", { none: "omit", sampling: false }],
146
+ ["claude-opus-4-8", { none: "omit", sampling: false, forcedTool: true }],
147
+ ["claude-opus-4-7", { none: "omit", sampling: false, forcedTool: true }],
133
148
  // These still take `budget_tokens`, deprecated. Adaptive is the mode the docs
134
149
  // say to use where both exist, and it drops extended mode's rule that the
135
150
  // final assistant turn open with a thinking block — which this adapter,
136
151
  // replaying no thinking, can never satisfy.
137
- ["claude-opus-4-6", { none: "omit", sampling: true }],
138
- ["claude-sonnet-4-6", { none: "omit", sampling: true }],
152
+ ["claude-opus-4-6", { none: "omit", sampling: true, forcedTool: true }],
153
+ ["claude-sonnet-4-6", { none: "omit", sampling: true, forcedTool: true }],
139
154
  ]);
140
155
 
141
156
  /** Claude 4.5 and everything before it: extended thinking only (`adaptive` is
@@ -152,14 +167,15 @@ const CLAUDE_EXTENDED_ONLY = /^claude-(3-|(opus|sonnet|haiku)-4(-[0-5])?$)/;
152
167
  * rejects `enabled`; the newest reject `disabled` too; and `output_config.effort`
153
168
  * is accepted by every model that thinks adaptively. So an unknown id gets the
154
169
  * one request none of them has refused, and no sampling fields, since every
155
- * Claude since Opus 4.7 answers a non-default one with a 400. A model that
156
- * turns out to differ will say so with a 400 of its own, and earn a row.
170
+ * Claude since Opus 4.7 answers a non-default one with a 400. Nor a forced tool
171
+ * choice, which the newest refuse and an unforced request never trips. A model
172
+ * that turns out to differ will say so with a 400 of its own, and earn a row.
157
173
  */
158
174
  function claudeThinking(model: string): ClaudeThinking | undefined {
159
175
  if (!model.startsWith("claude-")) return undefined;
160
176
  const family = model.replace(/-\d{8}$/, ""); // a dated snapshot is its family
161
177
  if (CLAUDE_EXTENDED_ONLY.test(family)) return undefined;
162
- return CLAUDE_THINKING.get(family) ?? ALWAYS_ON;
178
+ return CLAUDE_THINKING.get(family) ?? LATEST;
163
179
  }
164
180
 
165
181
  /** Whether a request continues a tool loop: its last assistant message made
@@ -188,6 +204,18 @@ function mapStopReason(reason: string | undefined): FinishReason | undefined {
188
204
  }
189
205
  }
190
206
 
207
+ interface AnthropicToolChoice {
208
+ type: "auto" | "any" | "none" | "tool";
209
+ name?: string;
210
+ disable_parallel_tool_use?: boolean;
211
+ }
212
+
213
+ function toAnthropicToolChoice(choice: Exclude<ToolChoice, "auto">): AnthropicToolChoice {
214
+ if (choice === "none") return { type: "none" };
215
+ if (choice === "required") return { type: "any" };
216
+ return { type: "tool", name: choice.name };
217
+ }
218
+
191
219
  function partsToAnthropic(content: string | ContentPart[], provider: string): unknown[] {
192
220
  if (typeof content === "string") return [{ type: "text", text: content }];
193
221
  return content.map((part) => {
@@ -354,22 +382,36 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
354
382
  if (opts.temperature !== undefined) request.temperature = opts.temperature;
355
383
  if (opts.topP !== undefined) request.top_p = opts.topP;
356
384
  if (opts.stopSequences?.length) request.stop_sequences = opts.stopSequences;
357
- if (tools.length > 0) {
358
- request.tools = tools.map((tool) => ({
385
+ const claude = claudeThinking(model);
386
+ const forcesTool = opts.toolChoice === "required" || typeof opts.toolChoice === "object";
387
+ // A model that refuses a forced tool choice gets `auto` instead, which is
388
+ // the docs' own advice, and a named tool becomes the only tool it is
389
+ // offered. Sending the choice anyway is a 400 on every request.
390
+ // ponytail: the model may still answer in text. The upgrade is strict
391
+ // tool use or structured outputs, which the docs pair with `auto`.
392
+ const unforced = forcesTool && claude?.forcedTool === false;
393
+ const named = typeof opts.toolChoice === "object" ? opts.toolChoice.name : undefined;
394
+ const offered =
395
+ unforced && named !== undefined ? tools.filter((tool) => tool.name === named) : tools;
396
+ if (offered.length > 0) {
397
+ request.tools = offered.map((tool) => ({
359
398
  name: tool.name,
360
399
  description: tool.description,
361
400
  input_schema: toAnthropicToolSchema(tool.inputSchema),
362
401
  }));
363
402
  }
364
- if (opts.toolChoice && opts.toolChoice !== "auto") {
365
- request.tool_choice =
366
- opts.toolChoice === "none"
367
- ? { type: "none" }
368
- : opts.toolChoice === "required"
369
- ? { type: "any" }
370
- : { type: "tool", name: opts.toolChoice.name };
403
+ const choice =
404
+ opts.toolChoice && opts.toolChoice !== "auto" && !unforced
405
+ ? toAnthropicToolChoice(opts.toolChoice)
406
+ : undefined;
407
+ // This wire says "one call at a time" inside the tool choice, so asking
408
+ // for it needs a choice to ride on — `auto` when the caller named none.
409
+ // `none` calls nothing, so there is nothing to serialize.
410
+ if (opts.parallelToolCalls === false && offered.length > 0 && choice?.type !== "none") {
411
+ request.tool_choice = { ...(choice ?? { type: "auto" }), disable_parallel_tool_use: true };
412
+ } else if (choice) {
413
+ request.tool_choice = choice;
371
414
  }
372
- const claude = claudeThinking(model);
373
415
  // Extended mode, which is all Claude 4.5 and older have, can't think on
374
416
  // two kinds of request this adapter sends, so those run without thinking.
375
417
  // Documented (Anthropic Thinking page, read 2026-09-27):
@@ -382,7 +424,6 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
382
424
  // manual extended thinking." The caller asked for the tool, so it wins.
383
425
  // ponytail: every loop step after the first goes unthought on these
384
426
  // models. The upgrade is replaying the signed thinking blocks.
385
- const forcesTool = opts.toolChoice === "required" || typeof opts.toolChoice === "object";
386
427
  const unthought =
387
428
  !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(messages));
388
429
  if (claude) {
@@ -390,6 +431,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
390
431
  // default, which on most of these is thinking ON.
391
432
  if (effort === "none") {
392
433
  if (claude.none === "disabled") request.thinking = { type: "disabled" };
434
+ if (claude.none === "between_tools") request.thinking = { type: "between_tools" };
393
435
  if (claude.none === "low") request.output_config = { effort: "low" };
394
436
  } else if (effort) {
395
437
  request.thinking = { type: "adaptive" };
@@ -423,6 +465,19 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
423
465
  let outputTokens = 0;
424
466
  let toolCall: { index: number; id: string; name: string } | null = null;
425
467
  let blockIndex = -1;
468
+ // What proves the turn ended rather than the socket: a stated stop
469
+ // reason, or `message_stop`. See the check after the loop.
470
+ let stopped = false;
471
+ let closed = false;
472
+ const usageChunk = (): ProviderChunk => ({
473
+ type: "usage",
474
+ usage: {
475
+ inputTokens: freshInputTokens + cachedInputTokens + cacheWriteTokens,
476
+ cachedInputTokens,
477
+ cacheWriteTokens,
478
+ outputTokens,
479
+ },
480
+ });
426
481
 
427
482
  for await (const data of streamSse({
428
483
  url: apiUrl(baseUrl, "/v1/messages"),
@@ -438,6 +493,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
438
493
  provider: id,
439
494
  ...(opts.signal ? { signal: opts.signal } : {}),
440
495
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
496
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
441
497
  })) {
442
498
  let event: AnthropicEvent;
443
499
  try {
@@ -505,24 +561,26 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
505
561
  cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
506
562
  cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
507
563
  outputTokens = event.usage?.output_tokens ?? outputTokens;
564
+ if (event.delta?.stop_reason) stopped = true;
508
565
  const finishReason = mapStopReason(event.delta?.stop_reason);
509
566
  if (finishReason) yield { type: "finish", finishReason };
510
567
  break;
511
568
  }
512
569
 
513
570
  case "message_stop":
514
- yield {
515
- type: "usage",
516
- usage: {
517
- inputTokens: freshInputTokens + cachedInputTokens + cacheWriteTokens,
518
- cachedInputTokens,
519
- cacheWriteTokens,
520
- outputTokens,
521
- },
522
- };
571
+ closed = true;
572
+ yield usageChunk();
523
573
  break;
524
574
  }
525
575
  }
576
+ if (closed) return;
577
+ // A gateway that states its stop reason and then skips `message_stop`
578
+ // still finished the turn — and still billed it.
579
+ if (stopped) {
580
+ yield usageChunk();
581
+ return;
582
+ }
583
+ throw streamCut(id);
526
584
  },
527
585
  };
528
586
  return withConfiguredFallbacks(provider, config);
@@ -5,7 +5,7 @@
5
5
  // its own chain of thought on the next turn. Written straight against the REST
6
6
  // wire rather than @google/genai, because this package ships zero dependencies
7
7
  // and the SDK is a Node-shaped one.
8
- import { streamError } from "../errors.ts";
8
+ import { ProviderError, streamCut, streamError } from "../errors.ts";
9
9
  import { schemaPrompt, toGeminiToolSchema } from "../schema.ts";
10
10
  import { parseToolArgs } from "../tool-args.ts";
11
11
  import { streamSse, apiUrl } from "../transport.ts";
@@ -257,6 +257,8 @@ interface GeminiResponse {
257
257
  };
258
258
  /** Present only on the in-band failure below — never on a real candidate. */
259
259
  error?: GeminiStatus;
260
+ /** A prompt Gemini refused outright: no candidate at all, just the reason. */
261
+ promptFeedback?: { blockReason?: string };
260
262
  }
261
263
 
262
264
  export function createGeminiProvider(config: GeminiConfig): Provider {
@@ -329,6 +331,16 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
329
331
  ];
330
332
  if (systemParts.length > 0) request.systemInstruction = { parts: systemParts };
331
333
  if (tools.length > 0) {
334
+ // Gemini has no switch for one call per turn. Sending the request
335
+ // anyway would hand back the parallel calls the caller ruled out.
336
+ if (opts.parallelToolCalls === false) {
337
+ throw new ProviderError(
338
+ id,
339
+ "invalid",
340
+ `${id}: Gemini can't limit a turn to one tool call. Leave parallelToolCalls unset ` +
341
+ "for Gemini models, or run the calls it returns one at a time.",
342
+ );
343
+ }
332
344
  request.tools = [
333
345
  {
334
346
  functionDeclarations: tools.map((tool) => ({
@@ -351,6 +363,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
351
363
  // remembered rather than read off the final chunk.
352
364
  let sawFunctionCalls = false;
353
365
  let callIndex = 0;
366
+ let finished = false;
354
367
 
355
368
  for await (const data of streamSse({
356
369
  // Without `?alt=sse` the response is one long JSON array that only
@@ -361,6 +374,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
361
374
  provider: id,
362
375
  ...(opts.signal ? { signal: opts.signal } : {}),
363
376
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
377
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
364
378
  })) {
365
379
  let chunk: GeminiResponse;
366
380
  try {
@@ -373,6 +387,14 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
373
387
  // throttle or an overload landing after that arrives here as a
374
388
  // google.rpc.Status in the body rather than as a status line.
375
389
  if (chunk.error) throw streamError(id, chunk.error);
390
+ // A blocked PROMPT has no candidate to finish, so it reads as a cut
391
+ // stream — retried, into the same block. It is a content failure.
392
+ const blocked = chunk.promptFeedback?.blockReason;
393
+ if (blocked) {
394
+ throw new ProviderError(id, "content", `${id}: the prompt was blocked (${blocked})`, {
395
+ code: blocked,
396
+ });
397
+ }
376
398
 
377
399
  if (chunk.usageMetadata) {
378
400
  const usage = chunk.usageMetadata;
@@ -385,6 +407,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
385
407
  // turn by most of what it cost.
386
408
  outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
387
409
  cachedInputTokens: usage.cachedContentTokenCount ?? 0,
410
+ ...(usage.thoughtsTokenCount !== undefined
411
+ ? { reasoningTokens: usage.thoughtsTokenCount }
412
+ : {}),
388
413
  },
389
414
  };
390
415
  }
@@ -428,6 +453,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
428
453
  }
429
454
 
430
455
  if (candidate.finishReason) {
456
+ finished = true;
431
457
  yield {
432
458
  type: "finish",
433
459
  // A turn that called tools finishes as tool_calls whatever the
@@ -437,6 +463,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
437
463
  };
438
464
  }
439
465
  }
466
+ // Gemini states a finish reason on the last candidate of every turn it
467
+ // completes; a stream that closes without one was cut.
468
+ if (!finished) throw streamCut(id);
440
469
  },
441
470
  };
442
471
 
@@ -3,7 +3,7 @@
3
3
  // This is the dialect most gateways speak, so one adapter serves OpenAI,
4
4
  // OpenRouter, DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio.
5
5
  // Their divergences are small and named where they appear.
6
- import { fileRefused, streamError } from "../errors.ts";
6
+ import { fileRefused, streamCut, streamError } from "../errors.ts";
7
7
  import { streamSse, apiUrl } from "../transport.ts";
8
8
  import { attributionHeaders } from "../attribution.ts";
9
9
  import type {
@@ -315,7 +315,14 @@ function partsToOpenAI(content: string | ContentPart[], provider: string): unkno
315
315
  */
316
316
  export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "openai"): unknown[] {
317
317
  const out: unknown[] = [];
318
+ // Images from a run of tool results, held until the run ends. See "tool".
319
+ let toolImages: unknown[] = [];
320
+ const flushToolImages = () => {
321
+ if (toolImages.length > 0) out.push({ role: "user", content: toolImages });
322
+ toolImages = [];
323
+ };
318
324
  for (const message of messages) {
325
+ if (message.role !== "tool") flushToolImages();
319
326
  switch (message.role) {
320
327
  case "system":
321
328
  out.push({ role: "system", content: message.content });
@@ -329,17 +336,16 @@ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "o
329
336
  out.push({ role: "tool", tool_call_id: message.toolCallId, content: message.content });
330
337
  // This dialect has no image slot on a tool message — a `tool` role takes
331
338
  // text and nothing else. A screenshot a tool hands back therefore
332
- // follows as its own user message, which is the only way the model ever
333
- // sees it. Dropped instead, the turn reads as a tool that returned
334
- // words about a picture nobody was shown.
335
- if (message.images?.length) {
336
- out.push({
337
- role: "user",
338
- content: message.images.map((image) => ({
339
- type: "image_url",
340
- image_url: { url: toDataUri(image) },
341
- })),
342
- });
339
+ // follows as a user message, which is the only way the model ever sees
340
+ // it. Dropped instead, the turn reads as a tool that returned words
341
+ // about a picture nobody was shown.
342
+ //
343
+ // It follows the whole RUN of tool results, not this one: an assistant
344
+ // turn that made parallel calls needs every answer before any user
345
+ // message, and an image wedged between two of them is a 400 that
346
+ // classifies as "invalid" — never retried, never walked to a backup.
347
+ for (const image of message.images ?? []) {
348
+ toolImages.push({ type: "image_url", image_url: { url: toDataUri(image) } });
343
349
  }
344
350
  break;
345
351
 
@@ -369,6 +375,7 @@ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "o
369
375
  }
370
376
  }
371
377
  }
378
+ flushToolImages();
372
379
  return out;
373
380
  }
374
381
 
@@ -392,9 +399,12 @@ interface OpenAIChunk {
392
399
  prompt_tokens?: number;
393
400
  completion_tokens?: number;
394
401
  prompt_tokens_details?: { cached_tokens?: number };
402
+ completion_tokens_details?: { reasoning_tokens?: number };
395
403
  /** DeepSeek's native API reports the cache-hit count here instead of in
396
404
  * `prompt_tokens_details`, and it is absent from every OpenAI SDK type. */
397
405
  prompt_cache_hit_tokens?: number;
406
+ /** Kimi (Moonshot) puts it at the top level of `usage`. */
407
+ cached_tokens?: number;
398
408
  /** OpenRouter only, from here down: what the call cost, in its credits,
399
409
  * which are US dollars. */
400
410
  cost?: unknown;
@@ -487,6 +497,8 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
487
497
  parameters: tool.inputSchema,
488
498
  },
489
499
  }));
500
+ // Only beside tools: OpenAI refuses the field on a request without any.
501
+ if (opts.parallelToolCalls === false) request.parallel_tool_calls = false;
490
502
  }
491
503
  if (opts.toolChoice && opts.toolChoice !== "auto") {
492
504
  request.tool_choice =
@@ -561,6 +573,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
561
573
  ? "/chat/completions"
562
574
  : "/v1/chat/completions";
563
575
 
576
+ let ended = false;
564
577
  for await (const data of streamSse({
565
578
  url: apiUrl(baseUrl, config.path ?? defaultPath),
566
579
  headers: {
@@ -575,6 +588,10 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
575
588
  provider: id,
576
589
  ...(opts.signal ? { signal: opts.signal } : {}),
577
590
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
591
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
592
+ onDone: () => {
593
+ ended = true;
594
+ },
578
595
  })) {
579
596
  let chunk: OpenAIChunk;
580
597
  try {
@@ -594,13 +611,15 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
594
611
  // A usage-only frame carries no choices — this shape sends it last.
595
612
  if (chunk.usage) {
596
613
  const input = chunk.usage.prompt_tokens ?? 0;
597
- // Two spellings for the same subset. DeepSeek's native endpoint uses
598
- // its own field, and reading only the standard one bills every cached
599
- // token at the full input rate — on an agent loop, where the re-sent
600
- // prefix is overwhelmingly hits, that overstates a run by up to 10×.
614
+ // Three spellings for the same subset. DeepSeek's native endpoint and
615
+ // Kimi each use their own field, and reading only the standard one
616
+ // bills every cached token at the full input rate — on an agent loop,
617
+ // where the re-sent prefix is overwhelmingly hits, that overstates a
618
+ // run by up to 10×.
601
619
  const cached =
602
620
  chunk.usage.prompt_tokens_details?.cached_tokens ??
603
621
  chunk.usage.prompt_cache_hit_tokens ??
622
+ chunk.usage.cached_tokens ??
604
623
  0;
605
624
  const reportedCostUsd = reportsCost ? openRouterCostUsd(chunk.usage) : undefined;
606
625
  yield {
@@ -611,6 +630,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
611
630
  // Anthropic's, which excludes them. No reconciling to do.
612
631
  cachedInputTokens: cached,
613
632
  outputTokens: chunk.usage.completion_tokens ?? 0,
633
+ ...(chunk.usage.completion_tokens_details?.reasoning_tokens !== undefined
634
+ ? { reasoningTokens: chunk.usage.completion_tokens_details.reasoning_tokens }
635
+ : {}),
614
636
  ...(reportedCostUsd !== undefined ? { reportedCostUsd } : {}),
615
637
  },
616
638
  };
@@ -653,9 +675,13 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
653
675
  if (has) yield out;
654
676
  }
655
677
 
678
+ if (choice.finish_reason) ended = true;
656
679
  const finishReason = mapFinishReason(choice.finish_reason);
657
680
  if (finishReason) yield { type: "finish", finishReason };
658
681
  }
682
+ // Either proof will do: some gateways send `[DONE]` with no finish, and
683
+ // some a finish with no `[DONE]`. Neither means the socket closed early.
684
+ if (!ended) throw streamCut(id);
659
685
  },
660
686
  };
661
687