@providerkit/core 0.24.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 (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 +11 -1
  24. package/dist/providers/factory.d.ts.map +1 -1
  25. package/dist/providers/factory.js +172 -18
  26. package/dist/providers/factory.js.map +1 -1
  27. package/dist/providers/gemini.d.ts +17 -5
  28. package/dist/providers/gemini.d.ts.map +1 -1
  29. package/dist/providers/gemini.js +241 -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 +229 -20
  46. package/src/providers/gemini.ts +273 -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,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
@@ -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 }