@providerkit/core 0.22.0 → 0.23.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 (97) hide show
  1. package/dist/auth/flows.d.ts.map +1 -1
  2. package/dist/auth/flows.js +5 -5
  3. package/dist/auth/flows.js.map +1 -1
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +1 -0
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/auth/oauth.d.ts +12 -0
  9. package/dist/auth/oauth.d.ts.map +1 -1
  10. package/dist/auth/oauth.js +19 -1
  11. package/dist/auth/oauth.js.map +1 -1
  12. package/dist/auth/token-source.d.ts +9 -3
  13. package/dist/auth/token-source.d.ts.map +1 -1
  14. package/dist/auth/token-source.js +24 -9
  15. package/dist/auth/token-source.js.map +1 -1
  16. package/dist/auth/with-token.d.ts +29 -0
  17. package/dist/auth/with-token.d.ts.map +1 -0
  18. package/dist/auth/with-token.js +85 -0
  19. package/dist/auth/with-token.js.map +1 -0
  20. package/dist/context.d.ts +39 -1
  21. package/dist/context.d.ts.map +1 -1
  22. package/dist/context.js +119 -0
  23. package/dist/context.js.map +1 -1
  24. package/dist/errors.d.ts +12 -3
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +44 -5
  27. package/dist/errors.js.map +1 -1
  28. package/dist/fallback.d.ts +45 -1
  29. package/dist/fallback.d.ts.map +1 -1
  30. package/dist/fallback.js +138 -75
  31. package/dist/fallback.js.map +1 -1
  32. package/dist/key-pool.d.ts +10 -4
  33. package/dist/key-pool.d.ts.map +1 -1
  34. package/dist/key-pool.js +29 -12
  35. package/dist/key-pool.js.map +1 -1
  36. package/dist/presets.d.ts +7 -2
  37. package/dist/presets.d.ts.map +1 -1
  38. package/dist/presets.js +12 -2
  39. package/dist/presets.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts.map +1 -1
  41. package/dist/providers/anthropic.js +80 -25
  42. package/dist/providers/anthropic.js.map +1 -1
  43. package/dist/providers/gemini.d.ts +6 -0
  44. package/dist/providers/gemini.d.ts.map +1 -1
  45. package/dist/providers/gemini.js +102 -27
  46. package/dist/providers/gemini.js.map +1 -1
  47. package/dist/providers/openai.d.ts.map +1 -1
  48. package/dist/providers/openai.js +63 -14
  49. package/dist/providers/openai.js.map +1 -1
  50. package/dist/providers/responses.d.ts.map +1 -1
  51. package/dist/providers/responses.js +98 -52
  52. package/dist/providers/responses.js.map +1 -1
  53. package/dist/rate-limit.d.ts +10 -0
  54. package/dist/rate-limit.d.ts.map +1 -1
  55. package/dist/rate-limit.js +7 -1
  56. package/dist/rate-limit.js.map +1 -1
  57. package/dist/retry.d.ts +11 -0
  58. package/dist/retry.d.ts.map +1 -1
  59. package/dist/retry.js +25 -12
  60. package/dist/retry.js.map +1 -1
  61. package/dist/tool-args.d.ts +22 -0
  62. package/dist/tool-args.d.ts.map +1 -1
  63. package/dist/tool-args.js +28 -0
  64. package/dist/tool-args.js.map +1 -1
  65. package/dist/transport.d.ts +34 -4
  66. package/dist/transport.d.ts.map +1 -1
  67. package/dist/transport.js +77 -16
  68. package/dist/transport.js.map +1 -1
  69. package/dist/types.d.ts +13 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js +0 -8
  72. package/dist/types.js.map +1 -1
  73. package/dist/watchdog.d.ts +2 -0
  74. package/dist/watchdog.d.ts.map +1 -1
  75. package/dist/watchdog.js +25 -6
  76. package/dist/watchdog.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/auth/flows.ts +5 -4
  79. package/src/auth/index.ts +1 -0
  80. package/src/auth/oauth.ts +23 -1
  81. package/src/auth/token-source.ts +30 -11
  82. package/src/auth/with-token.ts +97 -0
  83. package/src/context.ts +137 -2
  84. package/src/errors.ts +46 -6
  85. package/src/fallback.ts +173 -75
  86. package/src/key-pool.ts +30 -13
  87. package/src/presets.ts +12 -2
  88. package/src/providers/anthropic.ts +83 -24
  89. package/src/providers/gemini.ts +102 -28
  90. package/src/providers/openai.ts +61 -13
  91. package/src/providers/responses.ts +102 -50
  92. package/src/rate-limit.ts +31 -3
  93. package/src/retry.ts +41 -10
  94. package/src/tool-args.ts +44 -0
  95. package/src/transport.ts +97 -17
  96. package/src/types.ts +13 -0
  97. package/src/watchdog.ts +25 -6
