@providerkit/core 0.21.1 → 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 (137) hide show
  1. package/README.md +7 -7
  2. package/dist/attribution.d.ts +2 -2
  3. package/dist/auth/flows.d.ts.map +1 -1
  4. package/dist/auth/flows.js +5 -5
  5. package/dist/auth/flows.js.map +1 -1
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -0
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/auth/oauth.d.ts +12 -0
  11. package/dist/auth/oauth.d.ts.map +1 -1
  12. package/dist/auth/oauth.js +19 -1
  13. package/dist/auth/oauth.js.map +1 -1
  14. package/dist/auth/token-source.d.ts +9 -3
  15. package/dist/auth/token-source.d.ts.map +1 -1
  16. package/dist/auth/token-source.js +24 -9
  17. package/dist/auth/token-source.js.map +1 -1
  18. package/dist/auth/with-token.d.ts +29 -0
  19. package/dist/auth/with-token.d.ts.map +1 -0
  20. package/dist/auth/with-token.js +85 -0
  21. package/dist/auth/with-token.js.map +1 -0
  22. package/dist/capability.d.ts +9 -9
  23. package/dist/capability.js +9 -9
  24. package/dist/capability.js.map +1 -1
  25. package/dist/context.d.ts +43 -5
  26. package/dist/context.d.ts.map +1 -1
  27. package/dist/context.js +123 -4
  28. package/dist/context.js.map +1 -1
  29. package/dist/errors.d.ts +26 -17
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +57 -18
  32. package/dist/errors.js.map +1 -1
  33. package/dist/fallback.d.ts +45 -1
  34. package/dist/fallback.d.ts.map +1 -1
  35. package/dist/fallback.js +138 -75
  36. package/dist/fallback.js.map +1 -1
  37. package/dist/image.d.ts +1 -1
  38. package/dist/image.d.ts.map +1 -1
  39. package/dist/jev.d.ts +9 -9
  40. package/dist/jev.d.ts.map +1 -1
  41. package/dist/jev.js +1 -1
  42. package/dist/key-pool.d.ts +14 -8
  43. package/dist/key-pool.d.ts.map +1 -1
  44. package/dist/key-pool.js +35 -18
  45. package/dist/key-pool.js.map +1 -1
  46. package/dist/presets.d.ts +33 -24
  47. package/dist/presets.d.ts.map +1 -1
  48. package/dist/presets.js +25 -14
  49. package/dist/presets.js.map +1 -1
  50. package/dist/providers/anthropic.d.ts +5 -5
  51. package/dist/providers/anthropic.d.ts.map +1 -1
  52. package/dist/providers/anthropic.js +89 -34
  53. package/dist/providers/anthropic.js.map +1 -1
  54. package/dist/providers/factory.d.ts +2 -2
  55. package/dist/providers/factory.d.ts.map +1 -1
  56. package/dist/providers/factory.js +2 -1
  57. package/dist/providers/factory.js.map +1 -1
  58. package/dist/providers/gemini.d.ts +8 -2
  59. package/dist/providers/gemini.d.ts.map +1 -1
  60. package/dist/providers/gemini.js +117 -41
  61. package/dist/providers/gemini.js.map +1 -1
  62. package/dist/providers/openai.d.ts +12 -9
  63. package/dist/providers/openai.d.ts.map +1 -1
  64. package/dist/providers/openai.js +72 -21
  65. package/dist/providers/openai.js.map +1 -1
  66. package/dist/providers/responses.d.ts +3 -3
  67. package/dist/providers/responses.d.ts.map +1 -1
  68. package/dist/providers/responses.js +103 -57
  69. package/dist/providers/responses.js.map +1 -1
  70. package/dist/providers/zai.d.ts +2 -2
  71. package/dist/providers/zai.js +2 -2
  72. package/dist/rate-limit.d.ts +13 -3
  73. package/dist/rate-limit.d.ts.map +1 -1
  74. package/dist/rate-limit.js +10 -4
  75. package/dist/rate-limit.js.map +1 -1
  76. package/dist/retry.d.ts +16 -5
  77. package/dist/retry.d.ts.map +1 -1
  78. package/dist/retry.js +29 -16
  79. package/dist/retry.js.map +1 -1
  80. package/dist/schema.d.ts +6 -6
  81. package/dist/schema.js +8 -8
  82. package/dist/tool-args.d.ts +23 -1
  83. package/dist/tool-args.d.ts.map +1 -1
  84. package/dist/tool-args.js +30 -2
  85. package/dist/tool-args.js.map +1 -1
  86. package/dist/tools.d.ts +3 -3
  87. package/dist/tools.js +2 -2
  88. package/dist/tools.js.map +1 -1
  89. package/dist/transport.d.ts +40 -10
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +82 -21
  92. package/dist/transport.js.map +1 -1
  93. package/dist/types.d.ts +37 -23
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +3 -11
  96. package/dist/types.js.map +1 -1
  97. package/dist/usage.d.ts +4 -4
  98. package/dist/usage.d.ts.map +1 -1
  99. package/dist/usage.js +3 -3
  100. package/dist/usage.js.map +1 -1
  101. package/dist/watchdog.d.ts +14 -12
  102. package/dist/watchdog.d.ts.map +1 -1
  103. package/dist/watchdog.js +35 -16
  104. package/dist/watchdog.js.map +1 -1
  105. package/dist/zod.d.ts +4 -4
  106. package/dist/zod.js +3 -3
  107. package/package.json +1 -1
  108. package/src/attribution.ts +2 -2
  109. package/src/auth/flows.ts +5 -4
  110. package/src/auth/index.ts +1 -0
  111. package/src/auth/oauth.ts +23 -1
  112. package/src/auth/token-source.ts +30 -11
  113. package/src/auth/with-token.ts +97 -0
  114. package/src/capability.ts +12 -12
  115. package/src/context.ts +141 -6
  116. package/src/errors.ts +63 -23
  117. package/src/fallback.ts +173 -75
  118. package/src/image.ts +1 -1
  119. package/src/jev.ts +11 -11
  120. package/src/key-pool.ts +37 -20
  121. package/src/presets.ts +38 -24
  122. package/src/providers/anthropic.ts +97 -38
  123. package/src/providers/factory.ts +4 -3
  124. package/src/providers/gemini.ts +121 -44
  125. package/src/providers/openai.ts +78 -25
  126. package/src/providers/responses.ts +113 -61
  127. package/src/providers/zai.ts +2 -2
  128. package/src/rate-limit.ts +35 -7
  129. package/src/retry.ts +46 -15
  130. package/src/schema.ts +8 -8
  131. package/src/tool-args.ts +46 -2
  132. package/src/tools.ts +4 -4
  133. package/src/transport.ts +103 -23
  134. package/src/types.ts +37 -23
  135. package/src/usage.ts +5 -5
  136. package/src/watchdog.ts +38 -19
  137. package/src/zod.ts +4 -4
