@providerkit/core 0.22.0 → 0.24.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 (131) hide show
  1. package/README.md +29 -7
  2. package/dist/auth/flows.d.ts.map +1 -1
  3. package/dist/auth/flows.js +5 -5
  4. package/dist/auth/flows.js.map +1 -1
  5. package/dist/auth/index.d.ts +1 -0
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/index.js +1 -0
  8. package/dist/auth/index.js.map +1 -1
  9. package/dist/auth/oauth.d.ts +12 -0
  10. package/dist/auth/oauth.d.ts.map +1 -1
  11. package/dist/auth/oauth.js +19 -1
  12. package/dist/auth/oauth.js.map +1 -1
  13. package/dist/auth/token-source.d.ts +9 -3
  14. package/dist/auth/token-source.d.ts.map +1 -1
  15. package/dist/auth/token-source.js +24 -9
  16. package/dist/auth/token-source.js.map +1 -1
  17. package/dist/auth/with-token.d.ts +32 -0
  18. package/dist/auth/with-token.d.ts.map +1 -0
  19. package/dist/auth/with-token.js +88 -0
  20. package/dist/auth/with-token.js.map +1 -0
  21. package/dist/context.d.ts +39 -1
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/context.js +119 -0
  24. package/dist/context.js.map +1 -1
  25. package/dist/errors.d.ts +12 -3
  26. package/dist/errors.d.ts.map +1 -1
  27. package/dist/errors.js +44 -5
  28. package/dist/errors.js.map +1 -1
  29. package/dist/fallback.d.ts +45 -1
  30. package/dist/fallback.d.ts.map +1 -1
  31. package/dist/fallback.js +138 -75
  32. package/dist/fallback.js.map +1 -1
  33. package/dist/gate.d.ts +29 -0
  34. package/dist/gate.d.ts.map +1 -0
  35. package/dist/gate.js +96 -0
  36. package/dist/gate.js.map +1 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/key-pool.d.ts +21 -4
  42. package/dist/key-pool.d.ts.map +1 -1
  43. package/dist/key-pool.js +43 -12
  44. package/dist/key-pool.js.map +1 -1
  45. package/dist/presets.d.ts +7 -2
  46. package/dist/presets.d.ts.map +1 -1
  47. package/dist/presets.js +12 -2
  48. package/dist/presets.js.map +1 -1
  49. package/dist/providers/anthropic.d.ts +7 -0
  50. package/dist/providers/anthropic.d.ts.map +1 -1
  51. package/dist/providers/anthropic.js +83 -26
  52. package/dist/providers/anthropic.js.map +1 -1
  53. package/dist/providers/factory.d.ts +26 -0
  54. package/dist/providers/factory.d.ts.map +1 -1
  55. package/dist/providers/factory.js +41 -9
  56. package/dist/providers/factory.js.map +1 -1
  57. package/dist/providers/gemini.d.ts +6 -0
  58. package/dist/providers/gemini.d.ts.map +1 -1
  59. package/dist/providers/gemini.js +102 -27
  60. package/dist/providers/gemini.js.map +1 -1
  61. package/dist/providers/openai.d.ts +7 -0
  62. package/dist/providers/openai.d.ts.map +1 -1
  63. package/dist/providers/openai.js +66 -15
  64. package/dist/providers/openai.js.map +1 -1
  65. package/dist/providers/responses.d.ts +11 -0
  66. package/dist/providers/responses.d.ts.map +1 -1
  67. package/dist/providers/responses.js +101 -53
  68. package/dist/providers/responses.js.map +1 -1
  69. package/dist/rate-limit.d.ts +10 -0
  70. package/dist/rate-limit.d.ts.map +1 -1
  71. package/dist/rate-limit.js +7 -1
  72. package/dist/rate-limit.js.map +1 -1
  73. package/dist/registry.d.ts +105 -0
  74. package/dist/registry.d.ts.map +1 -0
  75. package/dist/registry.js +291 -0
  76. package/dist/registry.js.map +1 -0
  77. package/dist/retry.d.ts +11 -0
  78. package/dist/retry.d.ts.map +1 -1
  79. package/dist/retry.js +25 -12
  80. package/dist/retry.js.map +1 -1
  81. package/dist/tool-args.d.ts +22 -0
  82. package/dist/tool-args.d.ts.map +1 -1
  83. package/dist/tool-args.js +28 -0
  84. package/dist/tool-args.js.map +1 -1
  85. package/dist/tools.d.ts +8 -0
  86. package/dist/tools.d.ts.map +1 -1
  87. package/dist/tools.js +2 -2
  88. package/dist/tools.js.map +1 -1
  89. package/dist/transport.d.ts +34 -4
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +77 -16
  92. package/dist/transport.js.map +1 -1
  93. package/dist/turn.d.ts +55 -0
  94. package/dist/turn.d.ts.map +1 -0
  95. package/dist/turn.js +125 -0
  96. package/dist/turn.js.map +1 -0
  97. package/dist/types.d.ts +21 -22
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +0 -53
  100. package/dist/types.js.map +1 -1
  101. package/dist/watchdog.d.ts +2 -0
  102. package/dist/watchdog.d.ts.map +1 -1
  103. package/dist/watchdog.js +25 -6
  104. package/dist/watchdog.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/auth/flows.ts +5 -4
  107. package/src/auth/index.ts +1 -0
  108. package/src/auth/oauth.ts +23 -1
  109. package/src/auth/token-source.ts +30 -11
  110. package/src/auth/with-token.ts +100 -0
  111. package/src/context.ts +137 -2
  112. package/src/errors.ts +46 -6
  113. package/src/fallback.ts +173 -75
  114. package/src/gate.ts +152 -0
  115. package/src/index.ts +3 -0
  116. package/src/key-pool.ts +45 -13
  117. package/src/presets.ts +12 -2
  118. package/src/providers/anthropic.ts +93 -25
  119. package/src/providers/factory.ts +66 -21
  120. package/src/providers/gemini.ts +102 -28
  121. package/src/providers/openai.ts +71 -14
  122. package/src/providers/responses.ts +116 -51
  123. package/src/rate-limit.ts +31 -3
  124. package/src/registry.ts +420 -0
  125. package/src/retry.ts +41 -10
  126. package/src/tool-args.ts +44 -0
  127. package/src/tools.ts +10 -2
  128. package/src/transport.ts +97 -17
  129. package/src/turn.ts +166 -0
  130. package/src/types.ts +21 -62
  131. package/src/watchdog.ts +25 -6
