@providerkit/core 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/capability.d.ts +41 -0
  2. package/dist/capability.d.ts.map +1 -1
  3. package/dist/capability.js +94 -0
  4. package/dist/capability.js.map +1 -1
  5. package/dist/errors.d.ts +30 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +171 -13
  8. package/dist/errors.js.map +1 -1
  9. package/dist/presets.d.ts +173 -28
  10. package/dist/presets.d.ts.map +1 -1
  11. package/dist/presets.js +172 -26
  12. package/dist/presets.js.map +1 -1
  13. package/dist/providers/anthropic.js +2 -2
  14. package/dist/providers/anthropic.js.map +1 -1
  15. package/dist/providers/factory.d.ts.map +1 -1
  16. package/dist/providers/factory.js +1 -0
  17. package/dist/providers/factory.js.map +1 -1
  18. package/dist/providers/gemini.d.ts.map +1 -1
  19. package/dist/providers/gemini.js +28 -10
  20. package/dist/providers/gemini.js.map +1 -1
  21. package/dist/providers/openai.d.ts +6 -0
  22. package/dist/providers/openai.d.ts.map +1 -1
  23. package/dist/providers/openai.js +58 -4
  24. package/dist/providers/openai.js.map +1 -1
  25. package/dist/rate-limit.d.ts +2 -0
  26. package/dist/rate-limit.d.ts.map +1 -1
  27. package/dist/rate-limit.js +91 -34
  28. package/dist/rate-limit.js.map +1 -1
  29. package/dist/retry.d.ts.map +1 -1
  30. package/dist/retry.js +2 -2
  31. package/dist/retry.js.map +1 -1
  32. package/dist/schema.d.ts +20 -0
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/schema.js +112 -0
  35. package/dist/schema.js.map +1 -1
  36. package/dist/tool-args.d.ts +13 -1
  37. package/dist/tool-args.d.ts.map +1 -1
  38. package/dist/tool-args.js +50 -1
  39. package/dist/tool-args.js.map +1 -1
  40. package/dist/transport.d.ts.map +1 -1
  41. package/dist/transport.js +3 -0
  42. package/dist/transport.js.map +1 -1
  43. package/dist/usage.d.ts +6 -0
  44. package/dist/usage.d.ts.map +1 -1
  45. package/dist/usage.js +20 -0
  46. package/dist/usage.js.map +1 -1
  47. package/package.json +3 -1
  48. package/src/capability.ts +157 -0
  49. package/src/errors.ts +181 -13
  50. package/src/presets.ts +177 -28
  51. package/src/providers/anthropic.ts +2 -2
  52. package/src/providers/factory.ts +1 -0
  53. package/src/providers/gemini.ts +30 -10
  54. package/src/providers/openai.ts +64 -4
  55. package/src/rate-limit.ts +98 -36
  56. package/src/retry.ts +8 -2
  57. package/src/schema.ts +127 -0
  58. package/src/tool-args.ts +48 -1
  59. package/src/transport.ts +4 -0
  60. package/src/usage.ts +21 -0