@@ -47,7 +47,7 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
47
47
  * preset's static protocol headers. */
48
48
  headers?: Record<string, string>;
49
49
  /**
50
- * App attribution for OpenRouter's rankings — the site URL rides as
50
+ * App attribution for OpenRouter's rankings. The site URL rides as
51
51
  * `HTTP-Referer`, the app name as `X-Title`. Public, not secret. Sent on
52
52
  * the OpenAI-compatible and Anthropic shapes (OpenRouter reads them for app
53
53
  * attribution); the Gemini and Responses shapes ignore them. An explicit
@@ -60,7 +60,7 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
60
60
  siteName?: string;
61
61
  /** OpenRouter-only: preferred upstream hosts, in order. OpenRouter's cache
62
62
  * lives on the upstream host's account and default routing hops hosts
63
- * between rounds — pinning keeps a conversation's rounds (and their cache)
63
+ * between rounds. Pinning keeps a conversation's rounds (and their cache)
64
64
  * on one host. Empty/absent = default routing. */
65
65
  providerOrder?: string[];
66
66
  /** Default `service_tier` for every call on the OpenAI and Responses shapes;
@@ -92,7 +92,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
92
92
  }
93
93
  const model = chain?.[0] ?? config.model ?? preset.defaultModel;
94
94
  if (!model) {
95
- throw new Error(`[providerkit] Preset "${id}" has no defaultModel — pass a model.`);
95
+ throw new Error(`[providerkit] Preset "${id}" has no defaultModel. Pass a model.`);
96
96
  }
97
97
  const wire = wireFor(preset, model);
98
98
  if (config.serviceTier && (wire.shape === "anthropic" || wire.shape === "gemini")) {
@@ -149,6 +149,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
149
149
  ...(config.providerOrder ? { providerOrder: config.providerOrder } : {}),
150
150
  ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
151
151
  ...(preset.initiatorHeader ? { initiatorHeader: true } : {}),
152
+ ...(preset.noSampling ? { noSampling: true } : {}),
152
153
  fetchImpl: config.fetchImpl,
153
154
  });
154
155
  break;
@@ -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 {
@@ -26,7 +27,7 @@ import type {
26
27
  export interface GeminiConfig extends ProviderFallbackConfig {
27
28
  apiKey: string;
28
29
  model: string;
29
- /** Any endpoint speaking the Generative Language REST dialect — a proxy or
30
+ /** Any endpoint speaking the Generative Language REST dialect, including a proxy or
30
31
  * gateway. Defaults to the Generative Language API.
31
32
  *
32
33
  * Not Vertex: it serves `/v1/projects/…/locations/…/publishers/google/models`
@@ -39,7 +40,7 @@ export interface GeminiConfig extends ProviderFallbackConfig {
39
40
  effort?: Effort;
40
41
  /**
41
42
  * How the schema rides on a call that also carries tools. See
42
- * {@link JsonWithTools} — and note that it is the MODEL's answer, not the
43
+ * {@link JsonWithTools}. Note that it is the model's answer, not the
43
44
  * endpoint's: measured 2026-09-07, `gemini-3.5-flash` and
44
45
  * `gemini-3.5-flash-lite` called their tool under `responseJsonSchema` every
45
46
  * time, while `gemini-3.8-flash` managed 3/10. `probeJsonWithTools` asks the
@@ -70,9 +71,10 @@ const THINKING_LEVEL: Record<Effort, string> = {
70
71
  };
71
72
 
72
73
  /**
73
- * Gemini 2.x thinking budget dial (tokens).
74
- * Gemini 2.5 uses `thinkingBudget` (0 disables thinking), while Gemini 3+ uses
75
- * `thinkingLevel` (MINIMAL, LOW, MEDIUM, HIGH).
74
+ * Gemini 2.x thinking budget dial (tokens). Gemini 2.5 takes `thinkingBudget`
75
+ * (0 disables thinking) and has no level. Every newer model takes
76
+ * `thinkingLevel`, and Google has said the next ones answer a budget with a
77
+ * 400 instead of remapping it.
76
78
  */
