@providerkit/core 0.23.0 → 0.25.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 (96) hide show
  1. package/README.md +43 -7
  2. package/dist/auth/device.d.ts +3 -2
  3. package/dist/auth/device.d.ts.map +1 -1
  4. package/dist/auth/device.js +5 -2
  5. package/dist/auth/device.js.map +1 -1
  6. package/dist/auth/flows.d.ts +5 -1
  7. package/dist/auth/flows.d.ts.map +1 -1
  8. package/dist/auth/flows.js +237 -0
  9. package/dist/auth/flows.js.map +1 -1
  10. package/dist/auth/oauth.d.ts +4 -2
  11. package/dist/auth/oauth.d.ts.map +1 -1
  12. package/dist/auth/oauth.js +4 -3
  13. package/dist/auth/oauth.js.map +1 -1
  14. package/dist/auth/with-token.d.ts +4 -1
  15. package/dist/auth/with-token.d.ts.map +1 -1
  16. package/dist/auth/with-token.js +9 -4
  17. package/dist/auth/with-token.js.map +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +3 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/gate.d.ts +29 -0
  22. package/dist/gate.d.ts.map +1 -0
  23. package/dist/gate.js +96 -0
  24. package/dist/gate.js.map +1 -0
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/key-pool.d.ts +11 -0
  30. package/dist/key-pool.d.ts.map +1 -1
  31. package/dist/key-pool.js +14 -0
  32. package/dist/key-pool.js.map +1 -1
  33. package/dist/presets.d.ts +53 -0
  34. package/dist/presets.d.ts.map +1 -1
  35. package/dist/presets.js +100 -0
  36. package/dist/presets.js.map +1 -1
  37. package/dist/providers/anthropic.d.ts +7 -0
  38. package/dist/providers/anthropic.d.ts.map +1 -1
  39. package/dist/providers/anthropic.js +3 -1
  40. package/dist/providers/anthropic.js.map +1 -1
  41. package/dist/providers/factory.d.ts +36 -0
  42. package/dist/providers/factory.d.ts.map +1 -1
  43. package/dist/providers/factory.js +208 -22
  44. package/dist/providers/factory.js.map +1 -1
  45. package/dist/providers/gemini.d.ts +17 -5
  46. package/dist/providers/gemini.d.ts.map +1 -1
  47. package/dist/providers/gemini.js +241 -119
  48. package/dist/providers/gemini.js.map +1 -1
  49. package/dist/providers/openai.d.ts +7 -0
  50. package/dist/providers/openai.d.ts.map +1 -1
  51. package/dist/providers/openai.js +7 -4
  52. package/dist/providers/openai.js.map +1 -1
  53. package/dist/providers/responses.d.ts +11 -0
  54. package/dist/providers/responses.d.ts.map +1 -1
  55. package/dist/providers/responses.js +3 -1
  56. package/dist/providers/responses.js.map +1 -1
  57. package/dist/registry.d.ts +105 -0
  58. package/dist/registry.d.ts.map +1 -0
  59. package/dist/registry.js +291 -0
  60. package/dist/registry.js.map +1 -0
  61. package/dist/schema.d.ts +27 -9
  62. package/dist/schema.d.ts.map +1 -1
  63. package/dist/schema.js +197 -62
  64. package/dist/schema.js.map +1 -1
  65. package/dist/tools.d.ts +8 -0
  66. package/dist/tools.d.ts.map +1 -1
  67. package/dist/tools.js +2 -2
  68. package/dist/tools.js.map +1 -1
  69. package/dist/turn.d.ts +55 -0
  70. package/dist/turn.d.ts.map +1 -0
  71. package/dist/turn.js +125 -0
  72. package/dist/turn.js.map +1 -0
  73. package/dist/types.d.ts +8 -22
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +0 -45
  76. package/dist/types.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/auth/device.ts +8 -4
  79. package/src/auth/flows.ts +288 -1
  80. package/src/auth/oauth.ts +8 -3
  81. package/src/auth/with-token.ts +9 -4
  82. package/src/errors.ts +3 -0
  83. package/src/gate.ts +152 -0
  84. package/src/index.ts +3 -0
  85. package/src/key-pool.ts +15 -0
  86. package/src/presets.ts +100 -0
  87. package/src/providers/anthropic.ts +10 -1
  88. package/src/providers/factory.ts +288 -34
  89. package/src/providers/gemini.ts +273 -124
  90. package/src/providers/openai.ts +15 -4
  91. package/src/providers/responses.ts +14 -1
  92. package/src/registry.ts +420 -0
  93. package/src/schema.ts +200 -72
  94. package/src/tools.ts +10 -2
  95. package/src/turn.ts +166 -0
  96. package/src/types.ts +8 -62
