@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.
Files changed (51) hide show
  1. package/dist/errors.d.ts +30 -0
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js +94 -0
  4. package/dist/errors.js.map +1 -1
  5. package/dist/fallback.d.ts.map +1 -1
  6. package/dist/fallback.js +5 -1
  7. package/dist/fallback.js.map +1 -1
  8. package/dist/presets.d.ts +46 -0
  9. package/dist/presets.d.ts.map +1 -1
  10. package/dist/presets.js +56 -2
  11. package/dist/presets.js.map +1 -1
  12. package/dist/providers/anthropic.js +2 -2
  13. package/dist/providers/anthropic.js.map +1 -1
  14. package/dist/providers/gemini.d.ts.map +1 -1
  15. package/dist/providers/gemini.js +28 -10
  16. package/dist/providers/gemini.js.map +1 -1
  17. package/dist/providers/openai.d.ts +23 -18
  18. package/dist/providers/openai.d.ts.map +1 -1
  19. package/dist/providers/openai.js +83 -45
  20. package/dist/providers/openai.js.map +1 -1
  21. package/dist/retry.d.ts.map +1 -1
  22. package/dist/retry.js +2 -2
  23. package/dist/retry.js.map +1 -1
  24. package/dist/schema.d.ts +20 -0
  25. package/dist/schema.d.ts.map +1 -1
  26. package/dist/schema.js +112 -0
  27. package/dist/schema.js.map +1 -1
  28. package/dist/tool-args.d.ts +13 -1
  29. package/dist/tool-args.d.ts.map +1 -1
  30. package/dist/tool-args.js +50 -1
  31. package/dist/tool-args.js.map +1 -1
  32. package/dist/transport.d.ts.map +1 -1
  33. package/dist/transport.js +3 -0
  34. package/dist/transport.js.map +1 -1
  35. package/dist/usage.d.ts +6 -0
  36. package/dist/usage.d.ts.map +1 -1
  37. package/dist/usage.js +20 -0
  38. package/dist/usage.js.map +1 -1
  39. package/package.json +2 -1
  40. package/src/capability.ts +0 -1
  41. package/src/errors.ts +109 -0
  42. package/src/fallback.ts +50 -22
  43. package/src/presets.ts +56 -2
  44. package/src/providers/anthropic.ts +2 -2
  45. package/src/providers/gemini.ts +30 -10
  46. package/src/providers/openai.ts +88 -45
  47. package/src/retry.ts +12 -5
  48. package/src/schema.ts +127 -0
  49. package/src/tool-args.ts +48 -1
  50. package/src/transport.ts +4 -0
  51. 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>>> = Object.freeze({
10
- rate: 60_000,
11
- quota: 3_600_000,
12
- timeout: 30_000,
13
- network: 30_000,
14
- overload: 30_000,
15
- auth: 3_600_000,
16
- entitlement: 3_600_000,
17
- model: 3_600_000,
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("fallback", "rate", "All configured providers are unavailable. Retry after the cooldown.", {
93
- resetAtMs: retryAtMs,
94
- retryAfterMs: Math.max(0, retryAtMs - now),
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(candidates: readonly T[], private readonly options: FallbackOptions<T> = {}) {
111
- if (candidates.length === 0) throw new Error("providerkit: supply a primary before its backups");
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("providerkit: fallback cooldowns must be finite, non-negative milliseconds");
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<{ candidate: T; retryAtMs: number; probing: boolean; kind?: ErrorKind; window?: RateLimitWindow }> {
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>(attempt: (candidate: T, signal: AbortSignal) => Promise<R>, signal?: AbortSignal): Promise<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)) return result;
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(...this.status().map((entry) => Math.max(this.now(), entry.retryAtMs)));
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, { ...rest, model, signal })) {
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: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash", "x-ai/grok-4.6", "openai/gpt-5.6-luna-pro"],
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: ["accounts/fireworks/models/deepseek-v4p1-flash", "accounts/fireworks/models/glm-5p3-flash"],
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") {
@@ -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
- if (effort) generationConfig.thinkingConfig = { thinkingLevel: THINKING_LEVEL[effort] };
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.
@@ -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, a model that must think refuses BOTH off switches.**
100
- * `reasoning.enabled: false` is refused by GLM 5.3 Flash with
101
- * `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`,
102
- * which cost one app its onboarding read, and is why nothing here ever emits
103
- * that field. That was then read as "OpenRouter has no off switch" and `none`
104
- * was floored to `low` on the strength of it — wrong, because `none` is a
105
- * member of OpenRouter's own effort enum and the floor bought a thinking pass
106
- * on every turn that asked for none. But the correction over-reached: `none`
107
- * is NOT universally safe either. Re-measured 2026-09-09, the same model
108
- * answers the same 400 to `effort: "none"`. An unsupported level is mapped to
109
- * the nearest only where the model CAN vary its effort; where thinking is
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 floor is a constraint and
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. `none` is
126
- * the caller touching it, and the two must not produce the same request.
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
- // Its own enum runs xhigh > high > medium > low > minimal > none, so `max`
147
- // has a real tier here and clamping it to `high` throws the top one away —
148
- // 0.95 of the budget against 0.8.
149
- //
150
- // CAUTION, measured 2026-09-09 against z-ai/glm-5.3-flash: the claim that
151
- // "OpenRouter maps an unsupported level to its nearest rather than
152
- // refusing" does NOT hold for `none`. That model answers
153
- // `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`
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
- // Same measurement, same model, one structured-output call three ways:
161
- //
162
- // no reasoning field 4,1s · 1 reasoning token
163
- // effort "minimal" 46,6s · 474 reasoning tokens
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: opts.model ?? config.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
- if (config.providerOrder?.length) {
402
- request.provider = { order: config.providerOrder, allow_fallbacks: true };
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 { classify, isBackupEligible, isTransient, parseRetryAfterMs, ProviderError } from "./errors.ts";
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 = err instanceof ProviderError && err.resetAtMs !== undefined
77
- ? Math.max(0, err.resetAtMs - Date.now())
78
- : undefined;
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 => isTransient(classify(err));
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 —