77
79
  const GEMINI_2_THINKING_BUDGET: Record<Effort, number> = {
78
80
  none: 0,
@@ -95,7 +97,7 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
95
97
  /**
96
98
  * Tool-call arguments as the OBJECT Gemini's `functionCall.args` requires.
97
99
  *
98
- * A bare JSON scalar or array is wrapped rather than rejected — the shared
100
+ * A bare JSON scalar or array is wrapped rather than rejected. The shared
99
101
  * `parseToolArgs` drops a non-object as a protocol violation, which is right
100
102
  * for reading a fresh tool call and wrong here, where replaying `{}` deletes an
101
103
  * argument the model did send. Everything else defers to it, so a truncated or
@@ -132,6 +134,12 @@ function toResponseObject(content: string): Record<string, unknown> {
132
134
  * and carry tool calls as `functionCall` parts with the thought signature
133
135
  * beside them; tool results are role `user` with a `functionResponse` part
134
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.
135
143
  */
136
144
  export function toGeminiContents(messages: readonly ChatMessage[]): {
137
145
  system?: string;
@@ -139,8 +147,11 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
139
147
  } {
140
148
  let system = "";
141
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;
142
152
 
143
153
  for (const message of messages) {
154
+ if (message.role === "user" || message.role === "assistant") results = undefined;
144
155
  switch (message.role) {
145
156
  case "system":
146
157
  system = system ? `${system}\n\n${message.content}` : message.content;
@@ -180,23 +191,24 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
180
191
  break;
181
192
  }
182
193
 
183
- case "tool":
184
- contents.push({
185
- role: "user",
186
- parts: [
187
- {
188
- functionResponse: {
189
- id: message.toolCallId,
190
- name: message.name,
191
- response: toResponseObject(message.content),
192
- },
193
- },
194
- ...(message.images ?? []).map((image) => ({
195
- inlineData: { mimeType: image.mimeType, data: image.data },
196
- })),
197
- ],
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
+ },
198
205
  });
206
+ results.responses += 1;
207
+ for (const image of message.images ?? []) {
208
+ results.parts.push({ inlineData: { mimeType: image.mimeType, data: image.data } });
209
+ }
199
210
  break;
211
+ }
200
212
  }