@@ -23,7 +23,15 @@ import {
23
23
  type ProviderPreset,
24
24
  type ProviderPresetId,
25
25
  } from "../presets.ts";
26
- import type { Effort, Provider, ServiceTier } from "../types.ts";
26
+ import type {
27
+ ChatMessage,
28
+ Effort,
29
+ Provider,
30
+ ProviderChunk,
31
+ ServiceTier,
32
+ StreamOptions,
33
+ ToolDefinition,
34
+ } from "../types.ts";
27
35
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
28
36
 
29
37
  export interface PresetProviderConfig extends ProviderFallbackConfig {
@@ -46,6 +54,19 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
46
54
  /** Per-request headers (request ids, `ChatGPT-Account-Id`) on top of the
47
55
  * preset's static protocol headers. */
48
56
  headers?: Record<string, string>;
57
+ /**
58
+ * The session id for a call without `StreamOptions.sessionId`, on a preset
59
+ * that sends one (`opencode-go`, `opencode-zen`, `chatgpt`). Absent, the provider mints its
60
+ * own when it is built. Pass one when you build the provider per attempt
61
+ * (`withKeyPool`, `withToken`): each build would mint a new id, and a gateway
62
+ * that pins a session to one upstream host then treats every call as a new
63
+ * session, re-routes it and misses its prompt cache. Every model in a chain
64
+ * sends the same one. A preset without a session header has nowhere to put
65
+ * it and ignores it.
66
+ */
67
+ instanceSessionId?: string;
68
+ /** Google Antigravity (Cloud Code Assist) project id for the request envelope. */
69
+ projectId?: string;
49
70
  /**
50
71
  * App attribution for OpenRouter's rankings. The site URL rides as
51
72
  * `HTTP-Referer`, the app name as `X-Title`. Public, not secret. Sent on
@@ -69,15 +90,15 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
69
90
  fetchImpl?: typeof fetch;
70
91
  }
71
92
 
93
+ /** What one adapter for one model takes: a `PresetProviderConfig` without the
94
+ * chain (`models`) and without the layers that go around an adapter. */
95
+ export type PresetAdapterConfig = Omit<
96
+ PresetProviderConfig,
97
+ "models" | keyof ProviderFallbackConfig
98
+ >;
99
+
72
100
  export function createPresetProvider(id: ProviderPresetId, config: PresetProviderConfig): Provider {
73
- // The table is closed (`satisfies`), but a caller may hold an id as a plain
74
- // string — validate rather than index into undefined.
75
- if (!(id in PROVIDER_PRESETS)) {
76
- throw new Error(
77
- `[providerkit] Unknown provider preset "${id}". Valid ids: ${PRESET_IDS.join(", ")}`,
78
- );
79
- }
80
- const preset: ProviderPreset = PROVIDER_PRESETS[id];
101
+ const preset = presetFor(id);
81
102
  const chain = config.models?.length ? config.models : config.model ? undefined : preset.rotation;
82
103
  if (chain && chain.length > 1) {
83
104
  const { models: _models, fallbacks = [], ...single } = config;
@@ -90,7 +111,27 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
90
111
  ],
91
112
  });
92
113
  }
93
- const model = chain?.[0] ?? config.model ?? preset.defaultModel;
114
+ const { models: _models, ...single } = config;
115
+ return withConfiguredFallbacks(
116
+ presetAdapter(id, { ...single, model: chain?.[0] ?? config.model }),
117
+ config,
118
+ );
119
+ }
120
+
121
+ /**
122
+ * The bare adapter for one preset and one model: the wire that serves the
123
+ * model, its auth style and the preset's quirks, and nothing around them. No
124
+ * fallbacks, no watchdog, no `models` chain. A registry builds one per
125
+ * (connection, model) and puts those layers around all of them once; calling
126
+ * `createPresetProvider` per model would nest a second pool and a second
127
+ * watchdog inside its own.
128
+ *
129
+ * The adapter's `id` is the preset id, because the OpenAI shape reads its
130
+ * effort dialect from it (`opencode-go`, `openrouter`, `deepseek`).
131
+ */
132
+ export function presetAdapter(id: ProviderPresetId, config: PresetAdapterConfig): Provider {
133
+ const preset = presetFor(id);
134
+ const model = config.model ?? preset.defaultModel;
94
135
  if (!model) {
95
136
  throw new Error(`[providerkit] Preset "${id}" has no defaultModel. Pass a model.`);
96
137
  }
@@ -101,24 +142,45 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
101
142
  "Remove serviceTier.",
102
143
  );