@@ -1,8 +1,9 @@
1
1
  // Anthropic-shape adapter — SSE from POST /v1/messages.
2
+ import { repairHistory } from "../context.ts";
2
3
  import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
3
4
  import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
4
- import { parseToolArgs } from "../tool-args.ts";
5
- import { streamSse, apiUrl } from "../transport.ts";
5
+ import { parseToolArgs, settleToolCalls, type ToolCallIdentity } from "../tool-args.ts";
6
+ import { streamSse, apiUrl, parseFrame } from "../transport.ts";
6
7
  import { attributionHeaders } from "../attribution.ts";
7
8
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
8
9
  import type {
@@ -191,19 +192,32 @@ function continuesToolLoop(messages: readonly ChatMessage[]): boolean {
191
192
  return false;
192
193
  }
193
194
 
194
- function mapStopReason(reason: string | undefined): FinishReason | undefined {
195
+ /**
196
+ * A stated stop, or a throw for one this adapter does not know. An unknown
197
+ * reason used to end the stream as a clean success with no finish at all.
198
+ * Nothing says the answer ended on purpose, so it is `overload`: theirs, and
199
+ * worth a retry (cc-proxy answers it the same way). `pause_turn` lands there
200
+ * too: it is a server-tool turn waiting to be sent back, which this adapter
201
+ * never asks for, and a half answer is not a finished one.
202
+ */
203
+ function mapStopReason(provider: string, reason: string): FinishReason {
195
204
  switch (reason) {
196
205
  case "end_turn":
197
206
  case "stop_sequence":
198
207
  return "stop";
199
208
  case "tool_use":
200
209
  return "tool_calls";
210
+ // `model_context_window_exceeded`: the context window filled before the
211
+ // answer did. Cut short, and a retry fills it the same way.
201
212
  case "max_tokens":
213
+ case "model_context_window_exceeded":
202
214
  return "length";
203
215
  case "refusal":
204
216
  return "content_filter";
205
217
  default:
206
- return undefined;
218
+ throw new ProviderError(provider, "overload", `${provider} stopped the turn on "${reason}"`, {
219
+ code: reason,
220
+ });
207
221
  }
208
222
  }
209
223
 
@@ -270,6 +284,34 @@ function jsonBlock(json: JsonOutput): unknown {
270
284
  return { type: "text", text: schemaPrompt(json.schema) };
271
285
  }
272
286
 
287
+ /**
288
+ * Tool ids as this wire accepts them: `[A-Za-z0-9_-]` only. Kimi's
289
+ * `functions.search:0` is a 400 here, and an opencode-go rotation hands a
290
+ * history from chat completions to Messages mid-conversation, so a Kimi id
291
+ * does arrive. Each other character becomes `_`, and a result follows its call
292
+ * because both go through the same map.
293
+ *
294
+ * Collision-safe in one forward pass: an id whose cleaned form is already
295
+ * taken gets `_2`, `_3`…, judged only against ids seen before it, so an earlier
296
+ * id never changes because of a later one and the prompt cache keeps its
297
+ * prefix. A history whose ids are already clean comes out byte for byte.
298
+ */
299
+ function anthropicIds(): (id: string) => string {
300
+ const sent = new Map<string, string>();
301
+ const taken = new Set<string>();
302
+ return (id) => {
303
+ let wire = sent.get(id);
304
+ if (wire === undefined) {
305
+ const clean = id.replace(/[^A-Za-z0-9_-]/g, "_");
306
+ wire = clean;
307
+ for (let k = 2; taken.has(wire); k++) wire = `${clean}_${k}`;
308
+ sent.set(id, wire);
309
+ taken.add(wire);
310
+ }
311
+ return wire;
312
+ };
313
+ }
314
+
273
315
  export function toAnthropicMessages(
274
316
  messages: readonly ChatMessage[],
275
317
  provider = "anthropic",
@@ -277,6 +319,7 @@ export function toAnthropicMessages(
277
319
  system?: unknown[];
278
320
  messages: unknown[];
279
321
  } {
322
+ const wireId = anthropicIds();
280
323
  const system = messages
281
324
  .filter((m) => m.role === "system")
282
325
  .map((m) => m.content)
@@ -299,7 +342,7 @@ export function toAnthropicMessages(
299
342
  pushBlocks("user", [
300
343
  {
301
344
  type: "tool_result",
302
- tool_use_id: message.toolCallId,
345
+ tool_use_id: wireId(message.toolCallId),
303
346
  content: [
304
347
  { type: "text", text: message.content },
305
348
  ...(message.images ?? []).map((image) => ({
@@ -319,7 +362,7 @@ export function toAnthropicMessages(
319
362
  for (const call of message.toolCalls ?? []) {
320
363
  blocks.push({
321
364
  type: "tool_use",
322
- id: call.id,
365
+ id: wireId(call.id),
323
366
  name: call.name,
324
367
  input: parseToolArgs(call.arguments),
325
368
  });
@@ -373,7 +416,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
373
416
  const model = opts.model ?? config.model;
374
417
  const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
375
418
  const effort = opts.effort ?? config.effort;
376
- const { system, messages: body } = toAnthropicMessages(messages, id);
419
+ // Repaired first: a result with no call, a call with no result, or an id
420
+ // used twice is a 400 that loses the turn. See repairHistory.
421
+ const sent = repairHistory(messages).messages;
422
+ const { system, messages: body } = toAnthropicMessages(sent, id);
377
423
 
378
424
  // Anthropic's own `service_tier` means auto or standard_only, not this.
379
425
  if (opts.serviceTier) {
@@ -438,7 +484,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
438
484
  // ponytail: every loop step after the first goes unthought on these
439
485
  // models. The upgrade is replaying the signed thinking blocks.
440
486
  const unthought =
441
- !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(messages));
487
+ !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(sent));
442
488
  if (claude) {
443
489
  // An effort the caller never set sends nothing: the model keeps its own
444
490
  // default, which on most of these is thinking ON.
@@ -476,12 +522,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
476
522
  let cachedInputTokens = 0;
477
523
  let cacheWriteTokens = 0;
478
524
  let outputTokens = 0;
479
- let toolCall: { index: number; id: string; name: string } | null = null;
525
+ let toolCall: ToolCallIdentity | null = null;
526
+ let sawToolCall = false;
480
527
  let blockIndex = -1;
481
528
  // What proves the turn ended rather than the socket: a stated stop
482
529
  // reason, or `message_stop`. See the check after the loop.
483
530
  let stopped = false;
484
- let closed = false;
485
531
  const usageChunk = (): ProviderChunk => ({
486
532
  type: "usage",
487
533
  usage: {
@@ -510,13 +556,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
510
556
  ...(opts.signal ? { signal: opts.signal } : {}),
511
557
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
512
558
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
559
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
560
+ // The stop reason rides in `message_delta` with the final usage, so a
561
+ // read that dies after it loses nothing but `message_stop`.
562
+ finished: () => stopped,
513
563
  })) {
514
- let event: AnthropicEvent;
515
- try {
516
- event = JSON.parse(data) as AnthropicEvent;
517
- } catch {
518
- continue; // a keep-alive or a frame we do not model
519
- }
564
+ const event = parseFrame<AnthropicEvent>(data, id);
520
565
 
521
566
  switch (event.type) {
522
567
  // A failure the backend reports after its headers went out. Classified
@@ -542,6 +587,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
542
587
  id: event.content_block.id ?? "",
543
588
  name: event.content_block.name ?? "",
544
589
  };
590
+ // Checked here, because this block is the only place this shape
591
+ // states a call's id and name.
592
+ settleToolCalls(id, [toolCall]);
593
+ sawToolCall = true;
545
594
  yield {
546
595
  type: "delta",
547
596
  toolCalls: [{ index: toolCall.index, id: toolCall.id, name: toolCall.name }],
@@ -577,21 +626,31 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
577
626
  cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
578
627
  cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
579
628
  outputTokens = event.usage?.output_tokens ?? outputTokens;
580
- if (event.delta?.stop_reason) stopped = true;
581
- const finishReason = mapStopReason(event.delta?.stop_reason);
582
- if (finishReason) yield { type: "finish", finishReason };
629
+ if (event.delta?.stop_reason) {
630
+ const stated = mapStopReason(id, event.delta.stop_reason);
631
+ stopped = true;
632
+ // Anthropic-compatible gateways can end a tool turn on
633
+ // `end_turn`, and a caller reading that as done never runs the
634
+ // tool. Only `stop` is promoted: a length cut may be what broke
635
+ // the call.
636
+ const finishReason = stated === "stop" && sawToolCall ? "tool_calls" : stated;
637
+ yield { type: "finish", finishReason };
638
+ }
583
639
  break;
584
640
  }
585
641
 
642
+ // The turn's last event, so the reader stops here. Some gateways
643
+ // append a `[DONE]` or other bytes after it, and a read that died on
644
+ // those failed an answer that was already whole (Kimi, GLM and
645
+ // OpenCode Go, measured by cc-proxy).
586
646
  case "message_stop":
587
- closed = true;
588
647
  yield usageChunk();
589
- break;
648
+ return;
590
649
  }
591
650
  }
592
- if (closed) return;
593
- // A gateway that states its stop reason and then skips `message_stop`
594
- // still finished the turn — and still billed it.
651
+ // A gateway that states its stop reason and then skips `message_stop`, or
652
+ // whose socket dies after the reason, still finished the turn, and still
653
+ // billed it.
595
654
  if (stopped) {
596
655
  yield usageChunk();
597
656
  return;
@@ -5,9 +5,10 @@
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 { ProviderError, streamCut, streamError } from "../errors.ts";
8
+ import { repairHistory } from "../context.ts";
9
+ import { ProviderError, streamCut, streamError, type ErrorKind } from "../errors.ts";
9
10
  import { schemaPrompt, toGeminiToolSchema } from "../schema.ts";
10
- import { parseToolArgs } from "../tool-args.ts";
11
+ import { parseToolArgs, settleToolCalls } from "../tool-args.ts";
11
12
  import { streamSse, apiUrl } from "../transport.ts";
12
13
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
13
14
  import type {
@@ -133,6 +134,12 @@ function toResponseObject(content: string): Record<string, unknown> {
133
134
  * and carry tool calls as `functionCall` parts with the thought signature
134
135
  * beside them; tool results are role `user` with a `functionResponse` part
135
136
  * naming the FUNCTION, since Gemini pairs a result to its call by name.
137
+ *
138
+ * A run of results is ONE user turn: Gemini answers a model turn's parallel
139
+ * calls with every response together, and spread over several turns it 400s
140
+ * that the response count does not match the call count. The responses go
141
+ * first, in call order, and the run's images follow them all, the way the
142
+ * OpenAI dialect places them.
136
143
  */
137
144
  export function toGeminiContents(messages: readonly ChatMessage[]): {
138
145
  system?: string;
@@ -140,8 +147,11 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
140
147
  } {
141
148
  let system = "";
142
149
  const contents: GeminiContent[] = [];
150
+ // The open run of tool results: its parts, and how many lead with a response.
151
+ let results: { parts: unknown[]; responses: number } | undefined;
143
152
 
144
153
  for (const message of messages) {
154
+ if (message.role === "user" || message.role === "assistant") results = undefined;
145
155
  switch (message.role) {
146
156
  case "system":
147
157
  system = system ? `${system}\n\n${message.content}` : message.content;
@@ -181,23 +191,24 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
181
191
  break;
182
192
  }
183
193
 
184
- case "tool":
185
- contents.push({
186
- role: "user",
187
- parts: [
188
- {
189
- functionResponse: {
190
- id: message.toolCallId,
191
- name: message.name,
192
- response: toResponseObject(message.content),
193
- },
194
- },
195
- ...(message.images ?? []).map((image) => ({
196
- inlineData: { mimeType: image.mimeType, data: image.data },
197
- })),
198
- ],
194
+ case "tool": {
195
+ if (!results) {
196
+ results = { parts: [], responses: 0 };
197
+ contents.push({ role: "user", parts: results.parts });
198
+ }
199
+ results.parts.splice(results.responses, 0, {
200
+ functionResponse: {
201
+ id: message.toolCallId,
202
+ name: message.name,
203
+ response: toResponseObject(message.content),
204
+ },
199
205
  });
206
+ results.responses += 1;
207
+ for (const image of message.images ?? []) {
208
+ results.parts.push({ inlineData: { mimeType: image.mimeType, data: image.data } });
209
+ }
200
210
  break;
211
+ }
201
212
  }
202
213
  }
203
214
 
@@ -216,15 +227,66 @@ function toToolConfig(choice: ToolChoice | undefined): unknown {
216
227
  return { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [choice.name] } };
217
228
  }
218
229
 
219
- function mapFinishReason(reason: string): FinishReason {
230
+ /**
231
+ * Every finish reason Gemini documents (the generate-content reference, read
232
+ * 2026-10-19), decided one by one. Anything this switch did not name used to
233
+ * read as `stop`, so a call the model botched (`MALFORMED_FUNCTION_CALL`) or a
234
+ * turn that ended for no stated reason (`OTHER`) came back as a finished, empty
235
+ * answer. After a tool result, `requireContent` takes an empty `stop` as the
236
+ * model choosing to say nothing, so nothing retried it.
237
+ */
238
+ function mapFinishReason(provider: string, reason: string): FinishReason {
239
+ const fail = (kind: ErrorKind, why: string): never => {
240
+ throw new ProviderError(provider, kind, `${provider} ended the turn on ${reason}: ${why}`, {
241
+ code: reason,
242
+ });
243
+ };
220
244
  switch (reason) {
245
+ case "STOP":
246
+ return "stop";
247
+ // The output cap. CONTINUATION is the same cap with a token to carry on,
248
+ // which this adapter never sends back.
221
249
  case "MAX_TOKENS":
250
+ case "CONTINUATION":
222
251
  return "length";
252
+ // A filter on what was said or drawn. The text so far stands and the
253
+ // finish says why it ended, as `content_filter` does on every wire. A
254
+ // retry would mostly meet the same filter.
223
255
  case "SAFETY":
224
256
  case "PROHIBITED_CONTENT":
257
+ case "BLOCKLIST":
258
+ case "SPII":
259
+ case "RECITATION":
260
+ case "LANGUAGE":
261
+ case "ESCALATION":
262
+ case "IMAGE_SAFETY":
263
+ case "IMAGE_PROHIBITED_CONTENT":
264
+ case "IMAGE_RECITATION":
225
265
  return "content_filter";
266
+ // The request lost a thought signature, and the same request loses it
267
+ // again. A bug in what was sent, so nothing retries it.
268
+ case "MISSING_THOUGHT_SIGNATURE":
269
+ return fail("invalid", "the request is missing a thought signature");
270
+ // The account was limited for a policy violation. Benched like a refused
271
+ // key, so a key pool moves to another project.
272
+ case "PUP_LIMITED_DISABLED":
273
+ return fail("auth", "the account is limited or disabled");
274
+ // The model broke its own turn: a call it could not form, one with no tool
275
+ // offered, too many in a row, a malformed reply, an image it never made.
276
+ // The same request usually works on the next try.
277
+ case "MALFORMED_FUNCTION_CALL":
278
+ case "UNEXPECTED_TOOL_CALL":
279
+ case "TOO_MANY_TOOL_CALLS":
280
+ case "MALFORMED_RESPONSE":
281
+ case "NO_IMAGE":
282
+ return fail("overload", "the model broke its own turn");
283
+ // These say nothing about why, and a reason added after this list says
284
+ // nothing we can read. Neither proves the answer is whole.
285
+ case "OTHER":
286
+ case "IMAGE_OTHER":
287
+ case "FINISH_REASON_UNSPECIFIED":
226
288
  default:
227
- return "stop";
289
+ return fail("overload", "it gave no reason that shows the answer is complete");
228
290
  }
229
291
  }
230
292
 
@@ -277,7 +339,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
277
339
  ): AsyncIterable<ProviderChunk> {
278
340
  const effort = opts.effort ?? config.effort;
279
341
  const maxTokens = opts.maxTokens ?? config.maxTokens;
280
- const { system, contents } = toGeminiContents(messages);
342
+ // Repaired first: a result with no call, or a call with no result, is a
343
+ // 400 that loses the turn. See repairHistory.
344
+ const { system, contents } = toGeminiContents(repairHistory(messages).messages);
281
345
 
282
346
  // A model id copied out of Gemini's docs is often already `models/…`, and
283
347
  // the doubled segment 404s as "model not found" — a confusing way to
@@ -385,6 +449,11 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
385
449
  ...(opts.signal ? { signal: opts.signal } : {}),
386
450
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
387
451
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
452
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
453
+ // This wire has no terminal event to stop at, so the finish reason is
454
+ // the end: a read that dies after it can cost a late usage record, never
455
+ // the answer.
456
+ finished: () => finished,
388
457
  })) {
389
458
  let chunk: GeminiResponse;
390
459
  try {
@@ -442,12 +511,14 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
442
511
  const call = part.functionCall;
443
512
  if (!call) continue;
444
513
  const index = callIndex++;
514
+ const identity = { index, id: call.id ?? "", name: call.name ?? "" };
515
+ // The part is the whole call, so it is settled here: one with no
516
+ // name can't run, and one with no id gets one the way the other
517
+ // three wires mint it. Gemini omits the id on a single-call turn, and
518
+ // the seam's consumers pair a result back to its call by id.
519
+ settleToolCalls(id, [identity]);
445
520
  toolCalls.push({
446
- index,
447
- // Gemini omits the id on a single-call turn, and the seam's
448
- // consumers pair a result back to its call by id.
449
- id: call.id ?? `call_${index}`,
450
- ...(call.name ? { name: call.name } : {}),
521
+ ...identity,
451
522
  // Whole and already assembled — unlike the OpenAI shape, Gemini
452
523
  // never fragments an argument object across chunks.
453
524
  arguments: JSON.stringify(call.args ?? {}),
@@ -463,13 +534,16 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
463
534
  }
464
535
 
465
536
  if (candidate.finishReason) {
537
+ // Mapped first: a reason that throws must not be dressed as tool_calls.
538
+ const finishReason = mapFinishReason(id, candidate.finishReason);
466
539
  finished = true;
467
540
  yield {
468
541
  type: "finish",
469
- // A turn that called tools finishes as tool_calls whatever the
470
- // candidate says — Gemini routinely reports STOP there, and a
542
+ // Gemini routinely reports STOP on a turn that called tools, and a
471
543
  // caller reading that as "done" drops the tool round entirely.
472
- finishReason: sawFunctionCalls ? "tool_calls" : mapFinishReason(candidate.finishReason),
544
+ // Only STOP is promoted, as on the other three wires: a length cut
545
+ // or a filter may be what broke the call (invariant 10).
546
+ finishReason: sawFunctionCalls && finishReason === "stop" ? "tool_calls" : finishReason,
473
547
  };
474
548
  }
475
549
  }
@@ -3,8 +3,10 @@
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 { repairHistory } from "../context.ts";
6
7
  import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
7
- import { streamSse, apiUrl } from "../transport.ts";
8
+ import { streamSse, apiUrl, parseFrame } from "../transport.ts";
9
+ import { settleToolCalls, type ToolCallIdentity } from "../tool-args.ts";
8
10
  import { attributionHeaders } from "../attribution.ts";
9
11
  import type {
10
12
  ChatMessage,
@@ -328,19 +330,43 @@ function dialectFor(id: string): EffortDialect {
328
330
  return "openai";
329
331
  }
330
332
 
331
- function mapFinishReason(reason: string | null | undefined): FinishReason | undefined {
333
+ /**
334
+ * A stated finish, or a throw for one this dialect does not document. An
335
+ * unknown reason used to end the stream as a clean success with no finish at
336
+ * all, so DeepSeek's `insufficient_system_resource`, Z.ai's `network_error` and
337
+ * OpenRouter's `error` each read as a finished answer. Nothing says the answer
338
+ * ended on purpose, so it is `overload`: theirs, and worth a retry (cc-proxy
339
+ * answers it the same way).
340
+ */
341
+ function mapFinishReason(provider: string, reason: string): FinishReason {
332
342
  switch (reason) {
343
+ // A natural end under another name, from servers on this wire: Together's
344
+ // `eos`, Hugging Face TGI's `eos_token` and `stop_sequence`. Thrown as
345
+ // unknown, each would fail an answer that was already whole.
333
346
  case "stop":
347
+ case "eos":
348
+ case "eos_token":
349
+ case "stop_sequence":
334
350
  return "stop";
351
+ // Z.ai's `model_context_window_exceeded` and Mistral's `model_length`: the
352
+ // context window filled before the answer did. Cut short, and a retry fills
353
+ // it the same way.
335
354
  case "length":
355
+ case "model_context_window_exceeded":
356
+ case "model_length":
336
357
  return "length";
337
358
  case "tool_calls":
338
359
  case "function_call":
339
360
  return "tool_calls";
361
+ // `sensitive` is Z.ai's name for its safety stop. A retry meets the same
362
+ // filter.
340
363
  case "content_filter":
364
+ case "sensitive":
341
365
  return "content_filter";
342
366
  default:
343
- return undefined;
367
+ throw new ProviderError(provider, "overload", `${provider} ended the turn on "${reason}"`, {
368
+ code: reason,
369
+ });
344
370
  }
345
371
  }
346
372
 
@@ -519,7 +545,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
519
545
  const effort = opts.effort ?? config.effort;
520
546
  const model = opts.model ?? config.model;
521
547
 
522
- const body = toOpenAIMessages(messages, id);
548
+ // Repaired first: a result with no call, or a call with no result, is a
549
+ // 400 that loses the turn. See repairHistory.
550
+ const body = toOpenAIMessages(repairHistory(messages).messages, id);
523
551
  const request: Record<string, unknown> = {
524
552
  model,
525
553
  messages: body,
@@ -624,6 +652,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
624
652
  : "/v1/chat/completions";
625
653
 
626
654
  let ended = false;
655
+ // Each call as its deltas named it, by index. The finish checks them
656
+ // before it goes out, and they are what says the turn called a tool.
657
+ const calls = new Map<number, ToolCallIdentity>();
627
658
  for await (const data of streamSse({
628
659
  url: apiUrl(baseUrl, config.path ?? defaultPath),
629
660
  headers: {
@@ -642,16 +673,16 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
642
673
  ...(opts.signal ? { signal: opts.signal } : {}),
643
674
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
644
675
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
676
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
645
677
  onDone: () => {
646
678
  ended = true;
647
679
  },
680
+ // Usage comes AFTER the finish on this shape, so the reader keeps going
681
+ // to `[DONE]` (invariant 5). But a read that dies in between costs only
682
+ // the usage frame: the answer is whole once its finish is stated.
683
+ finished: () => ended,
648
684
  })) {
649
- let chunk: OpenAIChunk;
650
- try {
651
- chunk = JSON.parse(data) as OpenAIChunk;
652
- } catch {
653
- continue;
654
- }
685
+ const chunk = parseFrame<OpenAIChunk>(data, id);
655
686
 
656
687
  // A failure the backend reports after its headers went out. The
657
688
  // gateways speaking this dialect — OpenRouter above all — report a
@@ -723,18 +754,35 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
723
754
  ? { arguments: call.function.arguments }
724
755
  : {}),
725
756
  }));
757
+ for (const call of out.toolCalls) {
758
+ const seen = calls.get(call.index) ?? { index: call.index, id: "", name: "" };
759
+ if (call.id) seen.id = call.id;
760
+ if (call.name) seen.name = call.name;
761
+ calls.set(call.index, seen);
762
+ }
726
763
  has = true;
727
764
  }
728
765
  if (has) yield out;
729
766
  }
730
767
 
731
- if (choice.finish_reason) ended = true;
732
- const finishReason = mapFinishReason(choice.finish_reason);
733
- if (finishReason) yield { type: "finish", finishReason };
768
+ if (choice.finish_reason) {
769
+ const stated = mapFinishReason(id, choice.finish_reason);
770
+ const minted = settleToolCalls(id, calls.values());
771
+ if (minted.length > 0) yield { type: "delta", toolCalls: minted };
772
+ ended = true;
773
+ // Several gateways end a turn that called a tool on `stop`, and a
774
+ // caller reading that as done never runs the tool. Only `stop` is
775
+ // promoted: a length cut or a filter may be what broke the call.
776
+ const finishReason = stated === "stop" && calls.size > 0 ? "tool_calls" : stated;
777
+ yield { type: "finish", finishReason };
778
+ }
734
779
  }
735
780
  // Either proof will do: some gateways send `[DONE]` with no finish, and
736
781
  // some a finish with no `[DONE]`. Neither means the socket closed early.
737
782
  if (!ended) throw streamCut(id);
783
+ // A `[DONE]` with no finish stated still owes its calls the same check.
784
+ const minted = settleToolCalls(id, calls.values());
785
+ if (minted.length > 0) yield { type: "delta", toolCalls: minted };
738
786
  },
739
787
  };
740
788