201
213
  }
202
214
 
@@ -205,7 +217,7 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
205
217
 
206
218
  /**
207
219
  * `auto` and an absent choice are Gemini's own default, so the field is omitted
208
- * rather than sent as AUTO. `required` and a pinned tool are both mode ANY —
220
+ * rather than sent as AUTO. `required` and a pinned tool are both mode ANY:
209
221
  * the pin is the allow-list, not the mode.
210
222
  */
211
223
  function toToolConfig(choice: ToolChoice | undefined): unknown {
@@ -215,15 +227,66 @@ function toToolConfig(choice: ToolChoice | undefined): unknown {
215
227
  return { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [choice.name] } };
216
228
  }
217
229
 
218
- 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
+ };
219
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.
220
249
  case "MAX_TOKENS":
250
+ case "CONTINUATION":
221
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.
222
255
  case "SAFETY":
223
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":
224
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":
225
288
  default:
226
- return "stop";
289
+ return fail("overload", "it gave no reason that shows the answer is complete");
227
290
  }
228
291
  }
229
292
 
@@ -236,7 +299,7 @@ interface GeminiPart {
236
299
  }
237
300
 
238
301
  /** Google's `google.rpc.Status`: an HTTP `code`, the canonical `status` name,
239
- * and `details` — which is where RetryInfo's `retryDelay` rides. */
302
+ * and `details`, which is where RetryInfo's `retryDelay` rides. */
240
303
  interface GeminiStatus {
241
304
  code?: number;
242
305
  message?: string;
@@ -255,7 +318,7 @@ interface GeminiResponse {
255
318
  thoughtsTokenCount?: number;
256
319
  cachedContentTokenCount?: number;
257
320
  };
258
- /** Present only on the in-band failure below — never on a real candidate. */
321
+ /** Present only on the in-band failure below. Never on a real candidate. */
259
322
  error?: GeminiStatus;
260
323
  /** A prompt Gemini refused outright: no candidate at all, just the reason. */
261
324
  promptFeedback?: { blockReason?: string };
@@ -276,28 +339,32 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
276
339
  ): AsyncIterable<ProviderChunk> {
277
340
  const effort = opts.effort ?? config.effort;
278
341
  const maxTokens = opts.maxTokens ?? config.maxTokens;
279
- 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);
280
345
 
281
346
  // A model id copied out of Gemini's docs is often already `models/…`, and
282
347
  // the doubled segment 404s as "model not found" — a confusing way to
283
348
  // learn about a prefix.