103
144
  }
145
+ let provider = buildWireAdapter(id, preset, wire, model, config, config.fetchImpl);
146
+ if (id === "opencode-zen") {
147
+ provider = wrapOpenCodeZen(provider, preset, wire, config, model);
148
+ }
149
+
150
+ // Here rather than in `createPresetProvider`: the wire is resolved here, and
151
+ // the bare adapter is exported, so it must refuse on its own a per-call model
152
+ // it would post to the wrong endpoint. That 404 reads as an unknown model,
153
+ // and a pool benches the model for an hour.
154
+ return preset.routes ? refuseOtherWires(provider, preset, wire) : provider;
155
+ }
156
+
157
+ function buildWireAdapter(
158
+ id: ProviderPresetId,
159
+ preset: ProviderPreset,
160
+ wire: Wire,
161
+ model: string,
162
+ config: PresetAdapterConfig,
163
+ fetchImpl: typeof fetch | undefined,
164
+ ): Provider {
104
165
  const headers = { ...preset.headers, ...config.headers };
105
166
  const baseUrl = config.baseUrl ?? wire.baseUrl;
106
167
  const maxTokens = config.maxTokens ?? preset.maxTokens;
168
+ // Every adapter builds its own pool and watchdog from these. The type leaves
169
+ // them out, but a wider object (a whole `PresetProviderConfig` in a variable)
170
+ // can still carry them, and the adapter would nest a second pool and a
171
+ // second watchdog inside the caller's. Cleared, it stays bare.
172
+ const baseConfig = {
173
+ ...config,
174
+ fallbacks: undefined,
175
+ fallbackOptions: undefined,
176
+ watchdog: undefined,
177
+ };
107
178
 
108
- const {
109
- fallbacks: _fallbacks,
110
- fallbackOptions: _fallbackOptions,
111
- watchdog: _watchdog,
112
- models: _models,
113
- ...baseConfig
114
- } = config;
115
-
116
- let provider: Provider;
117
179
  switch (wire.shape) {
118
180
  case "anthropic":
119
181
  // `key` rides Anthropic's native x-api-key; coding plans and OAuth
120
182
  // tokens read Bearer (measured across the family's coding endpoints).
121
- provider = createAnthropicProvider({
183
+ return createAnthropicProvider({
122
184
  ...baseConfig,
123
185
  apiKey: config.apiKey,
124
186
  model,
@@ -132,11 +194,10 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
132
194
  // The endpoint's thinking dialect rides the table (zai: silence means
133
195
  // the model default, which is ON — "none" must be said out loud).
134
196
  ...(preset.explicitNone ? { explicitNone: true } : {}),
135
- fetchImpl: config.fetchImpl,
197
+ fetchImpl,
136
198
  });
137
- break;
138
199
  case "openai":
139
- provider = createOpenAIProvider({
200
+ return createOpenAIProvider({
140
201
  ...baseConfig,
141
202
  id,
142
203
  apiKey: config.apiKey,
@@ -150,11 +211,10 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
150
211
  ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
151
212
  ...(preset.initiatorHeader ? { initiatorHeader: true } : {}),
152
213
  ...(preset.noSampling ? { noSampling: true } : {}),
153
- fetchImpl: config.fetchImpl,
214
+ fetchImpl,
154
215
  });
155
- break;
156
216
  case "responses":
157
- provider = createResponsesProvider({
217
+ return createResponsesProvider({
158
218
  ...baseConfig,
159
219
  apiKey: config.apiKey,
160
220
  model,
@@ -167,27 +227,221 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
167
227
  ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
168
228
  ...(preset.imageLimits ? { imageLimits: preset.imageLimits } : {}),
169
229
  ...(preset.replayReasoning ? { replayReasoning: true } : {}),
170
- fetchImpl: config.fetchImpl,
230
+ fetchImpl,
171
231
  });
172
- break;
173
232
  case "gemini":
174
- provider = createGeminiProvider({
233
+ return createGeminiProvider({
175
234
  ...baseConfig,
176
235
  apiKey: config.apiKey,
177
236
  model,
178
237
  effort: config.effort,
238
+ maxTokens,
179
239
  baseUrl,
240
+ ...(wire.path ? { path: wire.path } : {}),
241
+ bearer: wire.auth !== "key",
242
+ ...(config.projectId ? { projectId: config.projectId } : {}),
243
+ ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
180
244
  id,
181
245
  headers,
182
- fetchImpl: config.fetchImpl,
246
+ fetchImpl,
183
247
  });
184
- break;
185
248
  }
249
+ }
186
250
 
187
- return withConfiguredFallbacks(
188
- preset.routes ? refuseOtherWires(provider, preset, wire) : provider,
189
- config,
251
+ const OPENCODE_CLI_USER_AGENT = "opencode/latest/2.0.32/cli";
252
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
253
+
254
+ /** Whether an OpenCode Zen model id belongs to the free shelf (`big-pickle` or `*-free`). */
255
+ export function isZenFreeModel(model: string): boolean {
256
+ return model === "big-pickle" || model.endsWith("-free");
257
+ }
258
+
259
+ /**
260
+ * Deterministically turns any conversation session id into OpenCode's
261
+ * `ses_<12 hex><14 base62>` identifier shape (`packages/schema/src/session-id.ts`),
262
+ * matching cc-proxy's `forge_opencode_session_id`.
263
+ */
264
+ export async function forgeOpenCodeSessionId(sessionId?: string): Promise<string> {
265
+ if (sessionId && /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/.test(sessionId)) {
266
+ return sessionId;
267
+ }
268
+ const seed = sessionId && sessionId.length > 0 ? sessionId : crypto.randomUUID();
269
+ const digest = new Uint8Array(
270
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(seed)),
190
271
  );
272
+ let hexPart = "";
273
+ for (let i = 0; i < 6; i++) {
274
+ hexPart += digest[i]!.toString(16).padStart(2, "0");
275
+ }
276
+ let tail = "";
277
+ for (let i = 6; i < 20; i++) {
278
+ tail += BASE62[digest[i]! % 62];
279
+ }
280
+ return `ses_${hexPart}${tail}`;
281
+ }
282
+
283
+ /**
284
+ * OpenCode Zen's free tier answers only a streamed call whose tools include
285
+ * OpenCode's own `read` (`cc-proxy/src/providers/opencode/mod.rs`). A call with
286
+ * `Read` sends it as `read` and gets `Read` back; a call with neither gets a
287
+ * decoy `read` the model is told not to call (and `toolChoice: "none"` when the
288
+ * caller passed no tools).
289
+ */
290
+ function offerOpenCodeRead(
291
+ messages: ChatMessage[],
292
+ tools: ToolDefinition[],
293
+ opts: StreamOptions,
294
+ ): {
295
+ messages: ChatMessage[];
296
+ tools: ToolDefinition[];
297
+ opts: StreamOptions;
298
+ renamedRead: boolean;
299
+ } {
300
+ if (tools.some((t) => t.name === "read")) {
301
+ return { messages, tools, opts, renamedRead: false };
302
+ }
303
+ const hasClaudeRead = tools.some((t) => t.name === "Read");
304
+ if (!hasClaudeRead) {
305
+ const decoy: ToolDefinition = {
306
+ name: "read",
307
+ description: "Not available in this conversation. Never call it.",
308
+ inputSchema: { type: "object", properties: {} },
309
+ };
310
+ return {
311
+ messages,
312
+ tools: [...tools, decoy],
313
+ opts: tools.length === 0 ? { ...opts, toolChoice: "none" } : opts,
314
+ renamedRead: false,
315
+ };
316
+ }
317
+ const nextTools = tools.map((t) => (t.name === "Read" ? { ...t, name: "read" } : t));
318
+ const nextMessages: ChatMessage[] = messages.map((msg) => {
319
+ if (msg.role === "assistant" && msg.toolCalls?.some((c) => c.name === "Read")) {
320
+ return {
321
+ ...msg,
322
+ toolCalls: msg.toolCalls.map((c) => (c.name === "Read" ? { ...c, name: "read" } : c)),
323
+ };
324
+ }
325
+ if (msg.role === "tool" && msg.name === "Read") {
326
+ return { ...msg, name: "read" };
327
+ }
328
+ return msg;
329
+ });
330
+ const nextOpts =
331
+ opts.toolChoice && typeof opts.toolChoice === "object" && opts.toolChoice.name === "Read"
332
+ ? { ...opts, toolChoice: { type: "tool" as const, name: "read" } }
333
+ : opts;
334
+ return { messages: nextMessages, tools: nextTools, opts: nextOpts, renamedRead: true };
335
+ }
336
+
337
+ function wrapOpenCodeZen(
338
+ baseProvider: Provider,
339
+ preset: ProviderPreset,
340
+ wire: Wire,
341
+ config: PresetAdapterConfig,
342
+ defaultModel: string,
343
+ ): Provider {
344
+ const instanceSessionId = config.instanceSessionId ?? crypto.randomUUID();
345
+ const baseFetch = config.fetchImpl ?? fetch;
346
+ const callerUa = config.headers?.["user-agent"] ?? config.headers?.["User-Agent"];
347
+
348
+ return {
349
+ ...baseProvider,
350
+ async *createStream(messages, tools, opts = {}) {
351
+ const activeModel = opts.model ?? defaultModel;
352
+ const free = isZenFreeModel(activeModel);
353
+ const ses = await forgeOpenCodeSessionId(opts.sessionId ?? instanceSessionId);
354
+ const prepared = free
355
+ ? offerOpenCodeRead(messages, tools, opts)
356
+ : { messages, tools, opts, renamedRead: false };
357
+
358
+ const runAttempt = async function* (anonymous: boolean): AsyncIterable<ProviderChunk> {
359
+ const fetchImpl: typeof fetch = (input, init) => {
360
+ const merged = new Headers(init?.headers);
361
+ if (!callerUa) merged.set("user-agent", OPENCODE_CLI_USER_AGENT);
362
+ merged.set("x-opencode-session", ses);
363
+ merged.set("x-opencode-session-id", ses);
364
+ merged.set("x-session-affinity", ses);
365
+ merged.set("x-session-id", ses);
366
+ merged.set("x-opencode-project", "global");
367
+ merged.set("x-opencode-client", "cli");
368
+ if (anonymous) {
369
+ merged.delete("authorization");
370
+ merged.delete("x-api-key");
371
+ merged.delete("x-goog-api-key");
372
+ }
373
+ const headerRecord: Record<string, string> = {};
374
+ merged.forEach((v, k) => {
375
+ headerRecord[k] = v;
376
+ });
377
+ return baseFetch(input, { ...init, headers: headerRecord });
378
+ };
379
+
380
+ const bound = buildWireAdapter(
381
+ "opencode-zen",
382
+ preset,
383
+ wire,
384
+ defaultModel,
385
+ config,
386
+ fetchImpl,
387
+ );
388
+ for await (const chunk of bound.createStream(prepared.messages, prepared.tools, {
389
+ ...prepared.opts,
390
+ sessionId: ses,
391
+ })) {
392
+ if (
393
+ prepared.renamedRead &&
394
+ chunk.type === "delta" &&
395
+ chunk.toolCalls?.some((c) => c.name === "read")
396
+ ) {
397
+ yield {
398
+ ...chunk,
399
+ toolCalls: chunk.toolCalls.map((c) =>
400
+ c.name === "read" ? { ...c, name: "Read" } : c,
401
+ ),
402
+ };
403
+ } else {
404
+ yield chunk;
405
+ }
406
+ }
407
+ };
408
+
409
+ const startAnonymous = free && !config.apiKey;
410
+ let emitted = false;
411
+ try {
412
+ for await (const chunk of runAttempt(startAnonymous)) {
413
+ emitted = true;
414
+ yield chunk;
415
+ }
416
+ return;
417
+ } catch (err) {
418
+ // Free models accept a valid key (using the account's own free quota),
419
+ // and refuse a stale key with 401. Retry anonymously when that happens.
420
+ if (
421
+ free &&
422
+ !startAnonymous &&
423
+ !emitted &&
424
+ err instanceof ProviderError &&
425
+ err.status === 401
426
+ ) {
427
+ yield* runAttempt(true);
428
+ return;
429
+ }
430
+ throw err;
431
+ }
432
+ },
433
+ };
434
+ }
435
+
436
+ /** The table is closed (`satisfies`), but a caller may hold an id as a plain
437
+ * string. Validate rather than index into undefined. */
438
+ function presetFor(id: ProviderPresetId): ProviderPreset {
439
+ if (!(id in PROVIDER_PRESETS)) {
440
+ throw new Error(
441
+ `[providerkit] Unknown provider preset "${id}". Valid ids: ${PRESET_IDS.join(", ")}`,
442
+ );
443
+ }
444
+ return PROVIDER_PRESETS[id];
191
445
  }
192
446
 
193
447
  /** The endpoint a model is served from: its route when the preset has one for