@providerkit/core 0.9.0 → 0.10.1
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/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +94 -0
- package/dist/errors.js.map +1 -1
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +5 -1
- package/dist/fallback.js.map +1 -1
- package/dist/presets.d.ts +46 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +56 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.js +2 -2
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +28 -10
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +23 -18
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +83 -45
- package/dist/providers/openai.js.map +1 -1
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +2 -2
- package/dist/retry.js.map +1 -1
- package/dist/schema.d.ts +20 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +112 -0
- package/dist/schema.js.map +1 -1
- package/dist/tool-args.d.ts +13 -1
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +50 -1
- package/dist/tool-args.js.map +1 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +3 -0
- package/dist/transport.js.map +1 -1
- package/dist/usage.d.ts +6 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +20 -0
- package/dist/usage.js.map +1 -1
- package/package.json +2 -1
- package/src/capability.ts +0 -1
- package/src/errors.ts +109 -0
- package/src/fallback.ts +50 -22
- package/src/presets.ts +56 -2
- package/src/providers/anthropic.ts +2 -2
- package/src/providers/gemini.ts +30 -10
- package/src/providers/openai.ts +88 -45
- package/src/retry.ts +12 -5
- package/src/schema.ts +127 -0
- package/src/tool-args.ts +48 -1
- package/src/transport.ts +4 -0
- package/src/usage.ts +21 -0
package/src/fallback.ts
CHANGED
|
@@ -6,16 +6,17 @@ import { createPresetProvider, type PresetProviderConfig } from "./providers/fac
|
|
|
6
6
|
|
|
7
7
|
/** Retry intervals when the server supplies no deadline, not estimates of when
|
|
8
8
|
* it will recover. A server deadline always wins over these defaults. */
|
|
9
|
-
export const DEFAULT_FALLBACK_COOLDOWNS: Readonly<Partial<Record<ErrorKind, number>>> =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
export const DEFAULT_FALLBACK_COOLDOWNS: Readonly<Partial<Record<ErrorKind, number>>> =
|
|
10
|
+
Object.freeze({
|
|
11
|
+
rate: 60_000,
|
|
12
|
+
quota: 3_600_000,
|
|
13
|
+
timeout: 30_000,
|
|
14
|
+
network: 30_000,
|
|
15
|
+
overload: 30_000,
|
|
16
|
+
auth: 3_600_000,
|
|
17
|
+
entitlement: 3_600_000,
|
|
18
|
+
model: 3_600_000,
|
|
19
|
+
});
|
|
19
20
|
|
|
20
21
|
export interface FallbackOptions<T> {
|
|
21
22
|
/** Per-kind intervals in ms; null stops fallback for that kind. Adding a kind
|
|
@@ -89,10 +90,15 @@ export class NoAvailableProviderError extends ProviderError {
|
|
|
89
90
|
readonly retryAtMs: number;
|
|
90
91
|
|
|
91
92
|
constructor(retryAtMs: number, now: number) {
|
|
92
|
-
super(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
super(
|
|
94
|
+
"fallback",
|
|
95
|
+
"rate",
|
|
96
|
+
"All configured providers are unavailable. Retry after the cooldown.",
|
|
97
|
+
{
|
|
98
|
+
resetAtMs: retryAtMs,
|
|
99
|
+
retryAfterMs: Math.max(0, retryAtMs - now),
|
|
100
|
+
},
|
|
101
|
+
);
|
|
96
102
|
this.name = "NoAvailableProviderError";
|
|
97
103
|
this.retryAtMs = retryAtMs;
|
|
98
104
|
}
|
|
@@ -107,14 +113,20 @@ export class FallbackPool<T> {
|
|
|
107
113
|
private readonly cooldowns = new Map<T, Cooldown>();
|
|
108
114
|
private readonly now: () => number;
|
|
109
115
|
|
|
110
|
-
constructor(
|
|
111
|
-
|
|
116
|
+
constructor(
|
|
117
|
+
candidates: readonly T[],
|
|
118
|
+
private readonly options: FallbackOptions<T> = {},
|
|
119
|
+
) {
|
|
120
|
+
if (candidates.length === 0)
|
|
121
|
+
throw new Error("providerkit: supply a primary before its backups");
|
|
112
122
|
if (new Set(candidates).size !== candidates.length) {
|
|
113
123
|
throw new Error("providerkit: each fallback candidate must appear only once");
|
|
114
124
|
}
|
|
115
125
|
for (const value of Object.values(options.cooldownMs ?? {})) {
|
|
116
126
|
if (value !== null && value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
117
|
-
throw new Error(
|
|
127
|
+
throw new Error(
|
|
128
|
+
"providerkit: fallback cooldowns must be finite, non-negative milliseconds",
|
|
129
|
+
);
|
|
118
130
|
}
|
|
119
131
|
}
|
|
120
132
|
this.candidates = [...candidates];
|
|
@@ -128,7 +140,13 @@ export class FallbackPool<T> {
|
|
|
128
140
|
else this.cooldowns.delete(selection[0]);
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
status(): Array<{
|
|
143
|
+
status(): Array<{
|
|
144
|
+
candidate: T;
|
|
145
|
+
retryAtMs: number;
|
|
146
|
+
probing: boolean;
|
|
147
|
+
kind?: ErrorKind;
|
|
148
|
+
window?: RateLimitWindow;
|
|
149
|
+
}> {
|
|
132
150
|
return this.candidates.map((candidate) => ({
|
|
133
151
|
candidate,
|
|
134
152
|
retryAtMs: 0,
|
|
@@ -138,10 +156,14 @@ export class FallbackPool<T> {
|
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
/** Buffered calls only: do not wrap an agent turn that has run side effects. */
|
|
141
|
-
async with<R>(
|
|
159
|
+
async with<R>(
|
|
160
|
+
attempt: (candidate: T, signal: AbortSignal) => Promise<R>,
|
|
161
|
+
signal?: AbortSignal,
|
|
162
|
+
): Promise<R> {
|
|
142
163
|
for await (const result of this.stream(async function* (candidate, inner) {
|
|
143
164
|
yield await attempt(candidate, inner);
|
|
144
|
-
}, signal))
|
|
165
|
+
}, signal))
|
|
166
|
+
return result;
|
|
145
167
|
throw new Error("providerkit: fallback attempt ended without a result");
|
|
146
168
|
}
|
|
147
169
|
|
|
@@ -218,7 +240,9 @@ export class FallbackPool<T> {
|
|
|
218
240
|
if (hasFailed) throw lastError;
|
|
219
241
|
// If all expired candidates are already being probed, the next opportunity
|
|
220
242
|
// is now. Other callers need not wait for those probes or start duplicates.
|
|
221
|
-
const retryAtMs = Math.min(
|
|
243
|
+
const retryAtMs = Math.min(
|
|
244
|
+
...this.status().map((entry) => Math.max(this.now(), entry.retryAtMs)),
|
|
245
|
+
);
|
|
222
246
|
throw new NoAvailableProviderError(retryAtMs, this.now());
|
|
223
247
|
}
|
|
224
248
|
}
|
|
@@ -245,7 +269,11 @@ export function withFallbackProviders(
|
|
|
245
269
|
return fallbacks.stream(async function* (provider, signal): AsyncGenerator<ProviderChunk> {
|
|
246
270
|
const { model: override, ...rest } = opts;
|
|
247
271
|
const model = provider === primary ? (override ?? provider.model) : provider.model;
|
|
248
|
-
for await (const chunk of provider.createStream(messages, tools, {
|
|
272
|
+
for await (const chunk of provider.createStream(messages, tools, {
|
|
273
|
+
...rest,
|
|
274
|
+
model,
|
|
275
|
+
signal,
|
|
276
|
+
})) {
|
|
249
277
|
yield { ...chunk, source: chunk.source ?? { provider: provider.id, model } };
|
|
250
278
|
}
|
|
251
279
|
}, opts.signal);
|
package/src/presets.ts
CHANGED
|
@@ -111,7 +111,12 @@ export const PROVIDER_PRESETS = {
|
|
|
111
111
|
baseUrl: "https://openrouter.ai/api",
|
|
112
112
|
auth: "key",
|
|
113
113
|
defaultModel: "deepseek/deepseek-v4.1-flash",
|
|
114
|
-
models: [
|
|
114
|
+
models: [
|
|
115
|
+
"deepseek/deepseek-v4.1-flash",
|
|
116
|
+
"z-ai/glm-5.3-flash",
|
|
117
|
+
"x-ai/grok-4.6",
|
|
118
|
+
"openai/gpt-5.6-luna-pro",
|
|
119
|
+
],
|
|
115
120
|
},
|
|
116
121
|
groq: {
|
|
117
122
|
shape: "openai",
|
|
@@ -146,7 +151,10 @@ export const PROVIDER_PRESETS = {
|
|
|
146
151
|
baseUrl: "https://api.fireworks.ai/inference/v1",
|
|
147
152
|
auth: "key",
|
|
148
153
|
defaultModel: "accounts/fireworks/models/deepseek-v4p1-flash",
|
|
149
|
-
models: [
|
|
154
|
+
models: [
|
|
155
|
+
"accounts/fireworks/models/deepseek-v4p1-flash",
|
|
156
|
+
"accounts/fireworks/models/glm-5p3-flash",
|
|
157
|
+
],
|
|
150
158
|
},
|
|
151
159
|
cerebras: {
|
|
152
160
|
shape: "openai",
|
|
@@ -177,6 +185,13 @@ export const PROVIDER_PRESETS = {
|
|
|
177
185
|
defaultModel: "MiniMax-M3",
|
|
178
186
|
models: ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
|
|
179
187
|
},
|
|
188
|
+
"minimax-openai": {
|
|
189
|
+
shape: "openai",
|
|
190
|
+
baseUrl: "https://api.minimax.io/v1",
|
|
191
|
+
auth: "key",
|
|
192
|
+
defaultModel: "MiniMax-M3",
|
|
193
|
+
models: ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
|
|
194
|
+
},
|
|
180
195
|
|
|
181
196
|
// ── Popular gateways & cloud providers ──────────────────────────────────
|
|
182
197
|
nvidia: {
|
|
@@ -284,6 +299,14 @@ export const PROVIDER_PRESETS = {
|
|
|
284
299
|
// (`z-ai/glm-5.3-flash`) answers 400 [1211] Unknown Model.
|
|
285
300
|
explicitNone: true,
|
|
286
301
|
},
|
|
302
|
+
"zai-openai": {
|
|
303
|
+
shape: "openai",
|
|
304
|
+
baseUrl: "https://api.z.ai/api/paas/v4",
|
|
305
|
+
path: "/chat/completions",
|
|
306
|
+
auth: "key",
|
|
307
|
+
defaultModel: "glm-5.3-flash",
|
|
308
|
+
models: ["glm-5.3-flash", "glm-5.3", "glm-5.2"],
|
|
309
|
+
},
|
|
287
310
|
kimi: {
|
|
288
311
|
shape: "anthropic",
|
|
289
312
|
baseUrl: "https://api.kimi.ai/coding",
|
|
@@ -291,6 +314,13 @@ export const PROVIDER_PRESETS = {
|
|
|
291
314
|
defaultModel: "kimi-for-coding",
|
|
292
315
|
models: ["kimi-for-coding", "kimi-for-coding-highspeed", "k3", "k3-256k"],
|
|
293
316
|
},
|
|
317
|
+
"kimi-openai": {
|
|
318
|
+
shape: "openai",
|
|
319
|
+
baseUrl: "https://api.moonshot.ai/v1",
|
|
320
|
+
auth: "key",
|
|
321
|
+
defaultModel: "kimi-k2.7-code",
|
|
322
|
+
models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k3"],
|
|
323
|
+
},
|
|
294
324
|
/** Alibaba's dashscope coding plan — OpenAI-compatible per its registry. */
|
|
295
325
|
"alibaba-coding-plan": {
|
|
296
326
|
shape: "openai",
|
|
@@ -307,6 +337,30 @@ export const PROVIDER_PRESETS = {
|
|
|
307
337
|
defaultModel: "qwen3.8-max",
|
|
308
338
|
models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.6-flash"],
|
|
309
339
|
},
|
|
340
|
+
/** QwenCloud's token plan — OpenAI-compatible endpoint. */
|
|
341
|
+
"qwen-token-plan-openai": {
|
|
342
|
+
shape: "openai",
|
|
343
|
+
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
|
|
344
|
+
auth: "bearer",
|
|
345
|
+
defaultModel: "qwen3.8-max",
|
|
346
|
+
models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.6-flash"],
|
|
347
|
+
},
|
|
348
|
+
/** Alibaba DashScope Platform API — standard pay-as-you-go API key. */
|
|
349
|
+
alibaba: {
|
|
350
|
+
shape: "openai",
|
|
351
|
+
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
352
|
+
auth: "bearer",
|
|
353
|
+
defaultModel: "qwen-max",
|
|
354
|
+
models: ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-flash"],
|
|
355
|
+
},
|
|
356
|
+
/** Alias for alibaba (DashScope platform API). */
|
|
357
|
+
"qwen-api": {
|
|
358
|
+
shape: "openai",
|
|
359
|
+
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
360
|
+
auth: "bearer",
|
|
361
|
+
defaultModel: "qwen-max",
|
|
362
|
+
models: ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-flash"],
|
|
363
|
+
},
|
|
310
364
|
|
|
311
365
|
// ── Local runtimes ──────────────────────────────────────────────────────
|
|
312
366
|
ollama: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Anthropic-shape adapter — SSE from POST /v1/messages.
|
|
2
2
|
import { streamError } from "../errors.ts";
|
|
3
|
-
import { schemaPrompt } from "../schema.ts";
|
|
3
|
+
import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
|
|
4
4
|
import { parseToolArgs } from "../tool-args.ts";
|
|
5
5
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
6
6
|
import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
|
|
@@ -249,7 +249,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
249
249
|
request.tools = tools.map((tool) => ({
|
|
250
250
|
name: tool.name,
|
|
251
251
|
description: tool.description,
|
|
252
|
-
input_schema: tool.inputSchema,
|
|
252
|
+
input_schema: toAnthropicToolSchema(tool.inputSchema),
|
|
253
253
|
}));
|
|
254
254
|
}
|
|
255
255
|
if (opts.toolChoice && opts.toolChoice !== "auto") {
|
package/src/providers/gemini.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// wire rather than @google/genai, because this package ships zero dependencies
|
|
7
7
|
// and the SDK is a Node-shaped one.
|
|
8
8
|
import { streamError } from "../errors.ts";
|
|
9
|
-
import { schemaPrompt } from "../schema.ts";
|
|
9
|
+
import { schemaPrompt, toGeminiToolSchema } from "../schema.ts";
|
|
10
10
|
import { parseToolArgs } from "../tool-args.ts";
|
|
11
11
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
12
12
|
import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
|
|
@@ -69,6 +69,19 @@ const THINKING_LEVEL: Record<Effort, string> = {
|
|
|
69
69
|
max: "HIGH",
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
+
/**
|
|
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).
|
|
76
|
+
*/
|
|
77
|
+
const GEMINI_2_THINKING_BUDGET: Record<Effort, number> = {
|
|
78
|
+
none: 0,
|
|
79
|
+
low: 1024,
|
|
80
|
+
medium: 4096,
|
|
81
|
+
high: 8192,
|
|
82
|
+
max: 16384,
|
|
83
|
+
};
|
|
84
|
+
|
|
72
85
|
/** A turn in Gemini's history. `model` is its word for the assistant. */
|
|
73
86
|
export interface GeminiContent {
|
|
74
87
|
role: "user" | "model";
|
|
@@ -261,15 +274,27 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
261
274
|
const maxTokens = opts.maxTokens ?? config.maxTokens;
|
|
262
275
|
const { system, contents } = toGeminiContents(messages);
|
|
263
276
|
|
|
277
|
+
// A model id copied out of Gemini's docs is often already `models/…`, and
|
|
278
|
+
// the doubled segment 404s as "model not found" — a confusing way to
|
|
279
|
+
// learn about a prefix.
|
|
280
|
+
const model = (opts.model ?? config.model).replace(/^models\//, "");
|
|
281
|
+
const isGemini3 = /gemini-3/i.test(model);
|
|
282
|
+
|
|
264
283
|
const generationConfig: Record<string, unknown> = {};
|
|
265
284
|
if (maxTokens !== undefined) generationConfig.maxOutputTokens = maxTokens;
|
|
266
285
|
if (opts.temperature !== undefined) generationConfig.temperature = opts.temperature;
|
|
267
286
|
if (opts.topP !== undefined) generationConfig.topP = opts.topP;
|
|
268
287
|
if (opts.stopSequences?.length) generationConfig.stopSequences = opts.stopSequences;
|
|
269
|
-
// No effort means the model's own dynamic thinking. Sending MINIMAL here
|
|
288
|
+
// No effort means the model's own dynamic thinking. Sending MINIMAL / 0 here
|
|
270
289
|
// would switch that off for a caller who never asked, which is the whole
|
|
271
290
|
// reason the seam treats an absent effort as "never sent".
|
|
272
|
-
|
|
291
|
+
// Gemini 3 uses thinkingLevel (MINIMAL..HIGH), while Gemini 2.5 uses
|
|
292
|
+
// thinkingBudget (0 tokens disables thinking, positive numbers set budget).
|
|
293
|
+
if (effort) {
|
|
294
|
+
generationConfig.thinkingConfig = isGemini3
|
|
295
|
+
? { thinkingLevel: THINKING_LEVEL[effort] }
|
|
296
|
+
: { thinkingBudget: GEMINI_2_THINKING_BUDGET[effort] };
|
|
297
|
+
}
|
|
273
298
|
// A schema that shares its call with tools pins the decoder, and a pinned
|
|
274
299
|
// decoder cannot emit a functionCall — the model narrates the call it
|
|
275
300
|
// could not make and the turn ends, with nothing logged. See
|
|
@@ -281,7 +306,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
281
306
|
// `responseJsonSchema` takes JSON Schema as written; `responseSchema`
|
|
282
307
|
// is Gemini's own trimmed dialect and rejects most of what a real
|
|
283
308
|
// schema carries.
|
|
284
|
-
generationConfig.responseJsonSchema = opts.json.schema;
|
|
309
|
+
generationConfig.responseJsonSchema = toGeminiToolSchema(opts.json.schema);
|
|
285
310
|
}
|
|
286
311
|
|
|
287
312
|
const request: Record<string, unknown> = { contents, generationConfig };
|
|
@@ -302,7 +327,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
302
327
|
description: tool.description,
|
|
303
328
|
// Same rule as the response schema: `parameters` is the trimmed
|
|
304
329
|
// dialect, `parametersJsonSchema` is the schema we actually wrote.
|
|
305
|
-
parametersJsonSchema: tool.inputSchema,
|
|
330
|
+
parametersJsonSchema: toGeminiToolSchema(tool.inputSchema),
|
|
306
331
|
})),
|
|
307
332
|
},
|
|
308
333
|
];
|
|
@@ -312,11 +337,6 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
312
337
|
if (toolConfig) request.toolConfig = toolConfig;
|
|
313
338
|
}
|
|
314
339
|
|
|
315
|
-
// A model id copied out of Gemini's docs is often already `models/…`, and
|
|
316
|
-
// the doubled segment 404s as "model not found" — a confusing way to
|
|
317
|
-
// learn about a prefix.
|
|
318
|
-
const model = (opts.model ?? config.model).replace(/^models\//, "");
|
|
319
|
-
|
|
320
340
|
// Gemini reports the finish reason on a candidate that can arrive AFTER
|
|
321
341
|
// the chunk carrying the function calls, so a turn's tool use has to be
|
|
322
342
|
// remembered rather than read off the final chunk.
|
package/src/providers/openai.ts
CHANGED
|
@@ -83,10 +83,56 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
|
|
|
83
83
|
* not a lock.
|
|
84
84
|
*/
|
|
85
85
|
providerOrder?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Automatically pin OpenRouter calls to the first-party model vendor's host
|
|
88
|
+
* when hitting openrouter.ai and providerOrder is not passed. Defaults to true.
|
|
89
|
+
*/
|
|
90
|
+
pinHost?: boolean;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
const DEFAULT_BASE_URL = "https://api.openai.com";
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Known first-party host slugs on OpenRouter for route pinning.
|
|
97
|
+
* Pinning keeps consecutive turns on the vendor's own endpoint, preserving
|
|
98
|
+
* the server-side KV prompt cache across rounds.
|
|
99
|
+
*/
|
|
100
|
+
const OPENROUTER_HOSTS: Record<string, string> = {
|
|
101
|
+
anthropic: "anthropic",
|
|
102
|
+
"arcee-ai": "arcee-ai",
|
|
103
|
+
cohere: "cohere",
|
|
104
|
+
deepseek: "deepseek",
|
|
105
|
+
google: "google-ai-studio",
|
|
106
|
+
meta: "meta",
|
|
107
|
+
minimax: "minimax",
|
|
108
|
+
mistralai: "mistral",
|
|
109
|
+
moonshotai: "moonshotai",
|
|
110
|
+
morph: "morph",
|
|
111
|
+
openai: "openai",
|
|
112
|
+
perplexity: "perplexity",
|
|
113
|
+
qwen: "alibaba",
|
|
114
|
+
stepfun: "stepfun",
|
|
115
|
+
tencent: "tencent",
|
|
116
|
+
upstage: "upstage",
|
|
117
|
+
"x-ai": "xai",
|
|
118
|
+
"z-ai": "z-ai",
|
|
119
|
+
zai: "z-ai",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function openRouterHostFor(baseUrl: string, model: string): string | undefined {
|
|
123
|
+
let host: string;
|
|
124
|
+
try {
|
|
125
|
+
host = new URL(baseUrl).hostname;
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
if (!host.includes("openrouter.ai")) return undefined;
|
|
130
|
+
const slash = model.indexOf("/");
|
|
131
|
+
if (slash <= 0) return undefined;
|
|
132
|
+
const vendor = model.slice(0, slash).toLowerCase();
|
|
133
|
+
return OPENROUTER_HOSTS[vendor];
|
|
134
|
+
}
|
|
135
|
+
|
|
90
136
|
/** The spellings of "think this hard" across the dialects that share this
|
|
91
137
|
* adapter. `off` sends nothing and leaves the model on its own default. */
|
|
92
138
|
export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
|
|
@@ -96,23 +142,20 @@ export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
|
|
|
96
142
|
*
|
|
97
143
|
* One knob, three incompatible spellings, and the differences are not cosmetic:
|
|
98
144
|
*
|
|
99
|
-
* - **On OpenRouter,
|
|
100
|
-
* `reasoning
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* mandatory, `none` is refused outright and the whole call is lost.
|
|
111
|
-
* The honest way to ask for the least thinking on this dialect is to pass no
|
|
112
|
-
* effort at all — see `effortParams`, where the numbers live.
|
|
145
|
+
* - **On OpenRouter, GLM's off switch is the ABSENT field.** Not setting
|
|
146
|
+
* `reasoning` at all disables thinking for GLM 5.3 Flash (measured live
|
|
147
|
+
* 2026-09-14). Naming a level raises it — the accepted values are
|
|
148
|
+
* `low`, `high` and `max` (`medium` and `xhigh` are accepted too, but our
|
|
149
|
+
* vocabulary has no use for them). `none` is NOT sendable there: the model
|
|
150
|
+
* answers
|
|
151
|
+
* `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`
|
|
152
|
+
* to `reasoning.effort: "none"` exactly as it does to
|
|
153
|
+
* `reasoning.enabled: false` — so "do not think" on this dialect is said by
|
|
154
|
+
* omitting the field, never by naming a level. See `effortParams`, where the
|
|
155
|
+
* numbers live.
|
|
113
156
|
* - **DeepSeek V4 defaults thinking ON**, so `none` has to be an explicit
|
|
114
|
-
* refusal. That is the case proving OpenRouter's
|
|
115
|
-
* not a preference for always thinking.
|
|
157
|
+
* refusal. That is the case proving OpenRouter's absent-field off switch is
|
|
158
|
+
* a real dialect fact and not a preference for always thinking.
|
|
116
159
|
* - **OpenAI** takes `reasoning_effort`, and `none` is one of its values — not
|
|
117
160
|
* the absence of the field. GPT-5.1 both accepted `none` and made it the
|
|
118
161
|
* default; everything from GPT-5 back still defaults to `medium`. So sending
|
|
@@ -122,8 +165,10 @@ export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
|
|
|
122
165
|
* empty with `finish_reason: "length"`.
|
|
123
166
|
*
|
|
124
167
|
* An absent effort sends nothing on every dialect: the seam's rule is that a
|
|
125
|
-
* knob the caller never touched is a knob the provider still owns.
|
|
126
|
-
*
|
|
168
|
+
* knob the caller never touched is a knob the provider still owns. On
|
|
169
|
+
* OpenRouter that makes `none` and "never asked" the same request — on this
|
|
170
|
+
* dialect, not thinking IS the default state, so there is no third spelling
|
|
171
|
+
* to invent.
|
|
127
172
|
*/
|
|
128
173
|
export function effortParams(
|
|
129
174
|
dialect: EffortDialect,
|
|
@@ -143,31 +188,19 @@ export function effortParams(
|
|
|
143
188
|
? { thinking: { type: "enabled" } }
|
|
144
189
|
: { thinking: { type: "enabled" }, reasoning_effort: level };
|
|
145
190
|
case "openrouter":
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// to `reasoning.effort: "none"` exactly as it does to
|
|
155
|
-
// `reasoning.enabled: false` — so on a model that must think, `none` is a
|
|
156
|
-
// refusal and not a floor. Callers that mean "as little as possible"
|
|
157
|
-
// should pass no effort at all rather than `none`, which is why the
|
|
158
|
-
// absent-effort branch above stays load-bearing.
|
|
191
|
+
// GLM's off switch here is the ABSENT field (measured live 2026-09-14:
|
|
192
|
+
// omit `reasoning` → thinking disabled for glm-5.3-flash), so `none`
|
|
193
|
+
// sends nothing — the same request as a caller who never asked, which on
|
|
194
|
+
// this dialect is the same state. Naming a level RAISES it: the accepted
|
|
195
|
+
// values are low, high and max (`medium` and `xhigh` exist on the wire
|
|
196
|
+
// The refusal that once looked like "OpenRouter cannot be told not to
|
|
197
|
+
// think" was `reasoning.enabled: false` — a different field, never sent
|
|
198
|
+
// (pinned by the test below).
|
|
159
199
|
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
// max_tokens 2000 30,3s · 484 reasoning tokens
|
|
165
|
-
//
|
|
166
|
-
// Its default sat below every level on offer, so naming one RAISED the
|
|
167
|
-
// thinking — an effort is a request, never a cap. Nearly all of the extra
|
|
168
|
-
// time was spent before the first token arrived, so it does not show up as
|
|
169
|
-
// slow streaming; it shows up as a call that looks hung.
|
|
170
|
-
return { reasoning: { effort: effort === "max" ? "xhigh" : (level ?? "none") } };
|
|
200
|
+
// `max` rides verbatim here — the enum clamp below is DeepSeek/OpenAI's
|
|
201
|
+
// shape (DeepSeek auto-bumps past its own top, OpenAI's top IS high);
|
|
202
|
+
// OpenRouter takes `max` as named (measured live 2026-09-14).
|
|
203
|
+
return effort === "none" ? {} : { reasoning: { effort } };
|
|
171
204
|
case "openai":
|
|
172
205
|
return { reasoning_effort: level ?? "none" };
|
|
173
206
|
case "off":
|
|
@@ -316,10 +349,11 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
316
349
|
opts: StreamOptions = {},
|
|
317
350
|
): AsyncIterable<ProviderChunk> {
|
|
318
351
|
const effort = opts.effort ?? config.effort;
|
|
352
|
+
const model = opts.model ?? config.model;
|
|
319
353
|
|
|
320
354
|
const body = toOpenAIMessages(messages);
|
|
321
355
|
const request: Record<string, unknown> = {
|
|
322
|
-
model
|
|
356
|
+
model,
|
|
323
357
|
messages: body,
|
|
324
358
|
stream: true,
|
|
325
359
|
// Without this the usage record never arrives and every call costs
|
|
@@ -398,8 +432,17 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
398
432
|
body.push({ role: "system", content: schemaPrompt(opts.json.schema) });
|
|
399
433
|
}
|
|
400
434
|
}
|
|
401
|
-
|
|
402
|
-
|
|
435
|
+
const pinOrder = config.providerOrder?.length
|
|
436
|
+
? config.providerOrder
|
|
437
|
+
: config.pinHost !== false
|
|
438
|
+
? (() => {
|
|
439
|
+
const pinned = openRouterHostFor(baseUrl, model);
|
|
440
|
+
return pinned ? [pinned] : undefined;
|
|
441
|
+
})()
|
|
442
|
+
: undefined;
|
|
443
|
+
|
|
444
|
+
if (pinOrder?.length) {
|
|
445
|
+
request.provider = { order: pinOrder, allow_fallbacks: true };
|
|
403
446
|
}
|
|
404
447
|
|
|
405
448
|
const defaultPath = /\/v\d+[^/]*$/i.test(baseUrl.replace(/\/+$/, ""))
|
package/src/retry.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
//
|
|
14
14
|
// 3. Honour the provider's own number. When it says `Retry-After: 30`, a
|
|
15
15
|
// one-second backoff is three wasted attempts before the same wait.
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
classify,
|
|
18
|
+
isBackupEligible,
|
|
19
|
+
isRetryable,
|
|
20
|
+
parseRetryAfterMs,
|
|
21
|
+
ProviderError,
|
|
22
|
+
} from "./errors.ts";
|
|
17
23
|
|
|
18
24
|
export interface RetryOptions {
|
|
19
25
|
/** Total attempts including the first. Default 3. */
|
|
@@ -73,9 +79,10 @@ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
73
79
|
function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | null {
|
|
74
80
|
const asked = parseRetryAfterMs(err);
|
|
75
81
|
const cap = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
76
|
-
const resetWait =
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
const resetWait =
|
|
83
|
+
err instanceof ProviderError && err.resetAtMs !== undefined
|
|
84
|
+
? Math.max(0, err.resetAtMs - Date.now())
|
|
85
|
+
: undefined;
|
|
79
86
|
if (asked !== undefined || resetWait !== undefined) {
|
|
80
87
|
const wait = Math.max(asked ?? 0, resetWait ?? 0);
|
|
81
88
|
return wait > cap ? null : wait;
|
|
@@ -83,7 +90,7 @@ function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | n
|
|
|
83
90
|
return backoffMs(attempt, opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, cap);
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
const defaultShouldRetry = (err: unknown): boolean =>
|
|
93
|
+
const defaultShouldRetry = (err: unknown, _attempt: number): boolean => isRetryable(err);
|
|
87
94
|
|
|
88
95
|
/**
|
|
89
96
|
* Run `fn`, retrying transient failures with backoff. For one-shot calls —
|