284
349
  const model = (opts.model ?? config.model).replace(/^models\//, "");
285
350
  const isGemini3 = /gemini-3/i.test(model);
351
+ // Not `!isGemini3`: `gemini-flash-latest` and models newer than 3 are not
352
+ // Gemini 3 either, and they must get a level.
353
+ const isGemini2 = /gemini-2/i.test(model);
286
354
 
287
355
  const generationConfig: Record<string, unknown> = {};
288
356
  if (maxTokens !== undefined) generationConfig.maxOutputTokens = maxTokens;
289
- if (opts.temperature !== undefined) generationConfig.temperature = opts.temperature;
290
- if (opts.topP !== undefined) generationConfig.topP = opts.topP;
357
+ // `temperature` and `topP` are never sent: Gemini 3.6 Flash and later ignore
358
+ // them and the next models reject them. Dropping them, rather than refusing,
359
+ // keeps a fallback chain that shares one set of options working.
291
360
  if (opts.stopSequences?.length) generationConfig.stopSequences = opts.stopSequences;
292
361
  // No effort means the model's own dynamic thinking. Sending MINIMAL / 0 here
293
362
  // would switch that off for a caller who never asked, which is the whole
294
363
  // reason the seam treats an absent effort as "never sent".
295
- // Gemini 3 uses thinkingLevel (MINIMAL..HIGH), while Gemini 2.5 uses
296
- // thinkingBudget (0 tokens disables thinking, positive numbers set budget).
297
364
  if (effort) {
298
- generationConfig.thinkingConfig = isGemini3
299
- ? { thinkingLevel: THINKING_LEVEL[effort] }
300
- : { thinkingBudget: GEMINI_2_THINKING_BUDGET[effort] };
365
+ generationConfig.thinkingConfig = isGemini2
366
+ ? { thinkingBudget: GEMINI_2_THINKING_BUDGET[effort] }
367
+ : { thinkingLevel: THINKING_LEVEL[effort] };
301
368
  }
302
369
  // A schema that shares its call with tools pins the decoder, and a pinned
303
370
  // decoder cannot emit a functionCall — the model narrates the call it
@@ -382,6 +449,11 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
382
449
  ...(opts.signal ? { signal: opts.signal } : {}),
383
450
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
384
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,
385
457
  })) {
386
458
  let chunk: GeminiResponse;
387
459
  try {
@@ -439,12 +511,14 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
439
511
  const call = part.functionCall;
440
512
  if (!call) continue;
441
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]);
442
520
  toolCalls.push({
443
- index,
444
- // Gemini omits the id on a single-call turn, and the seam's
445
- // consumers pair a result back to its call by id.
446
- id: call.id ?? `call_${index}`,
447
- ...(call.name ? { name: call.name } : {}),
521
+ ...identity,
448
522
  // Whole and already assembled — unlike the OpenAI shape, Gemini
449
523
  // never fragments an argument object across chunks.
450
524
  arguments: JSON.stringify(call.args ?? {}),
@@ -460,13 +534,16 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
460
534
  }
461
535
 
462
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);
463
539
  finished = true;
464
540
  yield {
465
541
  type: "finish",
466
- // A turn that called tools finishes as tool_calls whatever the
467
- // candidate says — Gemini routinely reports STOP there, and a
542
+ // Gemini routinely reports STOP on a turn that called tools, and a
468
543
  // caller reading that as "done" drops the tool round entirely.
469
- 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,
470
547
  };
471
548
  }
472
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,
@@ -31,7 +33,7 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
31
33
  * /chat/completions when the base already includes the API version, such as
32
34
  * a gateway using /v4 rather than /v1. */
33
35
  path?: string;
34
- /** Names the provider in errors and logs — "openrouter", "deepseek", … It
36
+ /** Names the provider in errors and logs ("openrouter", "deepseek", …). It
35
37
  * also picks the effort dialect below, unless `effortDialect` overrides. */
36
38
  id?: string;
37
39
  effort?: Effort;
@@ -50,7 +52,7 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
50
52
  /**
51
53
  * How a json request rides when the SAME call also carries tools.
52
54
  *
53
- * `response_format` — the default — sends both, which is what the shape
55
+ * `response_format` (the default) sends both, which is what the shape
54
56
  * documents and what most models honour. `prompt` drops the response format
55
57
  * from those calls only and sends the schema as prompt instead: the shape the
56
58
  * Anthropic adapter has always used, and the reason this failure cannot
@@ -58,7 +60,7 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
58
60
  *
59
61
  * It needs a knob because a model that cannot serve both does not say so. Its
60
62
  * decoder is pinned to the schema, the tool call has nowhere to go, and the
61
- * model writes the announcement instead — "let me look that up for you" — and
63
+ * model writes the announcement instead ("let me look that up for you") and
62
64
  * the turn ends looking like a model with no initiative rather than a request
63
65
  * that made the call impossible. Nothing is logged, because nothing failed.
64
66
  *
@@ -70,7 +72,7 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
70
72
  * No default is right for everyone, which is why this stays a setting rather
71
73
  * than becoming a rule: the same run put `qwen3.8-flash` at 1/6 with the
72
74
  * schema in the prompt against 6/6 with the response format. Don't guess it
73
- * from the model card — `probeJsonWithTools` asks the model itself, in one
75
+ * from the model card. `probeJsonWithTools` asks the model itself, in one
74
76
  * call. `StreamOptions.jsonWithTools` overrides this per call.
75
77
  */