@@ -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,
@@ -105,6 +107,13 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
105
107
  * no sessionId, the provider's own per-instance id rides instead, so the
106
108
  * gateway never sees a call without one. */
107
109
  sessionHeader?: string;
110
+ /** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
111
+ * adapter mints one when it is built. Pass one when something rebuilds the
112
+ * adapter (`withKeyPool` and `withToken` build one per attempt): each build
113
+ * mints a new id, and a gateway that pins a session to one upstream host
114
+ * then treats every call as a new session, re-routes it and misses its
115
+ * prompt cache. Ignored without `sessionHeader`. */
116
+ instanceSessionId?: string;
108
117
  /** Send `X-Initiator`: `user` when the last message is the user's, else
109
118
  * `agent`. GitHub Copilot bills a premium request per `user` turn and lets
110
119
  * the agent's own follow-ups (tool results) ride free. */
@@ -328,19 +337,43 @@ function dialectFor(id: string): EffortDialect {
328
337
  return "openai";
329
338
  }
330
339
 
331
- function mapFinishReason(reason: string | null | undefined): FinishReason | undefined {
340
+ /**
341
+ * A stated finish, or a throw for one this dialect does not document. An
342
+ * unknown reason used to end the stream as a clean success with no finish at
343
+ * all, so DeepSeek's `insufficient_system_resource`, Z.ai's `network_error` and
344
+ * OpenRouter's `error` each read as a finished answer. Nothing says the answer
345
+ * ended on purpose, so it is `overload`: theirs, and worth a retry (cc-proxy
346
+ * answers it the same way).
347
+ */
348
+ function mapFinishReason(provider: string, reason: string): FinishReason {
332
349
  switch (reason) {
350
+ // A natural end under another name, from servers on this wire: Together's
351
+ // `eos`, Hugging Face TGI's `eos_token` and `stop_sequence`. Thrown as
352
+ // unknown, each would fail an answer that was already whole.
333
353
  case "stop":
354
+ case "eos":
355
+ case "eos_token":
356
+ case "stop_sequence":
334
357
  return "stop";
358
+ // Z.ai's `model_context_window_exceeded` and Mistral's `model_length`: the
359
+ // context window filled before the answer did. Cut short, and a retry fills
360
+ // it the same way.
335
361
  case "length":
362
+ case "model_context_window_exceeded":
363
+ case "model_length":
336
364
  return "length";
337
365
  case "tool_calls":
338
366
  case "function_call":
339
367
  return "tool_calls";
368
+ // `sensitive` is Z.ai's name for its safety stop. A retry meets the same
369
+ // filter.
340
370
  case "content_filter":
371
+ case "sensitive":
341
372
  return "content_filter";
342
373
  default:
343
- return undefined;
374
+ throw new ProviderError(provider, "overload", `${provider} ended the turn on "${reason}"`, {
375
+ code: reason,
376
+ });
344
377
  }
345
378
  }
346
379
 
@@ -505,7 +538,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
505
538
  // `usage.cost` is its field in its unit. Another gateway that sends a
506
539
  // `cost` has not said what it means.
507
540
  const reportsCost = isOpenRouter(baseUrl);
508
- const instanceSessionId = config.sessionHeader ? crypto.randomUUID() : undefined;
541
+ const instanceSessionId = config.sessionHeader
542
+ ? (config.instanceSessionId ?? crypto.randomUUID())
543
+ : undefined;
509
544
 
510
545
  const provider: Provider = {
511
546
  id,
@@ -519,7 +554,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
519
554
  const effort = opts.effort ?? config.effort;
520
555
  const model = opts.model ?? config.model;
521
556
 
522
- const body = toOpenAIMessages(messages, id);
557
+ // Repaired first: a result with no call, or a call with no result, is a
558
+ // 400 that loses the turn. See repairHistory.
559
+ const body = toOpenAIMessages(repairHistory(messages).messages, id);
523
560
  const request: Record<string, unknown> = {
524
561
  model,
525
562
  messages: body,
@@ -624,6 +661,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
624
661
  : "/v1/chat/completions";
625
662
 
626
663
  let ended = false;
664
+ // Each call as its deltas named it, by index. The finish checks them
665
+ // before it goes out, and they are what says the turn called a tool.
666
+ const calls = new Map<number, ToolCallIdentity>();
627
667
  for await (const data of streamSse({
628
668
  url: apiUrl(baseUrl, config.path ?? defaultPath),
629
669
  headers: {
@@ -642,16 +682,16 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
642
682
  ...(opts.signal ? { signal: opts.signal } : {}),
643
683
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
644
684
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
685
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
645
686
  onDone: () => {
646
687
  ended = true;
647
688
  },
689
+ // Usage comes AFTER the finish on this shape, so the reader keeps going
690
+ // to `[DONE]` (invariant 5). But a read that dies in between costs only
691
+ // the usage frame: the answer is whole once its finish is stated.
692
+ finished: () => ended,
648
693
  })) {
649
- let chunk: OpenAIChunk;
650
- try {
651
- chunk = JSON.parse(data) as OpenAIChunk;
652
- } catch {
653
- continue;
654
- }
694
+ const chunk = parseFrame<OpenAIChunk>(data, id);
655
695
 
656
696
  // A failure the backend reports after its headers went out. The
657
697
  // gateways speaking this dialect — OpenRouter above all — report a
@@ -723,18 +763,35 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
723
763
  ? { arguments: call.function.arguments }
724
764
  : {}),
725
765
  }));
766
+ for (const call of out.toolCalls) {
767
+ const seen = calls.get(call.index) ?? { index: call.index, id: "", name: "" };
768
+ if (call.id) seen.id = call.id;
769
+ if (call.name) seen.name = call.name;
770
+ calls.set(call.index, seen);
771
+ }
726
772
  has = true;
727
773
  }
