@providerkit/core 0.24.0 → 0.25.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 (48) hide show
  1. package/README.md +14 -0
  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.js +5 -3
  15. package/dist/auth/with-token.js.map +1 -1
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +3 -0
  18. package/dist/errors.js.map +1 -1
  19. package/dist/presets.d.ts +53 -0
  20. package/dist/presets.d.ts.map +1 -1
  21. package/dist/presets.js +100 -0
  22. package/dist/presets.js.map +1 -1
  23. package/dist/providers/factory.d.ts +16 -1
  24. package/dist/providers/factory.d.ts.map +1 -1
  25. package/dist/providers/factory.js +176 -18
  26. package/dist/providers/factory.js.map +1 -1
  27. package/dist/providers/gemini.d.ts +23 -5
  28. package/dist/providers/gemini.d.ts.map +1 -1
  29. package/dist/providers/gemini.js +247 -119
  30. package/dist/providers/gemini.js.map +1 -1
  31. package/dist/providers/openai.d.ts.map +1 -1
  32. package/dist/providers/openai.js +4 -3
  33. package/dist/providers/openai.js.map +1 -1
  34. package/dist/schema.d.ts +27 -9
  35. package/dist/schema.d.ts.map +1 -1
  36. package/dist/schema.js +197 -62
  37. package/dist/schema.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/auth/device.ts +8 -4
  40. package/src/auth/flows.ts +288 -1
  41. package/src/auth/oauth.ts +8 -3
  42. package/src/auth/with-token.ts +5 -3
  43. package/src/errors.ts +3 -0
  44. package/src/presets.ts +100 -0
  45. package/src/providers/factory.ts +233 -20
  46. package/src/providers/gemini.ts +280 -124
  47. package/src/providers/openai.ts +5 -3
  48. package/src/schema.ts +200 -72
@@ -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,88 @@ 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
+ /**
89
+ * The User-Agent Cloud Code Assist expects for a model: `gemini-4-argon` is served to
90
+ * the Antigravity CLI, every other model to the IDE. Exported for hosts that cannot
91
+ * set the header on `fetch` (Chromium ignores it) and stamp it another way.
92
+ */
93
+ export function antigravityUserAgent(model: string): string {
94
+ return resolveAntigravityWireModel(model).startsWith("gemini-4-argon")
95
+ ? ANTIGRAVITY_CLI_USER_AGENT
96
+ : ANTIGRAVITY_IDE_USER_AGENT;
97
+ }
98
+
99
+ /** Deterministic Antigravity session id (`-<uint64>`), matching cc-proxy. */
100
+ export async function antigravitySessionId(sessionId?: string): Promise<string> {
101
+ const seed = sessionId && sessionId.length > 0 ? sessionId : crypto.randomUUID();
102
+ const digest = new Uint8Array(
103
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(seed)),
104
+ );
105
+ const view = new DataView(digest.buffer, digest.byteOffset, 8);
106
+ const masked = view.getBigUint64(0, false) & 0x7fffffffffffffffn;
107
+ return `-${masked.toString()}`;
108
+ }
109
+
110
+ function cleanAntigravitySystemText(text: string): string {
111
+ return text
112
+ .replace(/^x-anthropic-billing-header:[^\n]*\n*/, "")
113
+ .split("\n\n")
114
+ .filter((p) => p !== REJECTED_CLAUDE_SDK_PARAGRAPH)
115
+ .join("\n\n");
116
+ }
56
117
 
57
118
  /**
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.
119
+ * Gemini 3+ thinking dial.
120
+ *
121
+ * Measured across all 17 text models on 2026-10-21:
122
+ * - Only `gemini-3-flash*`, `gemini-3.5-flash*`, `gemini-3.6-flash`, and the
123
+ * `flash-lite` models accept `MINIMAL` (which spends 0 thought tokens).
124
+ * - `gemini-3.7-flash`, `gemini-3.8-flash`, `gemini-4-*`, `gemini-flash-latest`,
125
+ * and every Pro model answer `MINIMAL` with `400 Thinking level MINIMAL is not
126
+ * supported for this model`, and take `LOW` as their lowest level.
61
127
  *
62
128
  * Sent as the REST enum NAMES. The SDK's `ThinkingLevel` members serialize to
63
129
  * exactly these strings, so nothing is lost by writing them out.
64
130
  */