76
78
  jsonWithTools?: JsonWithTools;
@@ -78,7 +80,7 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
78
80
  fetchImpl?: typeof fetch;
79
81
  headers?: Record<string, string>;
80
82
  /**
81
- * App attribution for OpenRouter's rankings — the site URL rides as
83
+ * App attribution for OpenRouter's rankings. The site URL rides as
82
84
  * `HTTP-Referer`, the app name as `X-Title`. Public, not secret. Sent
83
85
  * whenever set (other vendors ignore unknown headers); an explicit entry
84
86
  * in `headers` always wins.
@@ -91,7 +93,7 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
91
93
  /**
92
94
  * Pin OpenRouter to preferred upstream hosts so the PROMPT CACHE stays warm
93
95
  * across rounds. The cache lives on the upstream host's account and default
94
- * routing hops between them, and every hop is a cold cache — worse latency
96
+ * routing hops between them. Every hop is a cold cache, with worse latency
95
97
  * and higher effective input cost. Fallbacks stay on: this is a preference,
96
98
  * not a lock.
97
99
  */
@@ -109,6 +111,9 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
109
111
  * `agent`. GitHub Copilot bills a premium request per `user` turn and lets
110
112
  * the agent's own follow-ups (tool results) ride free. */
111
113
  initiatorHeader?: boolean;
114
+ /** Never send `temperature` or `top_p`. For an endpoint that fixes its own
115
+ * sampling and rejects the fields (Gemini's OpenAI-compatible route). */
116
+ noSampling?: boolean;
112
117
  /** Default `service_tier` for every call; `StreamOptions.serviceTier` wins. */
113
118
  serviceTier?: ServiceTier;
114
119
  }
@@ -215,14 +220,14 @@ const OPENROUTER_NONE: ReadonlyMap<string, "low" | "minimal" | "none"> = new Map
215
220
  * MiMo v2.6 Flash takes one, and DeepSeek V4 Flash thinks at `low` and not
216
221
  * with the field omitted. So `none` is said per model, from
217
222
  * `OPENROUTER_NONE`, which also says why an unlisted model gets nothing.
218
- * Naming a level raises it — the accepted values are `low`, `high` and
223
+ * Naming a level raises it. The accepted values are `low`, `high` and
219
224
  * `max` (`medium` and `xhigh` are accepted too, but our vocabulary has no
220
225
  * use for them).
221
226
  * - **DeepSeek V4 defaults thinking ON** on its own API, so `none` has to be an
222
227
  * explicit refusal there. The same word is a different request on each
223
228
  * dialect, and on OpenRouter on each model: the spelling belongs to the
224
229
  * endpoint, never to the caller.
225
- * - **OpenAI** takes `reasoning_effort`, and `none` is one of its values — not
230
+ * - **OpenAI** takes `reasoning_effort`, and `none` is one of its values, not
226
231
  * the absence of the field. GPT-5.1 both accepted `none` and made it the
227
232
  * default; everything from GPT-5 back still defaults to `medium`. So sending
228
233
  * nothing is NOT a way to say "do not think": on every model released before
@@ -325,19 +330,43 @@ function dialectFor(id: string): EffortDialect {
325
330
  return "openai";
326
331
  }
327
332
 
