@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.
- package/README.md +7 -7
- package/dist/attribution.d.ts +2 -2
- package/dist/auth/flows.d.ts.map +1 -1
- package/dist/auth/flows.js +5 -5
- package/dist/auth/flows.js.map +1 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oauth.d.ts +12 -0
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +19 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/token-source.d.ts +9 -3
- package/dist/auth/token-source.d.ts.map +1 -1
- package/dist/auth/token-source.js +24 -9
- package/dist/auth/token-source.js.map +1 -1
- package/dist/auth/with-token.d.ts +29 -0
- package/dist/auth/with-token.d.ts.map +1 -0
- package/dist/auth/with-token.js +85 -0
- package/dist/auth/with-token.js.map +1 -0
- package/dist/capability.d.ts +9 -9
- package/dist/capability.js +9 -9
- package/dist/capability.js.map +1 -1
- package/dist/context.d.ts +43 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +123 -4
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +26 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +57 -18
- package/dist/errors.js.map +1 -1
- package/dist/fallback.d.ts +45 -1
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +138 -75
- package/dist/fallback.js.map +1 -1
- package/dist/image.d.ts +1 -1
- package/dist/image.d.ts.map +1 -1
- package/dist/jev.d.ts +9 -9
- package/dist/jev.d.ts.map +1 -1
- package/dist/jev.js +1 -1
- package/dist/key-pool.d.ts +14 -8
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +35 -18
- package/dist/key-pool.js.map +1 -1
- package/dist/presets.d.ts +33 -24
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +25 -14
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +5 -5
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +89 -34
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +2 -2
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +2 -1
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +8 -2
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +117 -41
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +12 -9
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +72 -21
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +3 -3
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +103 -57
- package/dist/providers/responses.js.map +1 -1
- package/dist/providers/zai.d.ts +2 -2
- package/dist/providers/zai.js +2 -2
- package/dist/rate-limit.d.ts +13 -3
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +10 -4
- package/dist/rate-limit.js.map +1 -1
- package/dist/retry.d.ts +16 -5
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +29 -16
- package/dist/retry.js.map +1 -1
- package/dist/schema.d.ts +6 -6
- package/dist/schema.js +8 -8
- package/dist/tool-args.d.ts +23 -1
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +30 -2
- package/dist/tool-args.js.map +1 -1
- package/dist/tools.d.ts +3 -3
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/transport.d.ts +40 -10
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +82 -21
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +37 -23
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -11
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +4 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +3 -3
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +14 -12
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +35 -16
- package/dist/watchdog.js.map +1 -1
- package/dist/zod.d.ts +4 -4
- package/dist/zod.js +3 -3
- package/package.json +1 -1
- package/src/attribution.ts +2 -2
- package/src/auth/flows.ts +5 -4
- package/src/auth/index.ts +1 -0
- package/src/auth/oauth.ts +23 -1
- package/src/auth/token-source.ts +30 -11
- package/src/auth/with-token.ts +97 -0
- package/src/capability.ts +12 -12
- package/src/context.ts +141 -6
- package/src/errors.ts +63 -23
- package/src/fallback.ts +173 -75
- package/src/image.ts +1 -1
- package/src/jev.ts +11 -11
- package/src/key-pool.ts +37 -20
- package/src/presets.ts +38 -24
- package/src/providers/anthropic.ts +97 -38
- package/src/providers/factory.ts +4 -3
- package/src/providers/gemini.ts +121 -44
- package/src/providers/openai.ts +78 -25
- package/src/providers/responses.ts +113 -61
- package/src/providers/zai.ts +2 -2
- package/src/rate-limit.ts +35 -7
- package/src/retry.ts +46 -15
- package/src/schema.ts +8 -8
- package/src/tool-args.ts +46 -2
- package/src/tools.ts +4 -4
- package/src/transport.ts +103 -23
- package/src/types.ts +37 -23
- package/src/usage.ts +5 -5
- package/src/watchdog.ts +38 -19
- package/src/zod.ts +4 -4
package/src/providers/factory.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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;
|
package/src/providers/gemini.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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}
|
|
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
|
-
*
|
|
75
|
-
* `thinkingLevel
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
parts:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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 =
|
|
299
|
-
? {
|
|
300
|
-
: {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/providers/openai.ts
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
531
|
-
|
|
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
|
-
|
|
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)
|
|
727
|
-
|
|
728
|
-
|
|
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
|
|