728
774
  if (has) yield out;
729
775
  }
730
776
 
731
- if (choice.finish_reason) ended = true;
732
- const finishReason = mapFinishReason(choice.finish_reason);
733
- if (finishReason) yield { type: "finish", finishReason };
777
+ if (choice.finish_reason) {
778
+ const stated = mapFinishReason(id, choice.finish_reason);
779
+ const minted = settleToolCalls(id, calls.values());
780
+ if (minted.length > 0) yield { type: "delta", toolCalls: minted };
781
+ ended = true;
782
+ // Several gateways end a turn that called a tool on `stop`, and a
783
+ // caller reading that as done never runs the tool. Only `stop` is
784
+ // promoted: a length cut or a filter may be what broke the call.
785
+ const finishReason = stated === "stop" && calls.size > 0 ? "tool_calls" : stated;
786
+ yield { type: "finish", finishReason };
787
+ }
734
788
  }
735
789
  // Either proof will do: some gateways send `[DONE]` with no finish, and
736
790
  // some a finish with no `[DONE]`. Neither means the socket closed early.
737
791
  if (!ended) throw streamCut(id);
792
+ // A `[DONE]` with no finish stated still owes its calls the same check.
793
+ const minted = settleToolCalls(id, calls.values());
794
+ if (minted.length > 0) yield { type: "delta", toolCalls: minted };
738
795
  },