package/src/presets.ts CHANGED
@@ -37,9 +37,11 @@ export type PresetAuth = "key" | "bearer" | "oauth";
37
37
  export interface ProviderPreset {
38
38
  /** Adapter wire format. */
39
39
  shape: PresetShape;
40
- /** API root — the adapter appends its own path (/v1/chat/completions,
41
- * /v1/messages, …), so this must NOT carry the version. */
40
+ /** API root — if it ends with /v1, /v4 or another version segment, the
41
+ * adapter automatically routes to /chat/completions instead of /v1/chat/completions. */
42
42
  baseUrl: string;
43
+ /** Explicit path override when a gateway has custom routing. */
44
+ path?: string;
43
45
  auth: PresetAuth;
44
46
  /** Fallback when the caller passes no model. */
45
47
  defaultModel?: string;
@@ -65,15 +67,15 @@ export const PROVIDER_PRESETS = {
65
67
  shape: "anthropic",
66
68
  baseUrl: "https://api.anthropic.com",
67
69
  auth: "key",
68
- defaultModel: "claude-sonnet-4-6",
69
- models: ["claude-sonnet-4-6", "claude-opus-5", "claude-fable-5-1"],
70
+ defaultModel: "claude-sonnet-5",
71
+ models: ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5"],
70
72
  },
71
73
  openai: {
72
74
  shape: "openai",
73
75
  baseUrl: "https://api.openai.com",
74
76
  auth: "key",
75
77
  defaultModel: "gpt-5.6-sol",
76
- models: ["gpt-5.6-sol", "gpt-5-pro", "gpt-5-nano"],
78
+ models: ["gpt-5.6-sol", "gpt-5.5-pro", "gpt-5.4-mini"],
77
79
  },
78
80
  google: {
79
81
  // Native Generative Language REST — the adapter authenticates with
@@ -81,8 +83,8 @@ export const PROVIDER_PRESETS = {
81
83
  shape: "gemini",
82
84
  baseUrl: "https://generativelanguage.googleapis.com",
83
85
  auth: "key",
84
- defaultModel: "gemini-3.1-pro",
85
- models: ["gemini-3.1-pro", "gemini-2.5-flash"],
86
+ defaultModel: "gemini-3.1-pro-preview",
87
+ models: ["gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-flash-latest"],
86
88
  },
87
89
  /** Gemini behind its OpenAI-compatible route — the drop-in for codebases
88
90
  * that only speak the openai shape. */
@@ -90,15 +92,17 @@ export const PROVIDER_PRESETS = {
90
92
  shape: "openai",
91
93
  baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
92
94
  auth: "key",
93
- defaultModel: "gemini-3.1-pro",
94
- models: ["gemini-3.1-pro", "gemini-2.5-flash"],
95
+ defaultModel: "gemini-3.1-pro-preview",
96
+ models: ["gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-flash-latest"],
95
97
  },
96
98
  deepseek: {
97
99
  shape: "openai",
98
100
  baseUrl: "https://api.deepseek.com",
99
101
  auth: "key",
100
- defaultModel: "deepseek-v4-flash",
101
- models: ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-flash"],
102
+ // Measured 2026-09-13 via /models on live key: first-party serves only
103
+ // deepseek-flash and deepseek-v4-pro. v4.1-flash is OpenRouter/Baseten.
104
+ defaultModel: "deepseek-flash",
105
+ models: ["deepseek-flash", "deepseek-v4-pro"],
102
106
  // Text-only API — an image part in the body is a hard 400 (measured).
103
107
  textOnly: true,
104
108
  },
@@ -107,7 +111,7 @@ export const PROVIDER_PRESETS = {
107
111
  baseUrl: "https://openrouter.ai/api",
108
112
  auth: "key",
109
113
  defaultModel: "deepseek/deepseek-v4.1-flash",
110
- models: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash", "qwen/qwen3.7-max"],
114
+ models: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash", "x-ai/grok-4.6", "openai/gpt-5.6-luna-pro"],
111
115
  },
112
116
  groq: {
113
117
  shape: "openai",
@@ -121,25 +125,28 @@ export const PROVIDER_PRESETS = {
121
125
  baseUrl: "https://api.mistral.ai/v1",
122
126
  auth: "key",
123
127
  defaultModel: "devstral-2512",
124
- models: ["devstral-2512", "magistral-small", "mistral-small-2506"],
128
+ models: ["devstral-2512", "mistral-large-latest", "magistral-small"],
125
129
  },
126
130
  xai: {
127
131
  shape: "openai",
128
132
  baseUrl: "https://api.x.ai/v1",
129
133
  auth: "key",
130
- defaultModel: "grok-4.5",
131
- models: ["grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"],
134
+ defaultModel: "grok-4.6",
135
+ models: ["grok-4.6", "grok-4.5", "grok-4.3"],
132
136
  },
133
137
  together: {
134
138
  shape: "openai",
135
139
  baseUrl: "https://api.together.xyz/v1",
136
140
  auth: "key",
137
- defaultModel: "deepseek-ai/DeepSeek-V3",
141
+ defaultModel: "MiniMaxAI/MiniMax-M3",
142
+ models: ["MiniMaxAI/MiniMax-M3", "Qwen/Qwen3-Coder-Next-FP8"],
138
143
  },
139
144
  fireworks: {
140
145
  shape: "openai",
141
146
  baseUrl: "https://api.fireworks.ai/inference/v1",
142
147
  auth: "key",
148
+ defaultModel: "accounts/fireworks/models/deepseek-v4p1-flash",
149
+ models: ["accounts/fireworks/models/deepseek-v4p1-flash", "accounts/fireworks/models/glm-5p3-flash"],
143
150
  },
144
151
  cerebras: {
145
152
  shape: "openai",
@@ -158,16 +165,119 @@ export const PROVIDER_PRESETS = {
158
165
  zhipu: {
159
166
  shape: "openai",
160
167
  baseUrl: "https://api.z.ai/api/paas/v4",
168
+ path: "/chat/completions",
161
169
  auth: "key",
162
170
  defaultModel: "glm-5.3-flash",
163
- models: ["glm-5.3-flash", "glm-5.2", "glm-4.7"],
171
+ models: ["glm-5.3-flash", "glm-5.3", "glm-5.2"],
164
172
  },
165
173
  minimax: {
166
174
  shape: "anthropic",
167
175
  baseUrl: "https://api.minimax.io/anthropic",
168
176
  auth: "key",
169
- defaultModel: "MiniMax-M2.5",
170
- models: ["MiniMax-M3", "MiniMax-M2.5", "MiniMax-M2.1"],
177
+ defaultModel: "MiniMax-M3",
178
+ models: ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
179
+ },
180
+ "minimax-openai": {
181
+ shape: "openai",
182
+ baseUrl: "https://api.minimax.io/v1",
183
+ auth: "key",
184
+ defaultModel: "MiniMax-M3",
185
+ models: ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
186
+ },
187
+
188
+ // ── Popular gateways & cloud providers ──────────────────────────────────
189
+ nvidia: {
190
+ shape: "openai",
191
+ baseUrl: "https://integrate.api.nvidia.com/v1",
192
+ auth: "key",
193
+ defaultModel: "deepseek-ai/deepseek-v4-flash",
194
+ models: ["deepseek-ai/deepseek-v4-flash", "deepseek-ai/deepseek-v4-pro"],
195
+ },
196
+ perplexity: {
197
+ shape: "openai",
198
+ baseUrl: "https://api.perplexity.ai",
199
+ auth: "key",
200
+ defaultModel: "sonar",
201
+ models: ["sonar", "sonar-pro", "sonar-reasoning-pro", "sonar-deep-research"],
202
+ },
203
+ deepinfra: {
204
+ shape: "openai",
205
+ baseUrl: "https://api.deepinfra.com/v1/openai",
206
+ path: "/chat/completions",
207
+ auth: "key",
208
+ defaultModel: "MiniMaxAI/MiniMax-M3",
209
+ models: ["MiniMaxAI/MiniMax-M3", "ByteDance/Seed-2.0-pro"],
210
+ },
211
+ nebius: {
212
+ shape: "openai",
213
+ baseUrl: "https://api.tokenfactory.nebius.com/v1",
214
+ auth: "key",
215
+ defaultModel: "Qwen/Qwen3.5-397B-A17B",
216
+ models: ["Qwen/Qwen3.5-397B-A17B", "deepseek-ai/DeepSeek-V4-Flash-0731"],
217
+ },
218
+ novita: {
219
+ shape: "openai",
220
+ baseUrl: "https://api.novita.ai/openai",
221
+ path: "/v1/chat/completions",
222
+ auth: "key",
223
+ defaultModel: "baichuan/baichuan-m2-32b",
224
+ models: ["baichuan/baichuan-m2-32b", "baidu/ernie-4.5-300b-a47b-paddle"],
225
+ },
226
+ chutes: {
227
+ shape: "openai",
228
+ baseUrl: "https://llm.chutes.ai/v1",
229
+ auth: "key",
230
+ defaultModel: "Qwen/Qwen3.8-27B-TEE",
231
+ models: ["Qwen/Qwen3.8-27B-TEE", "Qwen/Qwen3.5-397B-A17B-TEE"],
232
+ },
233
+ siliconflow: {
234
+ shape: "openai",
235
+ baseUrl: "https://api.siliconflow.com/v1",
236
+ auth: "key",
237
+ defaultModel: "Qwen/Qwen3-235B-A22B-Thinking-2507",
238
+ models: ["Qwen/Qwen3-235B-A22B-Thinking-2507", "MiniMaxAI/MiniMax-M2.5"],
239
+ },
240
+ volcengine: {
241
+ shape: "openai",
242
+ baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
243
+ auth: "key",
244
+ defaultModel: "doubao-seed-1-8-251228",
245
+ models: ["doubao-seed-1-8-251228", "deepseek-v4-flash-ga-260731"],
246
+ },
247
+ stepfun: {
248
+ shape: "openai",
249
+ baseUrl: "https://api.stepfun.com/v1",
250
+ auth: "key",
251
+ defaultModel: "step-3.7-flash",
252
+ models: ["step-3.7-flash", "step-3.5-flash"],
253
+ },
254
+ digitalocean: {
255
+ shape: "openai",
256
+ baseUrl: "https://inference.do-ai.run/v1",
257
+ auth: "key",
258
+ defaultModel: "anthropic-claude-3.7-sonnet",
259
+ models: ["anthropic-claude-3.7-sonnet", "alibaba-qwen3-32b"],
260
+ },
261
+ crusoe: {
262
+ shape: "openai",
263
+ baseUrl: "https://api.inference.crusoecloud.com/v1",
264
+ auth: "key",
265
+ defaultModel: "Qwen/Qwen3-235B-A22B-Instruct-2507",
266
+ models: ["Qwen/Qwen3-235B-A22B-Instruct-2507", "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B"],
267
+ },
268
+ baseten: {
269
+ shape: "openai",
270
+ baseUrl: "https://inference.baseten.co/v1",
271
+ auth: "key",
272
+ defaultModel: "deepseek-ai/DeepSeek-V4.1-Flash",
273
+ models: ["deepseek-ai/DeepSeek-V4.1-Flash", "MiniMaxAI/MiniMax-M2.5"],
274
+ },
275
+ huggingface: {
276
+ shape: "openai",
277
+ baseUrl: "https://router.huggingface.co/v1",
278
+ auth: "key",
279
+ defaultModel: "MiniMaxAI/MiniMax-M3",
280
+ models: ["MiniMaxAI/MiniMax-M3", "Qwen/Qwen2.5-Coder-32B-Instruct"],
171
281
  },
172
282
 
173
283
  // ── Coding plans — Anthropic wire + Bearer (measured family trait) ─────
@@ -176,25 +286,40 @@ export const PROVIDER_PRESETS = {
176
286
  baseUrl: "https://api.z.ai/api/anthropic",
177
287
  auth: "bearer",
178
288
  defaultModel: "glm-5.3-flash",
179
- models: ["glm-5.3-flash", "glm-5.3-highspeed", "glm-5.2", "glm-5-turbo"],
289
+ models: ["glm-5.3-flash", "glm-5.3-highspeed", "glm-5.3", "glm-5.2"],
180
290
  // Model ids are BARE here — the gateway-prefixed spelling
181
291
  // (`z-ai/glm-5.3-flash`) answers 400 [1211] Unknown Model.
182
292
  explicitNone: true,
183
293
  },
294
+ "zai-openai": {
295
+ shape: "openai",
296
+ baseUrl: "https://api.z.ai/api/paas/v4",
297
+ path: "/chat/completions",
298
+ auth: "key",
299
+ defaultModel: "glm-5.3-flash",
300
+ models: ["glm-5.3-flash", "glm-5.3", "glm-5.2"],
301
+ },
184
302
  kimi: {
185
303
  shape: "anthropic",
186
304
  baseUrl: "https://api.kimi.ai/coding",
187
305
  auth: "bearer",
188
306
  defaultModel: "kimi-for-coding",
189
- models: ["kimi-for-coding", "kimi-for-coding-highspeed", "k3"],
307
+ models: ["kimi-for-coding", "kimi-for-coding-highspeed", "k3", "k3-256k"],
308
+ },
309
+ "kimi-openai": {
310
+ shape: "openai",
311
+ baseUrl: "https://api.moonshot.ai/v1",
312
+ auth: "key",
313
+ defaultModel: "kimi-k2.7-code",
314
+ models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k3"],
190
315
  },
191
316
  /** Alibaba's dashscope coding plan — OpenAI-compatible per its registry. */
192
317
  "alibaba-coding-plan": {
193
318
  shape: "openai",
194
319
  baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
195
320
  auth: "bearer",
196
- defaultModel: "qwen3.7-max",
197
- models: ["qwen3.7-max", "qwen3-coder-next", "glm-4.7"],
321
+ defaultModel: "qwen3.5-plus",
322
+ models: ["qwen3.5-plus", "qwen3-coder-next", "glm-5"],
198
323
  },
199
324
  /** QwenCloud's token plan — Anthropic wire (measured by tabrunner). */
200
325
  qwen: {
@@ -202,7 +327,31 @@ export const PROVIDER_PRESETS = {
202
327
  baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
203
328
  auth: "bearer",
204
329
  defaultModel: "qwen3.8-max",
205
- models: ["qwen3.8-max", "qwen3.6-flash"],
330
+ models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.6-flash"],
331
+ },
332
+ /** QwenCloud's token plan — OpenAI-compatible endpoint. */
333
+ "qwen-token-plan-openai": {
334
+ shape: "openai",
335
+ baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
336
+ auth: "bearer",
337
+ defaultModel: "qwen3.8-max",
338
+ models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.6-flash"],
339
+ },
340
+ /** Alibaba DashScope Platform API — standard pay-as-you-go API key. */
341
+ alibaba: {
342
+ shape: "openai",
343
+ baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
344
+ auth: "bearer",
345
+ defaultModel: "qwen-max",
346
+ models: ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-flash"],
347
+ },
348
+ /** Alias for alibaba (DashScope platform API). */
349
+ "qwen-api": {
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"],
206
355
  },
207
356
 
208
357
  // ── Local runtimes ──────────────────────────────────────────────────────
@@ -227,8 +376,8 @@ export const PROVIDER_PRESETS = {
227
376
  shape: "anthropic",
228
377
  baseUrl: "https://api.anthropic.com",
229
378
  auth: "oauth",
230
- defaultModel: "claude-sonnet-4-6",
231
- models: ["claude-sonnet-4-6", "claude-opus-5"],
379
+ defaultModel: "claude-sonnet-5",
380
+ models: ["claude-sonnet-5", "claude-opus-5", "claude-fable-5"],
232
381
  // The beta flag is what switches the API into OAuth-token mode (measured
233
382
  // by tabrunner's Claude plan sign-in).
234
383
  headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" },
@@ -240,7 +389,7 @@ export const PROVIDER_PRESETS = {
240
389
  baseUrl: "https://chatgpt.com/backend-api/codex",
241
390
  auth: "oauth",
242
391
  defaultModel: "gpt-5.3-codex",
243
- models: ["gpt-5.3-codex", "gpt-5.5", "gpt-5.4-mini"],
392
+ models: ["gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.5", "gpt-5.4-mini"],
244
393
  },
245
394
  /** Kimi's coding endpoint reached with a subscription token instead of a
246
395
  * key — Kimi bills the two separately. */
@@ -255,7 +404,7 @@ export const PROVIDER_PRESETS = {
255
404
  baseUrl: "https://api.githubcopilot.com",
256
405
  auth: "oauth",
257
406
  defaultModel: "gpt-5.6-sol",
258
- models: ["gpt-5.6-sol", "claude-opus-5", "claude-opus-4.8", "kimi-k2.7-code"],
407
+ models: ["gpt-5.6-sol", "claude-opus-5", "claude-sonnet-5", "gpt-6-astra"],
259
408
  },
260
409
  } as const satisfies Record<string, ProviderPreset>;
261
410
 
@@ -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") {
@@ -91,6 +91,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
91
91
  effort: config.effort,
92
92
  maxTokens: config.maxTokens,
93
93
  baseUrl: preset.baseUrl,
94
+ ...(preset.path ? { path: preset.path } : {}),
94
95
  headers,
95
96
  ...(config.providerOrder ? { providerOrder: config.providerOrder } : {}),
96
97
  fetchImpl: config.fetchImpl,
@@ -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";
@@ -316,10 +362,11 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
316
362
  opts: StreamOptions = {},
317
363
  ): AsyncIterable<ProviderChunk> {
318
364
  const effort = opts.effort ?? config.effort;
365
+ const model = opts.model ?? config.model;
319
366
 
320
367
  const body = toOpenAIMessages(messages);
321
368
  const request: Record<string, unknown> = {
322
- model: opts.model ?? config.model,
369
+ model,
323
370
  messages: body,
324
371
  stream: true,
325
372
  // Without this the usage record never arrives and every call costs
@@ -398,12 +445,25 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
398
445
  body.push({ role: "system", content: schemaPrompt(opts.json.schema) });
399
446
  }
400
447
  }
401
- if (config.providerOrder?.length) {
402
- request.provider = { order: config.providerOrder, allow_fallbacks: true };
448
+ const pinOrder = config.providerOrder?.length
449
+ ? config.providerOrder
450
+ : config.pinHost !== false
451
+ ? (() => {
452
+ const pinned = openRouterHostFor(baseUrl, model);
453
+ return pinned ? [pinned] : undefined;
454
+ })()
455
+ : undefined;
456
+
457
+ if (pinOrder?.length) {
458
+ request.provider = { order: pinOrder, allow_fallbacks: true };
403
459
  }
404
460
 
461
+ const defaultPath = /\/v\d+[^/]*$/i.test(baseUrl.replace(/\/+$/, ""))
462
+ ? "/chat/completions"
463
+ : "/v1/chat/completions";
464
+
405
465
  for await (const data of streamSse({
406
- url: apiUrl(baseUrl, config.path ?? "/v1/chat/completions"),
466
+ url: apiUrl(baseUrl, config.path ?? defaultPath),
407
467
  headers: { authorization: `Bearer ${config.apiKey}`, ...config.headers },
408
468
  body: request,
409
469
  provider: id,