65
- const THINKING_LEVEL: Record<Effort, string> = {
66
- none: "MINIMAL",
131
+ const THINKING_LEVEL: Record<Exclude<Effort, "none">, string> = {
67
132
  low: "LOW",
68
133
  medium: "MEDIUM",
69
134
  high: "HIGH",
70
135
  max: "HIGH",
71
136
  };
72
137
 
138
+ const TAKES_MINIMAL_THINKING =
139
+ /^gemini-(?:3(?:\.[056])?-flash(?:-preview|-lite(?:-preview)?)?|3\.1-flash-lite(?:-preview)?|flash-lite-latest)$/i;
140
+
141
+ function geminiThinkingLevel(model: string, effort: Effort): string {
142
+ if (effort !== "none") return THINKING_LEVEL[effort];
143
+ return TAKES_MINIMAL_THINKING.test(model) ? "MINIMAL" : "LOW";
144
+ }
145
+
73
146
  /**
74
147
  * Gemini 2.x thinking budget dial (tokens). Gemini 2.5 takes `thinkingBudget`
75
148
  * (0 disables thinking) and has no level. Every newer model takes
@@ -327,6 +400,9 @@ interface GeminiResponse {
327
400
  export function createGeminiProvider(config: GeminiConfig): Provider {
328
401
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
329
402
  const id = config.id ?? "gemini";
403
+ const isAntigravity = id === "antigravity";
404
+ const instanceSessionId =
405
+ config.sessionHeader || isAntigravity ? (config.instanceSessionId ?? crypto.randomUUID()) : "";
330
406
 
331
407
  const provider: Provider = {
332
408
  id,
@@ -347,6 +423,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
347
423
  // the doubled segment 404s as "model not found" — a confusing way to
348
424
  // learn about a prefix.
349
425
  const model = (opts.model ?? config.model).replace(/^models\//, "");
426
+ const wireModel = isAntigravity ? resolveAntigravityWireModel(model) : model;
350
427
  const isGemini3 = /gemini-3/i.test(model);
351
428
  // Not `!isGemini3`: `gemini-flash-latest` and models newer than 3 are not
352
429
  // Gemini 3 either, and they must get a level.
@@ -361,10 +438,10 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
361
438
  // No effort means the model's own dynamic thinking. Sending MINIMAL / 0 here
362
439
  // would switch that off for a caller who never asked, which is the whole
363
440
  // reason the seam treats an absent effort as "never sent".
364
- if (effort) {
441
+ if (effort && !isAntigravity) {
365
442
  generationConfig.thinkingConfig = isGemini2
366
443
  ? { thinkingBudget: GEMINI_2_THINKING_BUDGET[effort] }
367
- : { thinkingLevel: THINKING_LEVEL[effort] };
444
+ : { thinkingLevel: geminiThinkingLevel(model, effort) };
368
445
  }
369
446
  // A schema that shares its call with tools pins the decoder, and a pinned
370
447
  // decoder cannot emit a functionCall — the model narrates the call it
@@ -399,8 +476,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
399
476
  // The prompt-carried schema rides as a second part, after the caller's
400
477
  // own text: this shape's cache is a prefix too, so a per-call schema in
401
478
  // front of the conversation would cold-start it on every change.
479
+ const cleanedSystem = system && isAntigravity ? cleanAntigravitySystemText(system) : system;
402
480
  const systemParts = [
403
- ...(system ? [{ text: system }] : []),
481
+ ...(cleanedSystem ? [{ text: cleanedSystem }] : []),
404
482
  ...(opts.json && promptCarried ? [{ text: schemaPrompt(opts.json.schema) }] : []),
405
483
  ];
406
484
  if (systemParts.length > 0) request.systemInstruction = { parts: systemParts };
@@ -420,9 +498,12 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
420
498
  functionDeclarations: tools.map((tool) => ({
421
499
  name: tool.name,
422
500
  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),
501
+ // Antigravity (Cloud Code Assist) still expects the OpenAPI-subset
502
+ // `parameters` field; the Gemini API and OpenCode Zen take standard
503
+ // JSON Schema in `parametersJsonSchema` (cc-proxy 0.2.8).
504
+ ...(isAntigravity
505
+ ? { parameters: toAntigravityToolSchema(tool.inputSchema) }
506
+ : { parametersJsonSchema: toGeminiToolSchema(tool.inputSchema) }),
426
507
  })),
427
508
  },
428
509
  ];
@@ -432,119 +513,194 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
432
513
  if (toolConfig) request.toolConfig = toolConfig;
433
514
  }
434
515
 
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;
516
+ const rawHeaders = { ...config.headers };
517
+ const headerProjectId = rawHeaders["x-antigravity-project"];
518
+ delete rawHeaders["x-antigravity-project"];
441
519
 
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;
520
+ let wireBody: Record<string, unknown> = request;
521
+ if (isAntigravity) {
522
+ request.sessionId = await antigravitySessionId(opts.sessionId ?? instanceSessionId);
523
+ if (wireModel.includes("claude") && !request.toolConfig) {
524
+ request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } };
463
525
  }
526
+ wireBody = {
527
+ model: wireModel,
528
+ userAgent: "antigravity",
529
+ requestType: "agent",
530
+ project: config.projectId ?? headerProjectId ?? "",
531
+ requestId: `agent-${crypto.randomUUID()}`,
532
+ request,
533
+ };
534
+ }
464
535
 
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
- }
536
+ const versionedBase = /\/v\d+[^/]*$/i.test(baseUrl.replace(/\/+$/, ""));
537
+ const defaultPath = `${versionedBase ? "" : "/v1beta"}/models/${model}:streamGenerateContent?alt=sse`;
538
+ const path = config.path ?? defaultPath;
539
+ const useBearer = config.bearer || isAntigravity;
540
+ const authHeader: Record<string, string> = config.apiKey
541
+ ? useBearer
542
+ ? { authorization: `Bearer ${config.apiKey}` }
543
+ : { "x-goog-api-key": config.apiKey }
544
+ : {};
545
+ const antigravityUa: Record<string, string> = isAntigravity
546
+ ? { "user-agent": antigravityUserAgent(model) }
547
+ : {};
548
+ const headers: Record<string, string> = {
549
+ ...antigravityUa,
550
+ ...authHeader,
551
+ ...rawHeaders,
552
+ ...(config.sessionHeader
553
+ ? { [config.sessionHeader]: opts.sessionId ?? instanceSessionId }
554
+ : {}),
555
+ };
556
+
557
+ const bases =
558
+ isAntigravity && baseUrl.replace(/\/+$/, "") === ANTIGRAVITY_DAILY_API
559
+ ? [ANTIGRAVITY_DAILY_API, ANTIGRAVITY_PROD_API]
560
+ : [baseUrl];
477
561
 
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
- };
494
- }
562
+ let sawFunctionCalls = false;
563
+ let callIndex = 0;
564
+ let finished = false;
565
+ let emitted = false;
495
566
 
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;
567
+ for (let baseIdx = 0; baseIdx < bases.length; baseIdx++) {
568
+ const currentBase = bases[baseIdx]!;
569
+ try {
570
+ for await (const data of streamSse({
571
+ // Without `?alt=sse` the response is one long JSON array that only
572
+ // parses once complete, which is not a stream.
573
+ url: apiUrl(currentBase, path),
574
+ headers,
575
+ body: wireBody,
576
+ provider: id,
577
+ ...(opts.signal ? { signal: opts.signal } : {}),
578
+ ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
579
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
580
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
581
+ // This wire has no terminal event to stop at, so the finish reason is
582
+ // the end: a read that dies after it can cost a late usage record, never
583
+ // the answer.
584
+ finished: () => finished,
585
+ })) {
586
+ let parsed: GeminiResponse & { response?: GeminiResponse };
587
+ try {
588
+ parsed = JSON.parse(data) as GeminiResponse & { response?: GeminiResponse };
589
+ } catch {
590
+ continue;
591
+ }
592
+ const chunk = parsed.response ?? parsed;
593
+
594
+ // `?alt=sse` commits to 200 the moment the headers go out, so a
595
+ // throttle or an overload landing after that arrives here as a
596
+ // google.rpc.Status in the body rather than as a status line.
597
+ if (chunk.error) throw streamError(id, chunk.error);
598
+ // A blocked PROMPT has no candidate to finish, so it reads as a cut
599
+ // stream — retried, into the same block. It is a content failure.
600
+ const blocked = chunk.promptFeedback?.blockReason;
601
+ if (blocked) {
602
+ throw new ProviderError(id, "content", `${id}: the prompt was blocked (${blocked})`, {
603
+ code: blocked,
604
+ });
605
+ }
606
+
607
+ if (chunk.usageMetadata) {
608
+ const usage = chunk.usageMetadata;
609
+ emitted = true;
610
+ yield {
611
+ type: "usage",
612
+ usage: {
613
+ inputTokens: usage.promptTokenCount ?? 0,
614
+ // Thoughts bill as OUTPUT, and Gemini reports them OUTSIDE
615
+ // candidatesTokenCount — leaving them out undercounts a thinking
616
+ // turn by most of what it cost.
617
+ outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
618
+ cachedInputTokens: usage.cachedContentTokenCount ?? 0,
619
+ ...(usage.thoughtsTokenCount !== undefined
620
+ ? { reasoningTokens: usage.thoughtsTokenCount }
621
+ : {}),
622
+ },
623
+ };
624
+ }
625
+
626
+ const candidate = chunk.candidates?.[0];
627
+ if (!candidate) continue;
628
+
629
+ // One chunk can carry several parts of each kind. They are coalesced
630
+ // per kind so the seam sees one reasoning delta and one content delta
631
+ // per chunk, in that order, rather than interleaved fragments.
632
+ let reasoning = "";
633
+ let content = "";
634
+ const toolCalls: ToolCallDelta[] = [];
635
+ for (const part of candidate.content?.parts ?? []) {
636
+ if (part.text) {
637
+ if (part.thought) reasoning += part.text;
638
+ else content += part.text;
639
+ continue;
640
+ }
641
+ const call = part.functionCall;
642
+ if (!call) continue;
643
+ const index = callIndex++;
644
+ const identity = { index, id: call.id ?? "", name: call.name ?? "" };
645
+ // The part is the whole call, so it is settled here: one with no
646
+ // name can't run, and one with no id gets one the way the other
647
+ // three wires mint it. Gemini omits the id on a single-call turn, and
648
+ // the seam's consumers pair a result back to its call by id.
649
+ settleToolCalls(id, [identity]);
650
+ toolCalls.push({
651
+ ...identity,
652
+ // Whole and already assembled — unlike the OpenAI shape, Gemini
653
+ // never fragments an argument object across chunks.
654
+ arguments: JSON.stringify(call.args ?? {}),
655
+ ...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
656
+ });
657
+ }
658
+
659
+ if (reasoning) {
660
+ emitted = true;
661
+ yield { type: "delta", reasoning };
662
+ }
663
+ if (content) {
664
+ emitted = true;
665
+ yield { type: "delta", content };
666
+ }
667
+ if (toolCalls.length > 0) {
668
+ sawFunctionCalls = true;
669
+ emitted = true;
670
+ yield { type: "delta", toolCalls };
671
+ }
672
+
673
+ if (candidate.finishReason) {
674
+ // Mapped first: a reason that throws must not be dressed as tool_calls.
675
+ const finishReason = mapFinishReason(id, candidate.finishReason);
676
+ finished = true;
677
+ emitted = true;
678
+ yield {
679
+ type: "finish",
680
+ // Gemini routinely reports STOP on a turn that called tools, and a
681
+ // caller reading that as "done" drops the tool round entirely.
682
+ // Only STOP is promoted, as on the other three wires: a length cut
683
+ // or a filter may be what broke the call (invariant 10).
684
+ finishReason:
685
+ sawFunctionCalls && finishReason === "stop" ? "tool_calls" : finishReason,
686
+ };
687
+ }
510
688
  }
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
- }
528
-
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
- }
535
-
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
- };
689
+ break;
690
+ } catch (err) {
691
+ const canFallback =
692
+ !emitted &&
693
+ baseIdx === 0 &&
694
+ bases.length > 1 &&
695
+ err instanceof ProviderError &&
696
+ (err.status === 403 ||
697
+ err.status === 404 ||
698
+ err.status === 500 ||
699
+ err.status === 502 ||
700
+ err.status === 503 ||
701
+ err.status === 504);
702
+ if (canFallback) continue;
703
+ throw err;
548
704
  }
549
705
  }
550
706
  // Gemini states a finish reason on the last candidate of every turn it
@@ -322,7 +322,9 @@ function goEffortParams(effort: Effort, model = ""): Record<string, unknown> {
322
322
  if (/glm-5[-.p]?[23]/.test(id)) {
323
323
  return level(effort === "max" ? "max" : "high");
324
324
  }
325
- 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
+ }
326
328
  if (id.includes("mimo"))
327
329
  return effort === "max" ? refuse("none, low, medium or high") : level(effort);
328
330
  return level(effort === "max" ? "high" : effort);
@@ -333,7 +335,7 @@ function goEffortParams(effort: Effort, model = ""): Record<string, unknown> {
333
335
  function dialectFor(id: string): EffortDialect {
334
336
  if (id === "openrouter") return "openrouter";
335
337
  if (id === "deepseek") return "deepseek";
336
- if (id === "opencode-go") return "opencode-go";
338
+ if (id === "opencode-go" || id === "opencode-zen") return "opencode-go";
337
339
  return "openai";
338
340
  }
339
341
 
@@ -667,7 +669,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
667
669
  for await (const data of streamSse({
668
670
  url: apiUrl(baseUrl, config.path ?? defaultPath),
669
671
  headers: {
670
- authorization: `Bearer ${config.apiKey}`,
672
+ ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}),
671
673
  ...attributionHeaders(config, config.headers),
672
674
  ...(config.sessionHeader
673
675
  ? { [config.sessionHeader]: opts.sessionId ?? instanceSessionId }