328
- 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 {
329
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.
330
346
  case "stop":
347
+ case "eos":
348
+ case "eos_token":
349
+ case "stop_sequence":
331
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.
332
354
  case "length":
355
+ case "model_context_window_exceeded":
356
+ case "model_length":
333
357
  return "length";
334
358
  case "tool_calls":
335
359
  case "function_call":
336
360
  return "tool_calls";
361
+ // `sensitive` is Z.ai's name for its safety stop. A retry meets the same
362
+ // filter.
337
363
  case "content_filter":
364
+ case "sensitive":
338
365
  return "content_filter";
339
366
  default:
340
- return undefined;
367
+ throw new ProviderError(provider, "overload", `${provider} ended the turn on "${reason}"`, {
368
+ code: reason,
369
+ });
341
370
  }
342
371
  }
343
372
 
@@ -351,7 +380,7 @@ function partsToOpenAI(content: string | ContentPart[], provider: string): unkno
351
380
  }
352
381
 
353
382
  /**
354
- * Assistant turns carry `reasoning_content` when the history has it — thinking
383
+ * Assistant turns carry `reasoning_content` when the history has it. Thinking
355
384
  * providers require the prior turn's chain-of-thought replayed on a turn that
356
385
  * made a tool call. A caller running a turn with thinking OFF must strip it
357
386
  * first (`stripReasoning`); the two cannot be mixed.
@@ -454,7 +483,7 @@ interface OpenAIChunk {
454
483
  is_byok?: boolean;
455
484
  cost_details?: { upstream_inference_cost?: unknown } | null;
456
485
  } | null;
457
- /** Present only on the in-band failure below — never beside a choice.
486
+ /** Present only on the in-band failure below. Never beside a choice.
458
487
  * `code` is the numeric HTTP status on the gateways, a slug on OpenAI. */
459
488
  error?: { message?: string; code?: string | number; type?: string };
460
489
  }
@@ -516,7 +545,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
516
545
  const effort = opts.effort ?? config.effort;
517
546
  const model = opts.model ?? config.model;
518
547
 
519
- 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);
520
551
  const request: Record<string, unknown> = {
521
552
  model,
522
553
  messages: body,
@@ -527,8 +558,10 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
527
558
  };
528
559
  const maxTokens = opts.maxTokens ?? config.maxTokens;
529
560
  if (maxTokens !== undefined) request.max_tokens = maxTokens;
530
- if (opts.temperature !== undefined) request.temperature = opts.temperature;
531
- if (opts.topP !== undefined) request.top_p = opts.topP;
561
+ if (!config.noSampling) {
562
+ if (opts.temperature !== undefined) request.temperature = opts.temperature;
563
+ if (opts.topP !== undefined) request.top_p = opts.topP;
564
+ }
532
565
  if (opts.stopSequences?.length) request.stop = opts.stopSequences;
533
566
  const serviceTier = opts.serviceTier ?? config.serviceTier;
534
567
  if (serviceTier) request.service_tier = serviceTier;
@@ -619,6 +652,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
619
652
  : "/v1/chat/completions";
620
653
 
621
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>();
622
658
  for await (const data of streamSse({
623
659
  url: apiUrl(baseUrl, config.path ?? defaultPath),
624
660
  headers: {
@@ -637,16 +673,16 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
637
673
  ...(opts.signal ? { signal: opts.signal } : {}),
638
674
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
639
675
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
676
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
640
677
  onDone: () => {
641
678
  ended = true;
642
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,
643
684
  })) {
644
- let chunk: OpenAIChunk;
645
- try {
646
- chunk = JSON.parse(data) as OpenAIChunk;
647
- } catch {
648
- continue;
649
- }
685
+ const chunk = parseFrame<OpenAIChunk>(data, id);
650
686
 
651
687
  // A failure the backend reports after its headers went out. The
652
688
  // gateways speaking this dialect — OpenRouter above all — report a
@@ -718,18 +754,35 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
718
754
  ? { arguments: call.function.arguments }
719
755
  : {}),
720
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
+ }
721
763
  has = true;
722
764
  }
723
765
  if (has) yield out;
724
766
  }
725
767
 
726
- if (choice.finish_reason) ended = true;
727
- const finishReason = mapFinishReason(choice.finish_reason);
728
- 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
+ }
729
779
  }
730
780
  // Either proof will do: some gateways send `[DONE]` with no finish, and
731
781
  // some a finish with no `[DONE]`. Neither means the socket closed early.
732
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 };
733
786
  },
734
787
  };
735
788