739
796
  };
740
797
 
@@ -10,8 +10,11 @@
10
10
  // The event names arrive on the SSE `event:` line and are repeated inside each
11
11
  // payload's own `type`. The transport yields only `data:` payloads, so this
12
12
  // adapter reads `type` — which is what survives, and what gateways agree on.
13
+ import { repairHistory } from "../context.ts";
13
14
  import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
14
- import { streamSse, apiUrl } from "../transport.ts";
15
+ import { streamSse, apiUrl, parseFrame } from "../transport.ts";
16
+ import { parseRateLimitReset } from "../rate-limit.ts";
17
+ import { settleToolCalls } from "../tool-args.ts";
15
18
  import type {
16
19
  ChatMessage,
17
20
  ContentPart,
@@ -45,6 +48,17 @@ export interface ResponsesConfig extends ProviderFallbackConfig {
45
48
  /** Header that carries `StreamOptions.sessionId`. When set and a call has no
46
49
  * sessionId, one stable id for this provider instance rides instead. */
47
50
  sessionHeader?: string;
51
+ /** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
52
+ * adapter mints one when it is built. Pass one when something rebuilds the
53
+ * adapter (`withKeyPool` and `withToken` build one per attempt): each build
54
+ * mints a new id, and a gateway that pins a session to one upstream host
55
+ * then treats every call as a new session, re-routes it and misses its
56
+ * prompt cache. Ignored without `sessionHeader`.
57
+ *
58
+ * It is not sent as `prompt_cache_key`, which stays the per-call
59
+ * `sessionId`'s: this id names the adapter, so it is shared by every
60
+ * conversation the adapter serves, and a minted one never was a cache key. */
61
+ instanceSessionId?: string;
48
62
  /**
49
63
  * Where this backend serves the endpoint, when it is not `/v1/responses`.
50
64
  * The ChatGPT subscription surface serves it at `/backend-api/codex/responses`
@@ -82,13 +96,26 @@ function reasoningEffort(model: string, effort: Effort): string {
82
96
  }
83
97
 
84
98
  /**
85
- * `response.incomplete` means the turn was cut short, and the seam has one word
86
- * for that: "length". `content_filter` is the only other reason this shape
87
- * documents; anything new stays on "length" rather than reporting a clean stop,
88
- * because a caller that believes a truncated answer finished will act on it.
99
+ * The finish an `incomplete` turn stands for. This shape documents two
100
+ * reasons, and each has its own finish: `max_output_tokens` is the output cap
101
+ * (`length`) and `content_filter` the filter.
102
+ *
103
+ * Any other reason, or none, says only that the turn did not end on purpose.
104
+ * It used to read as `length`, so an empty turn was told to raise a maxTokens
105
+ * it never hit. It is thrown as `overload` instead, the transient fault
106
+ * cc-proxy answers it with (a 503). Nothing about the request is wrong, so it
107
+ * is retried if nothing was emitted yet; if something was, the error reaches
108
+ * the caller after it (invariant 1). It is read before reasoning and usage go
109
+ * out, like a failed terminal, so that retry stays open.
89
110
  */
90
- function mapIncompleteReason(reason: string | undefined): FinishReason {
91
- return reason === "content_filter" ? "content_filter" : "length";
111
+ function incompleteFinish(provider: string, reason: string | undefined): FinishReason {
112
+ if (reason === "max_output_tokens") return "length";
113
+ if (reason === "content_filter") return "content_filter";
114
+ throw new ProviderError(
115
+ provider,
116
+ "overload",
117
+ `${provider}: the turn stopped early, reason: ${reason ?? "none given"}`,
118
+ );
92
119
  }
93
120
 
94
121
  // ── input items ───────────────────────────────────────────────────────────
@@ -282,8 +309,11 @@ interface ResponsesEvent {
282
309
  headers?: Record<string, unknown>;
283
310
  }
284
311
 
312
+ /** One window of the snapshot: the `x-codex-<slot>-*` header numbers as JSON. */
285
313
  interface CodexWindow {
286
314
  used_percent?: number;
315
+ window_minutes?: number;
316
+ reset_at?: number;
287
317
  reset_after_seconds?: number;
288
318
  }
289
319
 
@@ -301,24 +331,42 @@ interface CodexRateLimits {
301
331
  * A full window the account's credits cover, or that the backend still
302
332
  * `allowed`, lets the turn proceed. It is only the reason when the stream then
303
333
  * closes without a response: that close is a spent window, not a dropped
304
- * socket, and retrying it walks every backup into the same wall. The fuller
305
- * window binds; cc-proxy took the primary's clock first and told a weekly wall
306
- * to come back in a few hours.
334
+ * socket, and retrying it walks every backup into the same wall.
335
+ *
336
+ * Which window, and when it lifts, comes from the same parser as the headers
337
+ * the snapshot mirrors: the fuller window binds, and the later reset when both
338
+ * are full. cc-proxy took the primary's clock first and told a weekly wall to
339
+ * come back in a few hours. The window is named so a caller can say "weekly"
340
+ * instead of "the limit", which reads as the 5-hour one.
307
341
  */
308
342
  function quotaWall(event: ResponsesEvent, provider: string): ProviderError | undefined {
309
343
  const limits = event.rate_limits;
310
344
  if (!limits?.limit_reached || limits.allowed) return undefined;
311
345
  if (event.credits?.has_credits || event.credits?.unlimited) return undefined;
312
- const windows = [limits.primary, limits.secondary].filter((w): w is CodexWindow => !!w);
313
- const binding = windows.sort((a, b) => (b.used_percent ?? 0) - (a.used_percent ?? 0))[0];
314
- const after = binding?.reset_after_seconds;
315
- return new ProviderError(provider, "quota", `${provider}: the usage limit has been reached`, {
316
- ...(after !== undefined && after >= 0
317
- ? { retryAfterMs: after * 1000, resetAtMs: Date.now() + after * 1000 }
318
- : {}),
346
+ const now = Date.now();
347
+ const { window, resetAtMs } = parseRateLimitReset(codexHeaders(limits), now);
348
+ const named = window ? ` (${window} window)` : "";
349
+ const message = `${provider}: the usage limit has been reached${named}`;
350
+ return new ProviderError(provider, "quota", message, {
351
+ ...(resetAtMs !== undefined ? { retryAfterMs: resetAtMs - now, resetAtMs } : {}),
352
+ window,
319
353
  });
320
354
  }
321
355
 
356
+ /** The snapshot as the `x-codex-*` headers it mirrors, rebuilt the way
357
+ * cc-proxy's `event_headers` does, so one parser reads both. */
358
+ function codexHeaders(limits: CodexRateLimits): Headers {
359
+ const headers = new Headers();
360
+ for (const slot of ["primary", "secondary"] as const) {
361
+ for (const [field, value] of Object.entries(limits[slot] ?? {})) {
362
+ if (typeof value === "number") {
363
+ headers.set(`x-codex-${slot}-${field.replaceAll("_", "-")}`, String(value));
364
+ }
365
+ }
366
+ }
367
+ return headers;
368
+ }
369
+
322
370
  function usageChunk(usage: ResponsesUsage): ProviderChunk {
323
371
  return {
324
372
  type: "usage",
@@ -358,7 +406,9 @@ function unsentTail(snapshot: string, streamed: string): string {
358
406
  export function createResponsesProvider(config: ResponsesConfig): Provider {
359
407
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
360
408
  const id = config.id ?? "openai-responses";
361
- const instanceSessionId = config.sessionHeader ? crypto.randomUUID() : undefined;
409
+ const instanceSessionId = config.sessionHeader
410
+ ? (config.instanceSessionId ?? crypto.randomUUID())
411
+ : undefined;
362
412
 
363
413
  const provider: Provider = {
364
414
  id,
@@ -373,8 +423,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
373
423
  const model = opts.model ?? config.model;
374
424
  const museContributor = isMuseSparkContributor(model);
375
425
  const requestTools = museContributor && opts.toolChoice === "none" ? [] : tools;
426
+ // Repaired before the images are gated: an orphaned result becomes a user
427
+ // note that keeps its images, and those must meet the limits too.
428
+ const sent = repairHistory(messages).messages;
376
429
  const { instructions, input } = toResponsesInput(
377
- config.imageLimits ? gateImages(messages, config.imageLimits) : messages,
430
+ config.imageLimits ? gateImages(sent, config.imageLimits) : sent,
378
431
  id,
379
432
  );
380
433
 
@@ -457,9 +510,10 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
457
510
  const pendingFor = (itemId?: string, callId?: string) =>
458
511
  (itemId ? pending.get(itemId) : undefined) ?? (callId ? pending.get(callId) : undefined);
459
512
  let nextIndex = 0;
460
- // This shape never states a stop reason on a clean finish, so it is
461
- // inferred from whether the turn produced a function call.
462
- let sawToolCall = false;
513
+ // Every call the turn opened, kept past its `.done`. This shape never
514
+ // states a stop reason on a clean finish, so it is inferred from whether
515
+ // there are any, and the terminal event checks each one can run.
516
+ const calls: PendingCall[] = [];
463
517
  let wall: ProviderError | undefined;
464
518
  let produced = false;
465
519
  const reasoningItems: unknown[] = [];
@@ -482,13 +536,9 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
482
536
  ...(opts.signal ? { signal: opts.signal } : {}),
483
537
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
484
538
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
539
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
485
540
  })) {
486
- let event: ResponsesEvent;
487
- try {
488
- event = JSON.parse(data) as ResponsesEvent;
489
- } catch {
490
- continue; // a keep-alive or a frame we do not model
491
- }
541
+ const event = parseFrame<ResponsesEvent>(data, id);
492
542
  if (event.type?.endsWith(".delta") || event.type === "response.output_item.added") {
493
543
  produced = true;
494
544
  }
@@ -515,7 +565,6 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
515
565
  // turn came back as a nameless call with empty arguments.
516
566
  const key = item.id ?? item.call_id;
517
567
  if (!key) break;
518
- sawToolCall = true;
519
568
  const index = nextIndex++;
520
569
  // Normally empty here, but a backend that already has the whole
521
570
  // call sends it in the skeleton.
@@ -526,6 +575,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
526
575
  name: item.name ?? "",
527
576
  streamed: seeded,
528
577
  };
578
+ calls.push(call);
529
579
  pending.set(key, call);
530
580
  if (item.call_id) pending.set(item.call_id, call);
531
581
  // Only the fields the skeleton actually states. An empty `id` here
@@ -582,20 +632,24 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
582
632
 
583
633
  if (!known) {
584
634
  // A backend that emits neither the skeleton nor the deltas — the
585
- // whole call arrives here or not at all. Without a `call_id`
586
- // there is nothing to answer it with: the caller's
587
- // `function_call_output` would quote `""` and take a 400 reading
588
- // "No tool output found for function call" one turn later, so the
589
- // call is dropped rather than handed over unrunnable. Dropping is
590
- // only possible here, where nothing has been streamed for it yet.
591
- if (!item.call_id) break;
592
- sawToolCall = true;
635
+ // whole call arrives here or not at all. A call with no `call_id`
636
+ // used to be dropped, because the caller's `function_call_output`
637
+ // would quote `""` and take a 400 one turn later. Dropped, the
638
+ // model's call silently never happened. It gets an id at the
639
+ // terminal event instead (`settleToolCalls`).
640
+ const call: PendingCall = {
641
+ index: nextIndex++,
642
+ id: item.call_id ?? "",
643
+ name: item.name ?? "",
644
+ streamed: snapshot,
645
+ };
646
+ calls.push(call);
593
647
  yield {
594
648
  type: "delta",
595
649
  toolCalls: [
596
650
  {
597
- index: nextIndex++,
598
- id: item.call_id,
651
+ index: call.index,
652
+ ...(item.call_id ? { id: item.call_id } : {}),
599
653
  ...(item.name ? { name: item.name } : {}),
600
654
  arguments: snapshot,
601
655
  },
@@ -617,6 +671,8 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
617
671
  ...(item.name && item.name !== known.name ? { name: item.name } : {}),
618
672
  ...(tail ? { arguments: tail } : {}),
619
673
  };
674
+ known.id = item.call_id || known.id;
675
+ known.name = item.name || known.name;
620
676
  if (Object.keys(restated).length > 0) {
621
677
  yield { type: "delta", toolCalls: [{ index: known.index, ...restated }] };
622
678
  }
@@ -635,16 +691,27 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
635
691
  if (response?.status === "failed" || response?.error) {
636
692
  throw streamError(id, response.error ?? { message: "response failed" });
637
693
  }
694
+ // Any other status (`cancelled`, `queued`, one added later) says
695
+ // nothing about the answer being whole. Read as a clean stop, it
696
+ // was a finished turn nobody retried. Theirs, so `overload`.
697
+ const status = response?.status;
698
+ if (status !== undefined && status !== "completed" && status !== "incomplete") {
699
+ throw new ProviderError(id, "overload", `${id} ended the turn as "${status}"`, {
700
+ code: status,
701
+ });
702
+ }
703
+ let finishReason: FinishReason = calls.length > 0 ? "tool_calls" : "stop";
704
+ if (status === "incomplete") {
705
+ finishReason = incompleteFinish(id, response?.incomplete_details?.reason);
706
+ } else {
707
+ // Checked on a whole turn only: a cut one may have lost a call's
708
+ // name to the cut, and nobody runs a cut turn's calls.
709
+ const minted = settleToolCalls(id, calls);
710
+ if (minted.length > 0) yield { type: "delta", toolCalls: minted };
711
+ }
638
712
  yield* reasoningChunk();
639
713
  if (response?.usage) yield usageChunk(response.usage);
640
- if (response?.status === "incomplete") {
641
- yield {
642
- type: "finish",
643
- finishReason: mapIncompleteReason(response.incomplete_details?.reason),
644
- };
645
- return;
646
- }
647
- yield { type: "finish", finishReason: sawToolCall ? "tool_calls" : "stop" };
714
+ yield { type: "finish", finishReason };
648
715
  return;
649
716
  }
650
717
 
@@ -655,13 +722,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
655
722
  case "response.incomplete": {
656
723
  // A turn that ran out of output tokens still billed for its input,
657
724
  // and this event carries usage in the same shape as `completed`.
725
+ const finishReason = incompleteFinish(id, event.response?.incomplete_details?.reason);
658
726
  yield* reasoningChunk();
659
727
  const usage = event.response?.usage;
660
728
  if (usage) yield usageChunk(usage);
661
- yield {
662
- type: "finish",
663
- finishReason: mapIncompleteReason(event.response?.incomplete_details?.reason),
664
- };
729
+ yield { type: "finish", finishReason };
665
730
  return;
666
731
  }
667
732