@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
@@ -7,7 +7,7 @@
7
7
  // and the SDK is a Node-shaped one.
8
8
  import { repairHistory } from "../context.ts";
9
9
  import { ProviderError, streamCut, streamError, type ErrorKind } from "../errors.ts";
10
- import { schemaPrompt, toGeminiToolSchema } from "../schema.ts";
10
+ import { schemaPrompt, toAntigravityToolSchema, toGeminiToolSchema } from "../schema.ts";
11
11
  import { parseToolArgs, settleToolCalls } from "../tool-args.ts";
12
12
  import { streamSse, apiUrl } from "../transport.ts";
13
13
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
@@ -28,12 +28,20 @@ export interface GeminiConfig extends ProviderFallbackConfig {
28
28
  apiKey: string;
29
29
  model: string;
30
30
  /** Any endpoint speaking the Generative Language REST dialect, including a proxy or
31
- * gateway. Defaults to the Generative Language API.
32
- *
33
- * Not Vertex: it serves `/v1/projects/…/locations/…/publishers/google/models`
34
- * and authenticates with a Bearer token, and both the path and the
35
- * `x-goog-api-key` header are fixed below. Vertex would be its own adapter. */
31
+ * gateway. Defaults to the Generative Language API. */
36
32
  baseUrl?: string;
33
+ /** Explicit path override when a backend has custom routing (Antigravity:
34
+ * `/v1internal:streamGenerateContent?alt=sse`). */
35
+ path?: string;
36
+ /** Send the credential as `Authorization: Bearer` instead of `x-goog-api-key`
37
+ * (Antigravity OAuth). */
38
+ bearer?: boolean;
39
+ /** Google Antigravity (Cloud Code Assist) project id for the request envelope. */
40
+ projectId?: string;
41
+ /** Header that carries `StreamOptions.sessionId`. */
42
+ sessionHeader?: string;
43
+ /** The id `sessionHeader` carries when a call has no `sessionId`. */
44
+ instanceSessionId?: string;
37
45
  /** Names the provider in errors and logs. */
38
46
  id?: string;
39
47
  /** Bound default; a per-call `effort` overrides it. */
@@ -53,23 +61,77 @@ export interface GeminiConfig extends ProviderFallbackConfig {
53
61
  }
54
62
 
55
63
  const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com";
64
+ const ANTIGRAVITY_DAILY_API = "https://daily-cloudcode-pa.googleapis.com";
65
+ const ANTIGRAVITY_PROD_API = "https://cloudcode-pa.googleapis.com";
66
+ const ANTIGRAVITY_IDE_USER_AGENT =
67
+ "antigravity/ide/2.5.5 (os_type=windows; arch=amd64; aidev_client; auth_method=oauth)";
68
+ const ANTIGRAVITY_CLI_USER_AGENT =
69
+ "antigravity/cli/1.3.4 (os_type=darwin; arch=arm64; aidev_client; auth_method=oauth)";
70
+ const REJECTED_CLAUDE_SDK_PARAGRAPH =
71
+ "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
72
+
73
+ const ANTIGRAVITY_WIRE_MODELS: Record<string, string> = {
74
+ "gemini-4-argon": "gemini-4-argon-high",
75
+ "gemini-3.8-flash": "gemini-3.8-flash-medium",
76
+ "gemini-3.7-flash": "gemini-3.7-flash-tiered",
77
+ "gemini-3.1-pro": "gemini-pro-agent",
78
+ "claude-opus-5-5": "claude-opus-5-5-high",
79
+ "claude-sonnet-5-5": "claude-sonnet-5-5-high",
80
+ };
81
+
82
+ /** Resolve an Antigravity model id to its Cloud Code Assist wire model id. */
83
+ export function resolveAntigravityWireModel(model: string): string {
84
+ const stripped = model.replace(/^antigravity\//, "");
85
+ return ANTIGRAVITY_WIRE_MODELS[stripped] ?? stripped;
86
+ }
87
+
88
+ /** Deterministic Antigravity session id (`-<uint64>`), matching cc-proxy. */
89
+ export async function antigravitySessionId(sessionId?: string): Promise<string> {
90
+ const seed = sessionId && sessionId.length > 0 ? sessionId : crypto.randomUUID();
91
+ const digest = new Uint8Array(
92
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(seed)),
93
+ );
94
+ const view = new DataView(digest.buffer, digest.byteOffset, 8);
95
+ const masked = view.getBigUint64(0, false) & 0x7fffffffffffffffn;
96
+ return `-${masked.toString()}`;
97
+ }
98
+
99
+ function cleanAntigravitySystemText(text: string): string {
100
+ return text
101
+ .replace(/^x-anthropic-billing-header:[^\n]*\n*/, "")
102
+ .split("\n\n")
103
+ .filter((p) => p !== REJECTED_CLAUDE_SDK_PARAGRAPH)
104
+ .join("\n\n");
105
+ }
56
106
 
57
107
  /**
58
- * Gemini 3's thinking dial. `none` is MINIMAL rather than a 0 budget: the Pro
59
- * models reject a hard 0, so the only way to say "think as little as possible"
60
- * without a 400 is the lowest level.
108
+ * Gemini 3+ thinking dial.
109
+ *
110
+ * Measured across all 17 text models on 2026-10-21:
111
+ * - Only `gemini-3-flash*`, `gemini-3.5-flash*`, `gemini-3.6-flash`, and the
112
+ * `flash-lite` models accept `MINIMAL` (which spends 0 thought tokens).
113
+ * - `gemini-3.7-flash`, `gemini-3.8-flash`, `gemini-4-*`, `gemini-flash-latest`,
114
+ * and every Pro model answer `MINIMAL` with `400 Thinking level MINIMAL is not
115
+ * supported for this model`, and take `LOW` as their lowest level.
61
116
  *
62
117
  * Sent as the REST enum NAMES. The SDK's `ThinkingLevel` members serialize to
63
118
  * exactly these strings, so nothing is lost by writing them out.
64
119
  */
65
- const THINKING_LEVEL: Record<Effort, string> = {
66
- none: "MINIMAL",
120
+ const THINKING_LEVEL: Record<Exclude<Effort, "none">, string> = {
67
121
  low: "LOW",
68
122
  medium: "MEDIUM",
69
123
  high: "HIGH",
70
124
  max: "HIGH",
71
125
  };
72
126
 
127
+ const TAKES_MINIMAL_THINKING =
128
+ /^gemini-(?:3(?:\.[056])?-flash(?:-preview|-lite(?:-preview)?)?|3\.1-flash-lite(?:-preview)?|flash-lite-latest)$/i;
129
+
130
+ function geminiThinkingLevel(model: string, effort: Effort): string {
131
+ if (effort !== "none") return THINKING_LEVEL[effort];
132
+ return TAKES_MINIMAL_THINKING.test(model) ? "MINIMAL" : "LOW";
133
+ }
134
+
73
135
  /**
74
136
  * Gemini 2.x thinking budget dial (tokens). Gemini 2.5 takes `thinkingBudget`
75
137
  * (0 disables thinking) and has no level. Every newer model takes
@@ -327,6 +389,9 @@ interface GeminiResponse {
327
389
  export function createGeminiProvider(config: GeminiConfig): Provider {
328
390
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
329
391
  const id = config.id ?? "gemini";
392
+ const isAntigravity = id === "antigravity";
393
+ const instanceSessionId =
394
+ config.sessionHeader || isAntigravity ? (config.instanceSessionId ?? crypto.randomUUID()) : "";
330
395
 
331
396
  const provider: Provider = {
332
397
  id,
@@ -347,6 +412,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
347
412
  // the doubled segment 404s as "model not found" — a confusing way to
348
413
  // learn about a prefix.
349
414
  const model = (opts.model ?? config.model).replace(/^models\//, "");
415
+ const wireModel = isAntigravity ? resolveAntigravityWireModel(model) : model;
350
416
  const isGemini3 = /gemini-3/i.test(model);
351
417
  // Not `!isGemini3`: `gemini-flash-latest` and models newer than 3 are not
352
418
  // Gemini 3 either, and they must get a level.
@@ -361,10 +427,10 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
361
427
  // No effort means the model's own dynamic thinking. Sending MINIMAL / 0 here
362
428
  // would switch that off for a caller who never asked, which is the whole
363
429
  // reason the seam treats an absent effort as "never sent".
364
- if (effort) {
430
+ if (effort && !isAntigravity) {
365
431
  generationConfig.thinkingConfig = isGemini2
366
432
  ? { thinkingBudget: GEMINI_2_THINKING_BUDGET[effort] }
367
- : { thinkingLevel: THINKING_LEVEL[effort] };
433
+ : { thinkingLevel: geminiThinkingLevel(model, effort) };
368
434
  }
369
435
  // A schema that shares its call with tools pins the decoder, and a pinned
370
436
  // decoder cannot emit a functionCall — the model narrates the call it
@@ -399,8 +465,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
399
465
  // The prompt-carried schema rides as a second part, after the caller's
400
466
  // own text: this shape's cache is a prefix too, so a per-call schema in
401
467
  // front of the conversation would cold-start it on every change.
468
+ const cleanedSystem = system && isAntigravity ? cleanAntigravitySystemText(system) : system;
402
469
  const systemParts = [
403
- ...(system ? [{ text: system }] : []),
470
+ ...(cleanedSystem ? [{ text: cleanedSystem }] : []),
404
471
  ...(opts.json && promptCarried ? [{ text: schemaPrompt(opts.json.schema) }] : []),
405
472
  ];
406
473
  if (systemParts.length > 0) request.systemInstruction = { parts: systemParts };
@@ -420,9 +487,12 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
420
487
  functionDeclarations: tools.map((tool) => ({
421
488
  name: tool.name,
422
489
  description: tool.description,
423
- // Same rule as the response schema: `parameters` is the trimmed
424
- // dialect, `parametersJsonSchema` is the schema we actually wrote.
425
- parametersJsonSchema: toGeminiToolSchema(tool.inputSchema),
490
+ // Antigravity (Cloud Code Assist) still expects the OpenAPI-subset
491
+ // `parameters` field; the Gemini API and OpenCode Zen take standard
492
+ // JSON Schema in `parametersJsonSchema` (cc-proxy 0.2.8).
493
+ ...(isAntigravity
494
+ ? { parameters: toAntigravityToolSchema(tool.inputSchema) }
495
+ : { parametersJsonSchema: toGeminiToolSchema(tool.inputSchema) }),
426
496
  })),
427
497
  },
428
498
  ];
@@ -432,119 +502,198 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
432
502
  if (toolConfig) request.toolConfig = toolConfig;
433
503
  }
434
504
 
435
- // Gemini reports the finish reason on a candidate that can arrive AFTER
436
- // the chunk carrying the function calls, so a turn's tool use has to be
437
- // remembered rather than read off the final chunk.
438
- let sawFunctionCalls = false;
439
- let callIndex = 0;
440
- let finished = false;
505
+ const rawHeaders = { ...config.headers };
506
+ const headerProjectId = rawHeaders["x-antigravity-project"];
507
+ delete rawHeaders["x-antigravity-project"];
441
508
 
442
- for await (const data of streamSse({
443
- // Without `?alt=sse` the response is one long JSON array that only
444
- // parses once complete, which is not a stream.
445
- url: apiUrl(baseUrl, `/v1beta/models/${model}:streamGenerateContent?alt=sse`),
446
- headers: { "x-goog-api-key": config.apiKey, ...config.headers },
447
- body: request,
448
- provider: id,
449
- ...(opts.signal ? { signal: opts.signal } : {}),
450
- ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
451
- ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
452
- ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
453
- // This wire has no terminal event to stop at, so the finish reason is
454
- // the end: a read that dies after it can cost a late usage record, never
455
- // the answer.
456
- finished: () => finished,
457
- })) {
458
- let chunk: GeminiResponse;
459
- try {
460
- chunk = JSON.parse(data) as GeminiResponse;
461
- } catch {
462
- continue;
463
- }
464
-
465
- // `?alt=sse` commits to 200 the moment the headers go out, so a
466
- // throttle or an overload landing after that arrives here as a
467
- // google.rpc.Status in the body rather than as a status line.
468
- if (chunk.error) throw streamError(id, chunk.error);
469
- // A blocked PROMPT has no candidate to finish, so it reads as a cut
470
- // stream — retried, into the same block. It is a content failure.
471
- const blocked = chunk.promptFeedback?.blockReason;
472
- if (blocked) {
473
- throw new ProviderError(id, "content", `${id}: the prompt was blocked (${blocked})`, {
474
- code: blocked,
475
- });
476
- }
477
-
478
- if (chunk.usageMetadata) {
479
- const usage = chunk.usageMetadata;
480
- yield {
481
- type: "usage",
482
- usage: {
483
- inputTokens: usage.promptTokenCount ?? 0,
484
- // Thoughts bill as OUTPUT, and Gemini reports them OUTSIDE
485
- // candidatesTokenCount — leaving them out undercounts a thinking
486
- // turn by most of what it cost.
487
- outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
488
- cachedInputTokens: usage.cachedContentTokenCount ?? 0,
489
- ...(usage.thoughtsTokenCount !== undefined
490
- ? { reasoningTokens: usage.thoughtsTokenCount }
491
- : {}),
492
- },
493
- };
509
+ let wireBody: Record<string, unknown> = request;
510
+ if (isAntigravity) {
511
+ request.sessionId = await antigravitySessionId(opts.sessionId ?? instanceSessionId);
512
+ if (wireModel.includes("claude") && !request.toolConfig) {
513
+ request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } };
494
514
  }
515
+ wireBody = {
516
+ model: wireModel,
517
+ userAgent: "antigravity",
518
+ requestType: "agent",
519
+ project: config.projectId ?? headerProjectId ?? "",
520
+ requestId: `agent-${crypto.randomUUID()}`,
521
+ request,
522
+ };
523
+ }
495
524
 
496
- const candidate = chunk.candidates?.[0];
497
- if (!candidate) continue;
498
-
499
- // One chunk can carry several parts of each kind. They are coalesced
500
- // per kind so the seam sees one reasoning delta and one content delta
501
- // per chunk, in that order, rather than interleaved fragments.
502
- let reasoning = "";
503
- let content = "";
504
- const toolCalls: ToolCallDelta[] = [];
505
- for (const part of candidate.content?.parts ?? []) {
506
- if (part.text) {
507
- if (part.thought) reasoning += part.text;
508
- else content += part.text;
509
- continue;
525
+ const versionedBase = /\/v\d+[^/]*$/i.test(baseUrl.replace(/\/+$/, ""));
526
+ const defaultPath = `${versionedBase ? "" : "/v1beta"}/models/${model}:streamGenerateContent?alt=sse`;
527
+ const path = config.path ?? defaultPath;
528
+ const useBearer = config.bearer || isAntigravity;
529
+ const authHeader: Record<string, string> = config.apiKey
530
+ ? useBearer
531
+ ? { authorization: `Bearer ${config.apiKey}` }
532
+ : { "x-goog-api-key": config.apiKey }
533
+ : {};
534
+ const antigravityUa: Record<string, string> = isAntigravity
535
+ ? {
536
+ "user-agent": wireModel.startsWith("gemini-4-argon")
537
+ ? ANTIGRAVITY_CLI_USER_AGENT
538
+ : ANTIGRAVITY_IDE_USER_AGENT,
510
539
  }
511
- const call = part.functionCall;
512
- if (!call) continue;
513
- const index = callIndex++;
514
- const identity = { index, id: call.id ?? "", name: call.name ?? "" };
515
- // The part is the whole call, so it is settled here: one with no
516
- // name can't run, and one with no id gets one the way the other
517
- // three wires mint it. Gemini omits the id on a single-call turn, and
518
- // the seam's consumers pair a result back to its call by id.
519
- settleToolCalls(id, [identity]);
520
- toolCalls.push({
521
- ...identity,
522
- // Whole and already assembled — unlike the OpenAI shape, Gemini
523
- // never fragments an argument object across chunks.
524
- arguments: JSON.stringify(call.args ?? {}),
525
- ...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
526
- });
527
- }
540
+ : {};
541
+ const headers: Record<string, string> = {
542
+ ...antigravityUa,
543
+ ...authHeader,
544
+ ...rawHeaders,
545
+ ...(config.sessionHeader
546
+ ? { [config.sessionHeader]: opts.sessionId ?? instanceSessionId }
547
+ : {}),
548
+ };
549
+
550
+ const bases =
551
+ isAntigravity && baseUrl.replace(/\/+$/, "") === ANTIGRAVITY_DAILY_API
552
+ ? [ANTIGRAVITY_DAILY_API, ANTIGRAVITY_PROD_API]
553
+ : [baseUrl];
528
554
 
529
- if (reasoning) yield { type: "delta", reasoning };
530
- if (content) yield { type: "delta", content };
531
- if (toolCalls.length > 0) {
532
- sawFunctionCalls = true;
533
- yield { type: "delta", toolCalls };
534
- }
555
+ let sawFunctionCalls = false;
556
+ let callIndex = 0;
557
+ let finished = false;
558
+ let emitted = false;
535
559
 
536
- if (candidate.finishReason) {
537
- // Mapped first: a reason that throws must not be dressed as tool_calls.
538
- const finishReason = mapFinishReason(id, candidate.finishReason);
539
- finished = true;
540
- yield {
541
- type: "finish",
542
- // Gemini routinely reports STOP on a turn that called tools, and a
543
- // caller reading that as "done" drops the tool round entirely.
544
- // Only STOP is promoted, as on the other three wires: a length cut
545
- // or a filter may be what broke the call (invariant 10).
546
- finishReason: sawFunctionCalls && finishReason === "stop" ? "tool_calls" : finishReason,
547
- };
560
+ for (let baseIdx = 0; baseIdx < bases.length; baseIdx++) {
561
+ const currentBase = bases[baseIdx]!;
562
+ try {
563
+ for await (const data of streamSse({
564
+ // Without `?alt=sse` the response is one long JSON array that only
565
+ // parses once complete, which is not a stream.
566
+ url: apiUrl(currentBase, path),
567
+ headers,
568
+ body: wireBody,
569
+ provider: id,
570
+ ...(opts.signal ? { signal: opts.signal } : {}),
571
+ ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
572
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
573
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
574
+ // This wire has no terminal event to stop at, so the finish reason is
575
+ // the end: a read that dies after it can cost a late usage record, never
576
+ // the answer.
577
+ finished: () => finished,
578
+ })) {
579
+ let parsed: GeminiResponse & { response?: GeminiResponse };
580
+ try {
581
+ parsed = JSON.parse(data) as GeminiResponse & { response?: GeminiResponse };
582
+ } catch {
583
+ continue;
584
+ }
585
+ const chunk = parsed.response ?? parsed;
586
+
587
+ // `?alt=sse` commits to 200 the moment the headers go out, so a
588
+ // throttle or an overload landing after that arrives here as a
589
+ // google.rpc.Status in the body rather than as a status line.
590
+ if (chunk.error) throw streamError(id, chunk.error);
591
+ // A blocked PROMPT has no candidate to finish, so it reads as a cut
592
+ // stream — retried, into the same block. It is a content failure.
593
+ const blocked = chunk.promptFeedback?.blockReason;
594
+ if (blocked) {
595
+ throw new ProviderError(id, "content", `${id}: the prompt was blocked (${blocked})`, {
596
+ code: blocked,
597
+ });
598
+ }
599
+
600
+ if (chunk.usageMetadata) {
601
+ const usage = chunk.usageMetadata;
602
+ emitted = true;
603
+ yield {
604
+ type: "usage",
605
+ usage: {
606
+ inputTokens: usage.promptTokenCount ?? 0,
607
+ // Thoughts bill as OUTPUT, and Gemini reports them OUTSIDE
608
+ // candidatesTokenCount — leaving them out undercounts a thinking
609
+ // turn by most of what it cost.
610
+ outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
611
+ cachedInputTokens: usage.cachedContentTokenCount ?? 0,
612
+ ...(usage.thoughtsTokenCount !== undefined
613
+ ? { reasoningTokens: usage.thoughtsTokenCount }
614
+ : {}),
615
+ },
616
+ };
617
+ }
618
+
619
+ const candidate = chunk.candidates?.[0];
620
+ if (!candidate) continue;
621
+
622
+ // One chunk can carry several parts of each kind. They are coalesced
623
+ // per kind so the seam sees one reasoning delta and one content delta
624
+ // per chunk, in that order, rather than interleaved fragments.
625
+ let reasoning = "";
626
+ let content = "";
627
+ const toolCalls: ToolCallDelta[] = [];
628
+ for (const part of candidate.content?.parts ?? []) {
629
+ if (part.text) {
630
+ if (part.thought) reasoning += part.text;
631
+ else content += part.text;
632
+ continue;
633
+ }
634
+ const call = part.functionCall;
635
+ if (!call) continue;
636
+ const index = callIndex++;
637
+ const identity = { index, id: call.id ?? "", name: call.name ?? "" };
638
+ // The part is the whole call, so it is settled here: one with no
639
+ // name can't run, and one with no id gets one the way the other
640
+ // three wires mint it. Gemini omits the id on a single-call turn, and
641
+ // the seam's consumers pair a result back to its call by id.
642
+ settleToolCalls(id, [identity]);
643
+ toolCalls.push({
644
+ ...identity,
645
+ // Whole and already assembled — unlike the OpenAI shape, Gemini
646
+ // never fragments an argument object across chunks.
647
+ arguments: JSON.stringify(call.args ?? {}),
648
+ ...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
649
+ });
650
+ }
651
+
652
+ if (reasoning) {
653
+ emitted = true;
654
+ yield { type: "delta", reasoning };
655
+ }
656
+ if (content) {
657
+ emitted = true;
658
+ yield { type: "delta", content };
659
+ }
660
+ if (toolCalls.length > 0) {
661
+ sawFunctionCalls = true;
662
+ emitted = true;
663
+ yield { type: "delta", toolCalls };
664
+ }
665
+
666
+ if (candidate.finishReason) {
667
+ // Mapped first: a reason that throws must not be dressed as tool_calls.
668
+ const finishReason = mapFinishReason(id, candidate.finishReason);
669
+ finished = true;
670
+ emitted = true;
671
+ yield {
672
+ type: "finish",
673
+ // Gemini routinely reports STOP on a turn that called tools, and a
674
+ // caller reading that as "done" drops the tool round entirely.
675
+ // Only STOP is promoted, as on the other three wires: a length cut
676
+ // or a filter may be what broke the call (invariant 10).
677
+ finishReason:
678
+ sawFunctionCalls && finishReason === "stop" ? "tool_calls" : finishReason,
679
+ };
680
+ }
681
+ }
682
+ break;
683
+ } catch (err) {
684
+ const canFallback =
685
+ !emitted &&
686
+ baseIdx === 0 &&
687
+ bases.length > 1 &&
688
+ err instanceof ProviderError &&
689
+ (err.status === 403 ||
690
+ err.status === 404 ||
691
+ err.status === 500 ||
692
+ err.status === 502 ||
693
+ err.status === 503 ||
694
+ err.status === 504);
695
+ if (canFallback) continue;
696
+ throw err;
548
697
  }
549
698
  }
550
699
  // Gemini states a finish reason on the last candidate of every turn it
@@ -107,6 +107,13 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
107
107
  * no sessionId, the provider's own per-instance id rides instead, so the
108
108
  * gateway never sees a call without one. */
109
109
  sessionHeader?: string;
110
+ /** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
111
+ * adapter mints one when it is built. Pass one when something rebuilds the
112
+ * adapter (`withKeyPool` and `withToken` build one per attempt): each build
113
+ * mints a new id, and a gateway that pins a session to one upstream host
114
+ * then treats every call as a new session, re-routes it and misses its
115
+ * prompt cache. Ignored without `sessionHeader`. */
116
+ instanceSessionId?: string;
110
117
  /** Send `X-Initiator`: `user` when the last message is the user's, else
111
118
  * `agent`. GitHub Copilot bills a premium request per `user` turn and lets
112
119
  * the agent's own follow-ups (tool results) ride free. */
@@ -315,7 +322,9 @@ function goEffortParams(effort: Effort, model = ""): Record<string, unknown> {
315
322
  if (/glm-5[-.p]?[23]/.test(id)) {
316
323
  return level(effort === "max" ? "max" : "high");
317
324
  }
318
- if (id.includes("deepseek-v4")) return level(effort === "none" ? "low" : effort);
325
+ if (id.includes("deepseek-v4") || id.includes("minimax")) {
326
+ return level(effort === "none" ? "low" : effort);
327
+ }
319
328
  if (id.includes("mimo"))
320
329
  return effort === "max" ? refuse("none, low, medium or high") : level(effort);
321
330
  return level(effort === "max" ? "high" : effort);
@@ -326,7 +335,7 @@ function goEffortParams(effort: Effort, model = ""): Record<string, unknown> {
326
335
  function dialectFor(id: string): EffortDialect {
327
336
  if (id === "openrouter") return "openrouter";
328
337
  if (id === "deepseek") return "deepseek";
329
- if (id === "opencode-go") return "opencode-go";
338
+ if (id === "opencode-go" || id === "opencode-zen") return "opencode-go";
330
339
  return "openai";
331
340
  }
332
341
 
@@ -531,7 +540,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
531
540
  // `usage.cost` is its field in its unit. Another gateway that sends a
532
541
  // `cost` has not said what it means.
533
542
  const reportsCost = isOpenRouter(baseUrl);
534
- const instanceSessionId = config.sessionHeader ? crypto.randomUUID() : undefined;
543
+ const instanceSessionId = config.sessionHeader
544
+ ? (config.instanceSessionId ?? crypto.randomUUID())
545
+ : undefined;
535
546
 
536
547
  const provider: Provider = {
537
548
  id,
@@ -658,7 +669,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
658
669
  for await (const data of streamSse({
659
670
  url: apiUrl(baseUrl, config.path ?? defaultPath),
660
671
  headers: {
661
- authorization: `Bearer ${config.apiKey}`,
672
+ ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}),
662
673
  ...attributionHeaders(config, config.headers),
663
674
  ...(config.sessionHeader
664
675
  ? { [config.sessionHeader]: opts.sessionId ?? instanceSessionId }
@@ -48,6 +48,17 @@ export interface ResponsesConfig extends ProviderFallbackConfig {
48
48
  /** Header that carries `StreamOptions.sessionId`. When set and a call has no
49
49
  * sessionId, one stable id for this provider instance rides instead. */
50
50
  sessionHeader?: string;
51
+ /** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
52
+ * adapter mints one when it is built. Pass one when something rebuilds the
53
+ * adapter (`withKeyPool` and `withToken` build one per attempt): each build
54
+ * mints a new id, and a gateway that pins a session to one upstream host
55
+ * then treats every call as a new session, re-routes it and misses its
56
+ * prompt cache. Ignored without `sessionHeader`.
57
+ *
58
+ * It is not sent as `prompt_cache_key`, which stays the per-call
59
+ * `sessionId`'s: this id names the adapter, so it is shared by every
60
+ * conversation the adapter serves, and a minted one never was a cache key. */
61
+ instanceSessionId?: string;
51
62
  /**
52
63
  * Where this backend serves the endpoint, when it is not `/v1/responses`.
53
64
  * The ChatGPT subscription surface serves it at `/backend-api/codex/responses`
@@ -395,7 +406,9 @@ function unsentTail(snapshot: string, streamed: string): string {
395
406
  export function createResponsesProvider(config: ResponsesConfig): Provider {
396
407
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
397
408
  const id = config.id ?? "openai-responses";
398
- const instanceSessionId = config.sessionHeader ? crypto.randomUUID() : undefined;
409
+ const instanceSessionId = config.sessionHeader
410
+ ? (config.instanceSessionId ?? crypto.randomUUID())
411
+ : undefined;
399
412
 
400
413
  const provider: Provider = {
401
414
  id,