From 426b2791207e9376d122a8607420d22ccda603e8 Mon Sep 17 00:00:00 2001 From: MidhunCN Date: Thu, 15 Oct 2026 09:38:35 +0530 Subject: [PATCH] fix(models): route Gemini to the Google API on every vendor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini models served by OpenCode Go (gemini-4-argon-contributor) failed every request with 400 "Model does not support this protocol." — the gemini registry row was restricted to opencodezen, so Go fell through to the chat-completions catch-all while the gateway serves the model Google-native. The row now applies to every vendor; since Go's /zen/go/v1 base does not serve the :streamGenerateContent route at all, providers carry an optional googleModelsUrl (Go default https://opencode.ai/inference/go/v1/models per OpenCode's own endpoint table; Zen and custom apiBaseUrl gateways keep deriving from modelsUrl, issue #247 contract intact). Also in this change: - feat(thinking): GeminiThinking strategy — Thinking Effort picker mapped to generationConfig.thinkingConfig (thinkingLevel + includeThoughts), opencodego.thinking.gemini workspace default. Verified live: the level is accepted and thinking is billed (thoughtsTokenCount). Thought summaries are not relayed upstream (opaque thoughtSignature only) — documented in thinking/gemini.ts. - fix(usage): updateRequestUsageSummary understands Google usageMetadata and candidates[].finishReason (Gemini usage logged n/a and metered 0). - fix(usage): [diag-empty-response] no longer requires completion tokens to fire — that precondition made the raw-SSE dump unreachable exactly when Google parsing produced nothing. - fix(models): stop sending temperature to Google models that declare temperature: false. Verified live on gemini-4-argon-contributor (200 OK, thinkingLevel accepted, real usage); deepseek-v4.1-flash / mimo-v2.6-flash regression checked. npm run lint 7/7; npm test 540/540. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE-MAP.md | 6 +- CHANGELOG.md | 14 +++ README.md | 4 +- ...0260514-open-code-provider-architecture.md | 2 +- .../17-20260814-data-driven-model-registry.md | 34 ++++---- package.json | 12 +++ src/config.ts | 11 +++ src/core/registry.ts | 8 +- src/core/routing.ts | 12 ++- src/provider/OpenCodeProvider.ts | 2 +- src/provider/chatPrep.ts | 9 +- src/provider/definitions.ts | 14 ++- src/provider/settings.ts | 7 ++ src/providerTypes.ts | 7 ++ src/request/google.ts | 20 ++++- src/test/extractors.test.ts | 45 ++++++++++ src/test/registry.test.ts | 30 +++++-- src/test/thinking.test.ts | 42 ++++++++- src/thinking.ts | 2 + src/thinking/fallback.ts | 11 +-- src/thinking/gemini.ts | 86 +++++++++++++++++++ src/thinking/provider.ts | 3 + src/thinking/types.ts | 13 ++- src/transports/engine.ts | 28 +++--- src/transports/extract.ts | 16 +++- 25 files changed, 373 insertions(+), 65 deletions(-) create mode 100644 src/thinking/gemini.ts diff --git a/ARCHITECTURE-MAP.md b/ARCHITECTURE-MAP.md index ba8ff0560..bf766f3e4 100644 --- a/ARCHITECTURE-MAP.md +++ b/ARCHITECTURE-MAP.md @@ -234,7 +234,7 @@ flowchart TD | Claude | `/^claude-/i` | `messages` | `null` | any | | MiniMax m2.x | `/^minimax-m2\./i` | `messages` | `minimax` | `opencodego` only (Zen → chat-completions) | | Qwen Messages | `/^qwen3\.(?:5\|6)-plus(?:-free)?$/` · `/^qwen3\.7-max$/` | `messages` | `qwen` | any | -| Gemini | `/^gemini-/i` | `google` | `null` | `opencodezen` only | +| Gemini | `/^gemini-/i` | `google` | `gemini` | any | | MiniMax | `/^minimax-/i` | `chat-completions` | `minimax` | any | | DeepSeek | `/^deepseek-/i` | `chat-completions` | `deepseek` | any | | GLM | `/^glm-/i` | `chat-completions` | `glm` | any | @@ -245,7 +245,7 @@ flowchart TD > **Scope note:** context limits / capabilities / cost are **NOT** in this table — they stay metadata-driven (live models.dev via `src/models/metadata.ts`). Do not duplicate them into a static table. -**Endpoint URL resolution** (`resolveModelRouting`): agent-host variants resolve to their base vendor first; `responses` → `provider.responsesUrl`, `messages` → `provider.messagesUrl`, `google` → `${provider.modelsUrl}/${modelId}`, default → `provider.chatCompletionsUrl`. +**Endpoint URL resolution** (`resolveModelRouting`): agent-host variants resolve to their base vendor first; `responses` → `provider.responsesUrl`, `messages` → `provider.messagesUrl`, `google` → `${provider.googleModelsUrl ?? provider.modelsUrl}/${modelId}`, default → `provider.chatCompletionsUrl`. --- @@ -476,7 +476,7 @@ On push/PR to `main`/`develop`, Node 20: `npm ci` → `compile` → `lint` (with - **Activation:** `onStartupFinished`, `onLanguageModelChatProvider:opencodego`, `onLanguageModelChatProvider:opencodezen` - **Contribution points:** `commands` (16), `configuration` (35+ keys), `languageModelChatProviders` (4 vendors: `opencodego`, `opencodezen`, `opencodego-agent`, `opencodezen-agent`) - **4 providers:** Go (paid) + Zen (free default) + agent-host variants mirroring each base vendor -- **Endpoints:** `https://opencode.ai/zen/{go/,}v1/{models,chat/completions,messages,responses}` + `https://opencode.ai/zen/go/v1/usage` (server meters) + `https://models.dev/api.json` (metadata) +- **Endpoints:** `https://opencode.ai/zen/{go/,}v1/{models,chat/completions,messages,responses}` + `https://opencode.ai/inference/go/v1/models` (Go Gemini route, see `googleModelsUrl`) + `https://opencode.ai/zen/go/v1/usage` (server meters) + `https://models.dev/api.json` (metadata) --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a6109f0..21e9c3a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documented here. +## [0.7.10] — unreleased + +### Added + +- **`[Thinking]` Gemini models now get a Thinking Effort picker.** Gemini had no thinking control at all: its registry row declared no thinking family, and the generic `reasoning_effort` fallback is deliberately gated to the chat-completions endpoint (that field is meaningless on the Google wire format), so the picker never rendered and the request went out with `thinkingPayload={}` — the model always answered at its default effort. A new `GeminiThinking` strategy maps the pick to Google's native `generationConfig.thinkingConfig` (`thinkingLevel` + `includeThoughts: true`); verified live on `gemini-4-argon-contributor` that `thinkingLevel: "medium"/"high"` is accepted (HTTP 200) and thinking is actually performed (`usageMetadata.thoughtsTokenCount` billed). Caveat documented in `thinking/gemini.ts`: the gateway relays only an opaque `thoughtSignature`, not thought summaries, so the thinking panel stays empty for Gemini (same class as Muse Spark's `encrypted_content`) — if upstream starts relaying `thought: true` parts the existing Google normalizer maps them with no further client work. Levels come from the model's own models.dev `reasoning_options` (`off`/`on`/`low`/`medium`/`high`), with `opencodego.thinking.gemini` as the workspace default. `off` sends no thinking config at all (model default, unchanged from before). + +### Fixed + +- **`[Models]` Gemini models served by OpenCode Go now route to the Google API instead of chat-completions.** Every request to `gemini-4-argon-contributor` failed with `400 Model does not support this protocol.` because the `gemini` row in `core/registry.ts` was restricted to `opencodezen`, so on Go the lookup fell through to the catch-all row and the model was sent to `chat/completions` — while the gateway serves it Google-native (models.dev marks it `@ai-sdk/google`, and OpenCode's own Go endpoint table lists it under `@ai-sdk/google` too). The row now applies to every vendor. Fixing that exposed the second half of the bug: the Google route is built as `{base}/{modelId}:streamGenerateContent?alt=sse`, and Go's built-in `/zen/go/v1` base does not serve that path at all (it answers with the opencode.ai website 404 page), so provider routing gained an optional `googleModelsUrl` — Go's built-in default points at `https://opencode.ai/inference/go/v1/models` (the base OpenCode's own endpoint table documents for the model), while Zen and any configured `apiBaseUrl` gateway keep deriving the route from `modelsUrl` exactly as before (issue #247 contract intact). Every other route (chat/completions, messages, responses, models, usage) is untouched. Routing tests now cover Go, Zen, and both agent-host variants. Registry drift found while investigating (Go/Zen `minimax-m3`, `qwen3.8-max`, `qwen3.8-flash`, `qwen3.7-plus` documented as Anthropic `messages`) is deliberately not changed here — each needs live verification first. Documented in `docs/features/17-20260814-data-driven-model-registry.md`. + +- **`[Usage]` Google-route responses now report usage and finish reasons.** `updateRequestUsageSummary` understood OpenAI, Responses, and Anthropic payload shapes but not Google's `usageMetadata` / `candidates[].finishReason`, so every Gemini request logged `promptTokens=n/a … finishReason=` and the Go usage meter recorded `promptTokens=0` — silently losing cost tracking for the whole family. The Google normalizer's own usage mapping is now reused for the summary. With usage visible again, the empty-response diagnostic (`[diag-empty-response]`, the raw-SSE dump built for exactly the "gateway sent bytes but nothing parsed" case) no longer requires completion tokens to fire — that precondition made it unreachable on Google responses and left an empty `200 OK` stream of 5 unparsed events undiagnosable. + +- **`[Models]` Gemini requests no longer send `temperature` to models that declare it unsupported.** The Google body builder sent `temperature` unconditionally even when models.dev marks the model `temperature: false` (`gemini-4-argon-contributor`) — the OpenAI and Anthropic builders already guarded this. Guarded the same way, and the builder now receives the model metadata it needs for both this and the thinking payload. + ## [0.7.9] — 2026-10-13 ### Fixed diff --git a/README.md b/README.md index 722229d0b..fe9d3635d 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Deprecated/unavailable models are filtered before registration. Per-provider lim | Family | Endpoint | Why | | ----------------------------------------------------- | -------------------------------- | -------------------- | | Zen GPT (`gpt-*`) | `/responses` | OpenAI native | -| Zen Gemini (`gemini-*`) | `:streamGenerateContent?alt=sse` | Google native | +| Gemini (`gemini-*`, Zen + Go) | `:streamGenerateContent?alt=sse` | Google native | | Zen Claude (`claude-*`) + Go MiniMax (`minimax-m2.*`) | `/messages` | Anthropic-compatible | | Everything else (Qwen, DeepSeek, GLM, Kimi, MiMo…) | `/chat/completions` | OpenAI-compatible | @@ -248,6 +248,7 @@ Per-model reasoning configuration, dynamically enhanced with `reasoning_options` | **MiniMax** | `off` / `on` | `opencodego.thinking.minimax` | | **Mimo (Xiaomi)** | `off` / `low` / `medium` / `high` | `opencodego.thinking.mimo` | | **Qwen** | `auto` / `on` / `off` + `thinking_budget` (4096–81920) | `opencodego.thinking.qwen` + `.qwenBudget` | +| **Gemini (Google)** | `off` / `on` / `low` / `medium` / `high` (`thinkingLevel`, thoughts streamed) | `opencodego.thinking.gemini` | | **Any future reasoning model** | `off` / `low` / `medium` / `high` / `xhigh` / `max` (auto-detected from `models.dev`) | `opencodego.thinking.generic` | > **`opencodego.debugReasoning`** writes provider `reasoning_content` to **Output → OpenCode** for debugging. @@ -414,6 +415,7 @@ Provider diagnostics also include the VS Code/extension versions, extension host | `opencodego.thinking.mimo` | `"off"` | `off`/`low`/`medium`/`high` | | `opencodego.thinking.qwen` | `"off"` | `auto`/`on`/`off` | | `opencodego.thinking.qwenBudget` | `"auto"` | `auto`/`4096`/`16384`/`32768`/`81920` | +| `opencodego.thinking.gemini` | `"off"` | `off`/`on`/`low`/`medium`/`high` |
📜 Full settings reference with descriptions diff --git a/docs/architecture/01-20260514-open-code-provider-architecture.md b/docs/architecture/01-20260514-open-code-provider-architecture.md index 21296bbcc..bce5db467 100644 --- a/docs/architecture/01-20260514-open-code-provider-architecture.md +++ b/docs/architecture/01-20260514-open-code-provider-architecture.md @@ -202,7 +202,7 @@ Routing is centralized in `src/routing.ts`. | Claude family | `messages` | `/zen/v1/messages` | | Go MiniMax M2 family | `messages` | `/zen/go/v1/messages` | | Qwen 3.5/3.6 Plus and Qwen 3.7 Max | `messages` | provider messages endpoint | -| Zen Gemini family | `google` | `streamGenerateContent?alt=sse` style route | +| Gemini family (Zen + Go) | `google` | `streamGenerateContent?alt=sse` style route | | All other models | `chat-completions` | provider chat-completions endpoint | The request layer maps VS Code chat parts and tools into the correct request body for the selected endpoint. diff --git a/docs/features/17-20260814-data-driven-model-registry.md b/docs/features/17-20260814-data-driven-model-registry.md index 062eeab4c..5a2103f56 100644 --- a/docs/features/17-20260814-data-driven-model-registry.md +++ b/docs/features/17-20260814-data-driven-model-registry.md @@ -116,22 +116,22 @@ interface ModelRegistryEntry { ### Current rows (11 families + catch-all) -| Order | Family | Patterns | endpointKind | thinkingFamily | vendors | -| ----- | --------------- | ---------------------------------------------------------- | ---------------- | -------------- | ----------- | -| 1 | `gpt` | `/^gpt-/i` | responses | openai | — | -| 2 | `claude` | `/^claude-/i` | messages | null | — | -| 3 | `minimax-m2` | `/^minimax-m2\./i` | messages | minimax | opencodego | -| 4 | `qwen-messages` | `/^qwen3\.(?:5\|6)-plus(?:-free)?$/i`, `/^qwen3\.7-max$/i` | messages | qwen | — | -| 5 | `gemini` | `/^gemini-/i` | google | null | opencodezen | -| 6 | `minimax` | `/^minimax-/i` | chat-completions | minimax | — | -| 7 | `deepseek` | `/^deepseek-/i` | chat-completions | deepseek | — | -| 8 | `glm` | `/^glm-/i` | chat-completions | glm | — | -| 9 | `kimi` | `/^kimi-/i` | chat-completions | kimi | — | -| 10 | `mimo` | `/^mimo-/i` | chat-completions | mimo | — | -| 11 | `qwen` | `/^qwen3(?:\.\|-)/i` | chat-completions | qwen | — | -| 12 | `default` | `/.*/` | chat-completions | null | — | - -**Vendor-restriction semantics:** `minimax-m2.*` served by **Go** → Messages API (Anthropic); on **Zen** it falls through to the generic `minimax` row → chat-completions. `gemini-*` served by **Zen** → Google API; on Go → chat-completions. Agent-host variants are resolved to their base vendor via `resolveBaseVendor()` before lookup. +| Order | Family | Patterns | endpointKind | thinkingFamily | vendors | +| ----- | --------------- | ---------------------------------------------------------- | ---------------- | -------------- | ---------- | +| 1 | `gpt` | `/^gpt-/i` | responses | openai | — | +| 2 | `claude` | `/^claude-/i` | messages | null | — | +| 3 | `minimax-m2` | `/^minimax-m2\./i` | messages | minimax | opencodego | +| 4 | `qwen-messages` | `/^qwen3\.(?:5\|6)-plus(?:-free)?$/i`, `/^qwen3\.7-max$/i` | messages | qwen | — | +| 5 | `gemini` | `/^gemini-/i` | google | gemini | — | +| 6 | `minimax` | `/^minimax-/i` | chat-completions | minimax | — | +| 7 | `deepseek` | `/^deepseek-/i` | chat-completions | deepseek | — | +| 8 | `glm` | `/^glm-/i` | chat-completions | glm | — | +| 9 | `kimi` | `/^kimi-/i` | chat-completions | kimi | — | +| 10 | `mimo` | `/^mimo-/i` | chat-completions | mimo | — | +| 11 | `qwen` | `/^qwen3(?:\.\|-)/i` | chat-completions | qwen | — | +| 12 | `default` | `/.*/` | chat-completions | null | — | + +**Vendor-restriction semantics:** `minimax-m2.*` served by **Go** → Messages API (Anthropic); on **Zen** it falls through to the generic `minimax` row → chat-completions. `gemini-*` → Google API on **both** vendors; the route base is `googleModelsUrl ?? modelsUrl`, so Go's built-in gateway uses its own Google base while Zen (and any custom `apiBaseUrl` gateway) keeps building from `modelsUrl`. Agent-host variants are resolved to their base vendor via `resolveBaseVendor()` before lookup. ### How to add a model family @@ -149,7 +149,7 @@ interface ModelRegistryEntry { | `claude-` | **messages** (Anthropic) | any vendor | | `minimax-m2.*` (Go) | **messages** | on Zen → chat-completions | | `qwen3.5-plus(-free)`, `qwen3.6-plus(-free)`, `qwen3.7-max` | **messages** | others → chat-completions | -| `gemini-*` (Zen) | **google** | on Go → chat-completions | +| `gemini-*` (Zen + Go) | **google** | Go uses `googleModelsUrl` base, Zen uses `modelsUrl` | | everything else | **chat-completions** | default | --- diff --git a/package.json b/package.json index d0b3e413a..81607fa66 100644 --- a/package.json +++ b/package.json @@ -457,6 +457,18 @@ "default": "off", "markdownDescription": "Thinking mode for Muse Spark models (`muse-spark-1.2-contributor`, `muse-spark-1.2-contributor-free`). Maps to the Responses API `reasoning.effort` field. Higher effort increases reasoning depth and may increase latency and usage." }, + "opencodego.thinking.gemini": { + "type": "string", + "enum": [ + "off", + "on", + "low", + "medium", + "high" + ], + "default": "off", + "markdownDescription": "Thinking level for Gemini models (Google API wire format). Maps to `generationConfig.thinkingConfig.thinkingLevel` and sets `includeThoughts: true` so reasoning streams into the thinking panel. `off` sends no thinking config (model default); `on` surfaces thoughts without pinning a level. Higher levels increase reasoning depth and latency." + }, "opencodego.thinking.generic": { "type": "string", "enum": [ diff --git a/src/config.ts b/src/config.ts index f567c6d4a..6e4b17c08 100644 --- a/src/config.ts +++ b/src/config.ts @@ -61,6 +61,7 @@ export const SETTING_THINKING_QWEN = "thinking.qwen"; export const SETTING_THINKING_QWEN_BUDGET = "thinking.qwenBudget"; export const SETTING_THINKING_MIMO = "thinking.mimo"; export const SETTING_THINKING_MUSE = "thinking.muse"; +export const SETTING_THINKING_GEMINI = "thinking.gemini"; export const SETTING_THINKING_GENERIC = "thinking.generic"; // ─── Inline completions (issue #49) ───────────────────────────────────────── @@ -128,6 +129,15 @@ export const DEFAULT_MODEL_MAX_OUTPUT_TOKENS = 65536; /** Default OpenCode Go API base URL; can be overridden in VS Code settings. */ export const DEFAULT_GO_API_BASE_URL = "https://opencode.ai/zen/go/v1"; +/** + * Base of the Google-native (Gemini) generate route on the built-in Go gateway. + * Go serves that route on a different base than the rest of its routes: the + * `/zen/go/v1/models/{id}:streamGenerateContent` path is not routed at all + * (404), while `/inference/go/v1/models/{id}` is (verified 2026-10-14). A + * configured `apiBaseUrl` drives every route including this one (issue #247), + * so only the built-in default carries the split (see `provider/definitions.ts`). + */ +export const DEFAULT_GO_GOOGLE_MODELS_URL = "https://opencode.ai/inference/go/v1/models"; /** Default OpenCode Zen API base URL; can be overridden in VS Code settings. */ export const DEFAULT_ZEN_API_BASE_URL = "https://opencode.ai/zen/v1"; @@ -426,5 +436,6 @@ export const THINKING_DEFAULTS = { qwenBudget: "auto", mimo: "off", muse: "off", + gemini: "off", generic: "off", } as const; diff --git a/src/core/registry.ts b/src/core/registry.ts index 8271d8b3f..664905250 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -70,14 +70,16 @@ export const MODEL_REGISTRY: ModelRegistryEntry[] = [ sdkPackage: "@ai-sdk/anthropic", thinkingFamily: "qwen", }, - // Gemini family served by Zen → Google Generative Language API. + // Gemini family → Google Generative Language API. Both vendors serve Gemini + // Google-native (models.dev marks these `@ai-sdk/google`), so no vendor + // restriction; the Go gateway needs a different route base instead + // (`googleModelsUrl`, see `providerTypes.ts`). { family: "gemini", patterns: [/^gemini-/i], endpointKind: "google", sdkPackage: "@ai-sdk/google", - thinkingFamily: null, - vendors: ["opencodezen"], + thinkingFamily: "gemini", }, // Muse Spark 1.2 (Meta) → OpenAI Responses API (per OpenCode Go docs). // The gateway rejects chat-completions parameters (stream_options, diff --git a/src/core/routing.ts b/src/core/routing.ts index c18e0b818..f1cdaf193 100644 --- a/src/core/routing.ts +++ b/src/core/routing.ts @@ -32,7 +32,9 @@ export function resolveModelRouting( endpointUrl = provider.messagesUrl; break; case "google": - endpointUrl = `${provider.modelsUrl}/${modelId}`; + // Go's built-in gateway serves the Google route on its own base + // (`googleModelsUrl`); Zen — and any custom gateway — uses `modelsUrl`. + endpointUrl = `${provider.googleModelsUrl ?? provider.modelsUrl}/${modelId}`; break; default: endpointUrl = provider.chatCompletionsUrl; @@ -470,7 +472,11 @@ function extractResponsesReasoningText(data: Record): string { return ""; } -function normalizeGoogleUsage(usage: unknown): Record | undefined { +/** + * Map Google `usageMetadata` to the OpenAI-style usage shape consumed by + * `updateRequestUsageSummary` (exported for the transport usage accounting). + */ +export function normalizeGoogleUsage(usage: unknown): Record | undefined { if (!isRecord(usage)) { return undefined; } @@ -498,7 +504,7 @@ function normalizeGoogleUsage(usage: unknown): Record | undefin }; } -function normalizeGoogleFinishReason( +export function normalizeGoogleFinishReason( finishReason: string | undefined, hasToolCalls: boolean, ): "stop" | "tool_calls" | "length" | "content_filter" | null { diff --git a/src/provider/OpenCodeProvider.ts b/src/provider/OpenCodeProvider.ts index 0db3745f3..712ed9d26 100644 --- a/src/provider/OpenCodeProvider.ts +++ b/src/provider/OpenCodeProvider.ts @@ -401,7 +401,7 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider = (() => { // Routes derive from the configured apiBaseUrl setting so a custom compatible // gateway replaces the built-in opencode.ai endpoints end-to-end (issue #247). - const goRoutes = apiRoutesFromBaseUrl(configuredApiBaseUrl(GO_VENDOR)); + const goBase = configuredApiBaseUrl(GO_VENDOR); + const goRoutes = apiRoutesFromBaseUrl(goBase); const zenRoutes = apiRoutesFromBaseUrl(configuredApiBaseUrl(ZEN_VENDOR)); const go: ProviderDefinition = { vendor: GO_VENDOR, displayName: "OpenCode Go", modelNamePrefix: "OpenCode Go", modelsUrl: goRoutes.modelsUrl, + // Go's Google-native (Gemini) route sits on its own base on the built-in + // gateway (DEFAULT_GO_GOOGLE_MODELS_URL); a configured apiBaseUrl keeps + // driving every route end-to-end (issue #247). + googleModelsUrl: goBase === DEFAULT_GO_API_BASE_URL ? DEFAULT_GO_GOOGLE_MODELS_URL : goRoutes.modelsUrl, chatCompletionsUrl: goRoutes.chatCompletionsUrl, messagesUrl: goRoutes.messagesUrl, responsesUrl: goRoutes.responsesUrl, diff --git a/src/provider/settings.ts b/src/provider/settings.ts index 0f8c43016..613998b85 100644 --- a/src/provider/settings.ts +++ b/src/provider/settings.ts @@ -11,6 +11,7 @@ import { SETTING_STRIP_THINK_TAGS, SETTING_TEMPERATURE, SETTING_THINKING_DEEPSEEK, + SETTING_THINKING_GEMINI, SETTING_THINKING_GENERIC, SETTING_THINKING_GLM, SETTING_THINKING_KIMI, @@ -44,6 +45,7 @@ export const THINKING_ALLOWED_VALUES = { qwenBudget: ["auto", "4096", "16384", "32768", "81920"], mimo: ["off", "low", "medium", "high"], muse: ["off", "low", "medium", "high", "xhigh"], + gemini: ["off", "on", "low", "medium", "high"], generic: ["off", "low", "medium", "high", "xhigh", "max"], } as const; @@ -197,6 +199,11 @@ export function getSettings(): ApiSettings { THINKING_ALLOWED_VALUES.muse, THINKING_DEFAULTS.muse, ), + gemini: validThinkingValue( + readConfigValue(config, SETTING_THINKING_GEMINI, THINKING_DEFAULTS.gemini), + THINKING_ALLOWED_VALUES.gemini, + THINKING_DEFAULTS.gemini, + ), generic: validThinkingValue( readConfigValue(config, SETTING_THINKING_GENERIC, THINKING_DEFAULTS.generic), THINKING_ALLOWED_VALUES.generic, diff --git a/src/providerTypes.ts b/src/providerTypes.ts index f305b266e..0434c32d0 100644 --- a/src/providerTypes.ts +++ b/src/providerTypes.ts @@ -19,5 +19,12 @@ export interface ProviderRoutingDefinition { chatCompletionsUrl: string; messagesUrl: string; modelsUrl: string; + /** + * Base of the Google-native generate route (`{base}/{modelId}:streamGenerateContent`). + * Absent = same base as `modelsUrl` (Zen and custom gateways). Only Go's + * built-in gateway serves that route on a different base (issue #247 keeps a + * configured `apiBaseUrl` in charge of every route). + */ + googleModelsUrl?: string; responsesUrl?: string; } diff --git a/src/request/google.ts b/src/request/google.ts index 1c5dfe9f6..76d49d7ea 100644 --- a/src/request/google.ts +++ b/src/request/google.ts @@ -1,32 +1,46 @@ /** * Google (Gemini) request body builder. * - * Builds the Google generateContent wire payload (Gemini models on the Zen - * gateway). + * Builds the Google generateContent wire payload (Gemini models on the Zen and + * Go gateways). * * CONTRACT: pure functions only — `vscode` is used as a TYPE and for the * `LanguageModelChatToolMode` enum value only; no extension-host side effects. */ import * as vscode from "vscode"; import { joinedTextContent } from "../responsesRequest"; +import { thinkingProviderFor } from "../thinking"; +import type { ResolvedModelMetadata } from "../models/metadata"; import { parseToolInput } from "../toolCallAccumulator"; import { sanitizeToolSchema } from "./schema"; +import { messagesHaveImages } from "./shared"; import type { ModelLimits } from "../models/modelLimits"; import type { ApiMessage, ApiSettings } from "./types"; export function buildGoogleGenerateContentBody( + modelId: string, messages: ApiMessage[], options: vscode.ProvideLanguageModelChatResponseOptions, settings: ApiSettings, + metadata: ResolvedModelMetadata, limits: ModelLimits, ): Record { const tools = mapGoogleTools(options.tools); + // Google-native thinking: the Gemini strategy returns a `thinkingConfig` + // fragment that belongs INSIDE `generationConfig` (unlike the flat + // OpenAI/Anthropic shapes). + const thinkingPayload = thinkingProviderFor(modelId, metadata).buildPayload(settings.thinking, { + hasImageInput: messagesHaveImages(messages), + endpoint: "google", + }); return { contents: googleContentsFromMessages(messages), generationConfig: { maxOutputTokens: limits.maxOutputTokens, - temperature: settings.temperature, + // Only send temperature if the model supports it (not deprecated) + ...(metadata.temperature !== false ? { temperature: settings.temperature } : {}), + ...thinkingPayload, }, ...(tools.length ? { tools: [{ functionDeclarations: tools }], toolConfig: googleToolConfig(options.toolMode) } : {}), }; diff --git a/src/test/extractors.test.ts b/src/test/extractors.test.ts index 40af54e46..c18c29c27 100644 --- a/src/test/extractors.test.ts +++ b/src/test/extractors.test.ts @@ -305,6 +305,51 @@ describe("updateRequestUsageSummary — Responses nested usage", () => { }); }); +describe("updateRequestUsageSummary — Google usageMetadata + finishReason", () => { + let updateRequestUsageSummary: typeof import("../transports/extract.js").updateRequestUsageSummary; + + before(async () => { + const mod = await import("../transports/extract.js"); + updateRequestUsageSummary = mod.updateRequestUsageSummary; + }); + + it("maps usageMetadata counts (thoughts + cached content included)", () => { + const summary: { promptTokens?: number; completionTokens?: number; totalTokens?: number; cachedTokens?: number } = {}; + updateRequestUsageSummary(summary, { + candidates: [{ content: { parts: [{ text: "hi" }] }, finishReason: "STOP" }], + usageMetadata: { + promptTokenCount: 38026, + candidatesTokenCount: 78, + thoughtsTokenCount: 620, + totalTokenCount: 38724, + cachedContentTokenCount: 32768, + }, + }); + assert.equal(summary.promptTokens, 38026); + assert.equal(summary.completionTokens, 698); + assert.equal(summary.totalTokens, 38724); + assert.equal(summary.cachedTokens, 32768); + }); + + it("maps candidates[].finishReason (STOP → stop, MAX_TOKENS → length)", () => { + const summary: { finishReason?: string } = {}; + updateRequestUsageSummary(summary, { candidates: [{ content: { parts: [{ text: "hi" }] }, finishReason: "STOP" }] }); + assert.equal(summary.finishReason, "stop"); + + const lengthSummary: { finishReason?: string } = {}; + updateRequestUsageSummary(lengthSummary, { candidates: [{ finishReason: "MAX_TOKENS" }] }); + assert.equal(lengthSummary.finishReason, "length"); + }); + + it("maps a Gemini tool-call finish to 'tool_calls'", () => { + const summary: { finishReason?: string } = {}; + updateRequestUsageSummary(summary, { + candidates: [{ content: { parts: [{ functionCall: { name: "read_file" } }] }, finishReason: "STOP" }], + }); + assert.equal(summary.finishReason, "tool_calls"); + }); +}); + describe("OpenAiResponseExtractor — thinkTag carry flushing before tool calls", () => { it("emits pending thinkFilter carry as TextPart before tool calls on finish_reason: 'tool_calls'", () => { const filter = new ThinkTagFilter(); diff --git a/src/test/registry.test.ts b/src/test/registry.test.ts index 4c3f4cbde..5cced10b3 100644 --- a/src/test/registry.test.ts +++ b/src/test/registry.test.ts @@ -11,6 +11,7 @@ const goProvider: ProviderRoutingDefinition = { chatCompletionsUrl: "https://go.example/v1/chat/completions", messagesUrl: "https://go.example/v1/messages", modelsUrl: "https://go.example/v1/models", + googleModelsUrl: "https://go.example/google/v1/models", responsesUrl: "https://go.example/v1/responses", }; const zenProvider: ProviderRoutingDefinition = { @@ -51,10 +52,25 @@ describe("model registry — data-driven transport routing", () => { } }); - it("routes Gemini to the Google API on Zen but chat-completions on Go", () => { + it("routes Gemini to the Google API on every vendor", () => { + // Zen has no dedicated Google base → the route builds from modelsUrl. assert.equal(resolveModelRouting("gemini-3.5-flash", zenProvider).endpointKind, "google"); assert.equal(resolveModelRouting("gemini-3.5-flash", zenProvider).endpointUrl, `${zenProvider.modelsUrl}/gemini-3.5-flash`); - assert.equal(resolveModelRouting("gemini-3.5-flash", goProvider).endpointKind, "chat-completions"); + // Go serves the Google route on its own base (googleModelsUrl). + assert.equal(resolveModelRouting("gemini-4-argon-contributor", goProvider).endpointKind, "google"); + assert.equal( + resolveModelRouting("gemini-4-argon-contributor", goProvider).endpointUrl, + `${goProvider.googleModelsUrl}/gemini-4-argon-contributor`, + ); + // Agent-host variants resolve to their base vendor first. + const agentGo: ProviderRoutingDefinition = { ...goProvider, vendor: "opencodego-agent" }; + const agentZen: ProviderRoutingDefinition = { ...zenProvider, vendor: "opencodezen-agent" }; + assert.equal(resolveModelRouting("gemini-4-argon-contributor", agentGo).endpointKind, "google"); + assert.equal( + resolveModelRouting("gemini-4-argon-contributor", agentGo).endpointUrl, + `${goProvider.googleModelsUrl}/gemini-4-argon-contributor`, + ); + assert.equal(resolveModelRouting("gemini-3.5-flash", agentZen).endpointKind, "google"); }); it("routes Muse Spark to the Responses API on every vendor", () => { @@ -103,11 +119,12 @@ describe("model registry — data-driven thinking family", () => { assert.equal(thinkingFamily("qwen3.7-max"), "qwen"); assert.equal(thinkingFamily("mimo-v2.5"), "mimo"); assert.equal(thinkingFamily("muse-spark-1.2-contributor"), "muse"); + assert.equal(thinkingFamily("gemini-4-argon-contributor"), "gemini"); + assert.equal(thinkingFamily("gemini-3.5-flash"), "gemini"); }); it("returns null for families without a dedicated strategy", () => { assert.equal(thinkingFamily("claude-sonnet-4-6"), null); - assert.equal(thinkingFamily("gemini-3.5-flash"), null); assert.equal(thinkingFamily("hy3"), null); assert.equal(thinkingFamily("unknown-model"), null); }); @@ -115,14 +132,15 @@ describe("model registry — data-driven thinking family", () => { describe("model registry — lookup mechanics", () => { it("honors vendor restrictions when a vendor is given", () => { - assert.equal(lookupModelRegistryEntry("gemini-3.5-flash", "opencodezen").endpointKind, "google"); - assert.equal(lookupModelRegistryEntry("gemini-3.5-flash", "opencodego").endpointKind, "chat-completions"); assert.equal(lookupModelRegistryEntry("minimax-m2.7", "opencodego").endpointKind, "messages"); assert.equal(lookupModelRegistryEntry("minimax-m2.7", "opencodezen").endpointKind, "chat-completions"); + // Gemini is NOT vendor-restricted — both vendors serve it Google-native. + assert.equal(lookupModelRegistryEntry("gemini-4-argon-contributor", "opencodego").endpointKind, "google"); + assert.equal(lookupModelRegistryEntry("gemini-3.5-flash", "opencodezen").endpointKind, "google"); }); it("ignores vendor restrictions when no vendor is given (thinking-family lookup)", () => { - assert.equal(lookupModelRegistryEntry("gemini-3.5-flash").thinkingFamily, null); + assert.equal(lookupModelRegistryEntry("gemini-3.5-flash").thinkingFamily, "gemini"); assert.equal(lookupModelRegistryEntry("minimax-m2.7").thinkingFamily, "minimax"); }); diff --git a/src/test/thinking.test.ts b/src/test/thinking.test.ts index 97a211f7b..7f0c92abc 100644 --- a/src/test/thinking.test.ts +++ b/src/test/thinking.test.ts @@ -22,6 +22,7 @@ const defaultSettings: ThinkingSettings = { qwenBudget: "auto", mimo: "off", muse: "off", + gemini: "off", generic: "off", }; @@ -339,8 +340,10 @@ describe("FallbackThinking — unknown models without reasoning support", () => describe("FallbackThinking — family-less models on non-chat endpoints stay inert", () => { // `reasoning_effort` means nothing on these wire formats, so a picker there // would be a control that silently does nothing (or 400s the request). - it("claude-*, gemini-* and grok-* get neither picker nor payload", () => { - for (const id of ["claude-sonnet-4.6", "gemini-3.5-pro", "grok-4.6"]) { + // gemini-* used to sit here too — it now owns a Google-native strategy (see + // the GeminiThinking block). + it("claude-* and grok-* get neither picker nor payload", () => { + for (const id of ["claude-sonnet-4.6", "grok-4.6"]) { const provider = thinkingProviderFor(id, genericReasoningMetadata); assert.equal(provider.schema(genericReasoningMetadata), undefined, `expected no picker for ${id}`); assert.deepEqual(provider.buildPayload({ ...defaultSettings, generic: "high" }), {}, `expected no payload for ${id}`); @@ -450,6 +453,40 @@ describe("MuseThinking — payload + display (native reasoning model)", () => { }); }); +describe("GeminiThinking — payload (Google thinkingConfig)", () => { + it("gemini 'off' emits empty object (model default, no thinking config)", () => { + const payload = thinkingProviderFor("gemini-4-argon-contributor").buildPayload({ ...defaultSettings, gemini: "off" }); + assert.deepEqual(payload, {}); + }); + + it("gemini 'high' emits thinkingConfig.thinkingLevel + includeThoughts", () => { + const payload = thinkingProviderFor("gemini-4-argon-contributor").buildPayload( + { ...defaultSettings, gemini: "high" }, + { endpoint: "google" }, + ); + assert.deepEqual(payload, { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }); + }); + + it("gemini 'on' surfaces thoughts without pinning a level", () => { + const payload = thinkingProviderFor("gemini-4-argon-contributor").buildPayload({ ...defaultSettings, gemini: "on" }); + assert.deepEqual(payload, { thinkingConfig: { includeThoughts: true } }); + }); + + it("requestsThinking mirrors the picked level", () => { + const provider = thinkingProviderFor("gemini-4-argon-contributor"); + assert.equal(provider.requestsThinking({ ...defaultSettings, gemini: "off" }), false); + assert.equal(provider.requestsThinking({ ...defaultSettings, gemini: "low" }), true); + }); + + it("never surfaces reasoning_content as visible text (thought parts are CoT)", () => { + const provider = thinkingProviderFor("gemini-3.5-flash"); + assert.equal( + provider.treatReasoningAsContent("https://opencode.ai/zen/v1/models/gemini-3.5-flash", { ...defaultSettings, gemini: "high" }), + false, + ); + }); +}); + describe("GLMThinking — payload (issue #61)", () => { it("glm-5.2 with glm='high' emits reasoning_effort: 'high'", () => { const payload = thinkingProviderFor("glm-5.2").buildPayload({ ...defaultSettings, glm: "high" }); @@ -743,6 +780,7 @@ describe("schema defaults stay aligned with THINKING_DEFAULTS", () => { ["qwen3.6-plus", "qwen"], ["mimo-v2.5", "mimo"], ["muse-spark-1.2-contributor", "muse"], + ["gemini-4-argon-contributor", "gemini"], ]; for (const [modelId, key] of cases) { const schema = thinkingProviderFor(modelId).schema(); diff --git a/src/thinking.ts b/src/thinking.ts index 3a91cb908..37726b223 100644 --- a/src/thinking.ts +++ b/src/thinking.ts @@ -18,6 +18,8 @@ export { MiniMaxThinking } from "./thinking/minimax"; export { OpenAiThinking } from "./thinking/openai"; export { QwenThinking } from "./thinking/qwen"; export { MimoThinking } from "./thinking/mimo"; +export { MuseThinking } from "./thinking/muse"; +export { GeminiThinking } from "./thinking/gemini"; export { FallbackThinking } from "./thinking/fallback"; export type { ThinkingSettings, diff --git a/src/thinking/fallback.ts b/src/thinking/fallback.ts index e804e8b94..a00badffd 100644 --- a/src/thinking/fallback.ts +++ b/src/thinking/fallback.ts @@ -13,11 +13,12 @@ * * The generic payload is only valid on the OpenAI-compatible chat-completions * endpoint. Family-less models routed elsewhere — `claude-*` to Anthropic - * messages, `gemini-*` to Google, `grok-*` to the Responses API — keep their - * existing no-op behaviour: `reasoning_effort` means nothing on those wire - * formats, so surfacing a picker there would be a control that silently does - * nothing (or gets the request rejected). `endpointKind` comes from the shared - * model registry, so this stays data-driven rather than a name list. + * messages, `grok-*` to the Responses API — keep their existing no-op + * behaviour: `reasoning_effort` means nothing on those wire formats, so + * surfacing a picker there would be a control that silently does nothing (or + * gets the request rejected). `endpointKind` comes from the shared model + * registry, so this stays data-driven rather than a name list. (Gemini has its + * own Google-native strategy — see `thinking/gemini.ts`.) * * `reasoning_content` is always treated as genuine CoT (never visible text). */ diff --git a/src/thinking/gemini.ts b/src/thinking/gemini.ts new file mode 100644 index 000000000..3a883843e --- /dev/null +++ b/src/thinking/gemini.ts @@ -0,0 +1,86 @@ +/** + * Gemini thinking strategy (Google Generative Language API wire format). + * + * Gemini models speak Google's native `generationConfig.thinkingConfig`: + * `thinkingLevel` pins the reasoning depth and `includeThoughts: true` requests + * thought summaries so they could stream to the thinking panel while the model + * works. + * + * NOTE (verified 2026-10-15 against `gemini-4-argon-contributor`): the gateway + * accepts `thinkingConfig` (`thinkingLevel: "high"` → HTTP 200) and the model + * does think — `usageMetadata.thoughtsTokenCount` is billed and included in the + * completion count — but no thought SUMMARY text is ever relayed. Each stream + * carries only the answer parts and a final `{"text":"","thoughtSignature":"…"}` + * part whose value is an opaque signature (thought-context continuation token, + * not decodable text). The thinking panel therefore stays empty for Gemini even + * when thinking is enabled. This is an upstream limitation in the same class as + * Muse Spark's `encrypted_content` (see `./muse.ts`), not a parsing gap: there + * is no thought text in the payload to map. If upstream starts relaying + * `thought: true` parts, `core/routing.ts` already maps them to + * `reasoning_content` with no further client work. + * + * DISPLAY: Gemini thought parts are genuine CoT (echoed back as `thought: true` + * parts on follow-up turns, see `request/google.ts`) → never visible text. + * + * CONTRACT: the payload is a `thinkingConfig` fragment — `request/google.ts` + * merges it INSIDE `generationConfig` (unlike the flat OpenAI/Anthropic shapes). + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../models/metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +const GEMINI_EFFORTS = ["off", "on", "low", "medium", "high"] as const; + +export class GeminiThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "gemini"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + return ( + schemaFromReasoningOptions(metadata) ?? { + properties: { + reasoningEffort: effortProperty({ + enum: GEMINI_EFFORTS, + labels: ["Off", "On", "Low", "Medium", "High"], + descriptions: [ + "Fastest responses", + "Model-default thinking with visible thoughts", + "Faster responses with less reasoning", + "Balanced reasoning and speed", + "Greater reasoning depth but slower", + ], + default: "off", + }), + }, + } + ); + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + return this.applyEffort(settings, override, "gemini", GEMINI_EFFORTS); + } + + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + const level = thinking.gemini; + if (level === "off") { + return {}; + } + // "on" = model-default thinking, only surfaced; a level additionally pins depth. + return { + thinkingConfig: { + includeThoughts: true, + ...(level === "on" ? {} : { thinkingLevel: level }), + }, + }; + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.gemini !== "off"; + } + + // treatReasoningAsContent: thought parts are genuine CoT → never content. +} diff --git a/src/thinking/provider.ts b/src/thinking/provider.ts index 4fc269708..2153a5976 100644 --- a/src/thinking/provider.ts +++ b/src/thinking/provider.ts @@ -22,6 +22,7 @@ import { OpenAiThinking } from "./openai"; import { QwenThinking } from "./qwen"; import { MimoThinking } from "./mimo"; import { MuseThinking } from "./muse"; +import { GeminiThinking } from "./gemini"; import { FallbackThinking } from "./fallback"; /** Strategy interface implemented by each per-provider thinking class. */ @@ -75,6 +76,8 @@ export function thinkingProviderFor(modelId: string, metadata?: ResolvedModelMet return new MimoThinking(modelId); case "muse": return new MuseThinking(modelId); + case "gemini": + return new GeminiThinking(modelId); default: return new FallbackThinking(modelId, metadata); } diff --git a/src/thinking/types.ts b/src/thinking/types.ts index 79e7d6546..4300119df 100644 --- a/src/thinking/types.ts +++ b/src/thinking/types.ts @@ -15,6 +15,13 @@ export interface ThinkingSettings { qwenBudget: "auto" | "4096" | "16384" | "32768" | "81920"; mimo: "off" | "low" | "medium" | "high"; muse: "off" | "low" | "medium" | "high" | "xhigh"; + /** + * Google-native (Gemini) thinking level sent as + * `generationConfig.thinkingConfig.thinkingLevel`. `"off"` sends no thinking + * config at all (the model's own default applies); `"on"` surfaces thoughts + * without pinning a level. + */ + gemini: "off" | "on" | "low" | "medium" | "high"; /** * Effort for models with no dedicated thinking family, sent as a plain * OpenAI-compatible `reasoning_effort`. `"off"` sends nothing. @@ -27,7 +34,7 @@ export interface ThinkingSettings { } /** Detected thinking family for a raw model id. `null` = no known family. */ -export type ThinkingFamily = "deepseek" | "glm" | "kimi" | "minimax" | "openai" | "qwen" | "mimo" | "muse" | null; +export type ThinkingFamily = "deepseek" | "glm" | "kimi" | "minimax" | "openai" | "qwen" | "mimo" | "muse" | "gemini" | null; /** * Which configuration layer supplied the effective thinking value. @@ -56,6 +63,6 @@ export interface ThinkingOverride { export interface BuildThinkingPayloadOptions { /** Whether the request carries image input (affects Qwen auto behavior). */ hasImageInput?: boolean; - /** Endpoint the request will hit — changes the payload shape (Qwen). */ - endpoint?: "chat" | "messages" | "responses"; + /** Endpoint the request will hit — changes the payload shape (Qwen, Gemini). */ + endpoint?: "chat" | "messages" | "responses" | "google"; } diff --git a/src/transports/engine.ts b/src/transports/engine.ts index 66d79a98e..78a0d6b85 100644 --- a/src/transports/engine.ts +++ b/src/transports/engine.ts @@ -487,26 +487,28 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti ); } - // Diagnostic: when the gateway reported completion tokens but our - // extractor found nothing, dump raw SSE data to identify format mismatches. - // This helps diagnose issues like #93 where the model generates tokens - // but the response content is in an unrecognized format. + // Diagnostic: when the stream delivered data frames but nothing extracted + // into parts, dump the raw SSE events to identify format mismatches between + // gateway output and our extractor. This helps diagnose issues like #93 + // where the model generates content in an unrecognized format (e.g. the + // empty Google-route response that arrived as 5 unparsed events). + // + // NOTE: the earlier `completionTokens > 0` precondition made this dump + // unreachable on transports whose usage fields weren't captured (Google + // `usageMetadata`), so "bytes arrived, nothing extracted" must not depend + // on usage accounting. // // ISSUE #217 follow-up: skip the dump when a healthy finish_reason was // extracted. Tool-call responses on the Responses API (gpt-5.6-luna) emit // parts via the transport's finally flush AFTER this block runs, so // extractedPartCount is legitimately 0 here while the stream is perfectly - // healthy — the dump would false-positive on every tool-call turn. + // healthy — the dump would false-positive on every tool-call turn. A + // healthy-finish-but-empty stream is left to the response-summary line and + // `opencodego.debugReasoning` instead. const healthyFinish = typeof usageSummary.finishReason === "string" && usageSummary.finishReason !== "error"; - if ( - usageSummary.completionTokens && - usageSummary.completionTokens > 0 && - extractedPartCount === 0 && - !healthyFinish && - rawSseData.length > 0 - ) { + if (extractedPartCount === 0 && !healthyFinish && rawSseData.length > 0) { options.output?.appendLine( - `[diag-empty-response] model=${options.modelId} completionTokens=${String(usageSummary.completionTokens)} totalEvents=${String(totalEvents)} rawSseDataCount=${String(rawSseData.length)}`, + `[diag-empty-response] model=${options.modelId} completionTokens=${String(usageSummary.completionTokens ?? "n/a")} totalEvents=${String(totalEvents)} rawSseDataCount=${String(rawSseData.length)}`, ); for (let i = 0; i < rawSseData.length; i++) { options.output?.appendLine(`[diag-sse-event-${String(i)}] ${truncateForLog(JSON.stringify(rawSseData[i]))}`); diff --git a/src/transports/extract.ts b/src/transports/extract.ts index 0d59d189b..fc5169b6d 100644 --- a/src/transports/extract.ts +++ b/src/transports/extract.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { normalizeGoogleFinishReason, normalizeGoogleUsage } from "../core/routing"; import { parseToolInput } from "../toolCallAccumulator"; import { isRecord } from "../utils"; import { thinkingPartConstructor, type RequestUsageSummary } from "./streamParts"; @@ -178,10 +179,11 @@ export function updateRequestUsageSummary(summary: RequestUsageSummary, data: un } // Responses API nests usage under response.usage (response.completed event); - // OpenAI/Anthropic put it at top-level usage. Check both. + // OpenAI/Anthropic put it at top-level usage. Google (Gemini) sends camelCase + // counts under `usageMetadata`, normalized to the same OpenAI shape. Check all. const rawUsage = isRecord(data.usage) ? data.usage : undefined; const responseUsage = isRecord(data.response) && isRecord(data.response.usage) ? data.response.usage : undefined; - const usage = rawUsage ?? responseUsage; + const usage = rawUsage ?? responseUsage ?? normalizeGoogleUsage(data.usageMetadata); if (usage) { // OpenAI-compatible fields const promptTokens = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : undefined; @@ -240,6 +242,16 @@ export function updateRequestUsageSummary(summary: RequestUsageSummary, data: un if (firstChoice && typeof firstChoice.finish_reason === "string") { summary.finishReason = firstChoice.finish_reason; } + + // Google (Gemini) reports `candidates[0].finishReason` (STOP/MAX_TOKENS/…) + // on raw events — the Google normalizer maps it for the extractor only, so + // the summary needs its own capture here. + const googleCandidate = Array.isArray(data.candidates) && isRecord(data.candidates[0]) ? data.candidates[0] : undefined; + if (googleCandidate && typeof googleCandidate.finishReason === "string") { + const parts = isRecord(googleCandidate.content) && Array.isArray(googleCandidate.content.parts) ? googleCandidate.content.parts : []; + const hasToolCalls = parts.some((part) => isRecord(part) && isRecord(part.functionCall)); + summary.finishReason = normalizeGoogleFinishReason(googleCandidate.finishReason, hasToolCalls) ?? googleCandidate.finishReason; + } } export { extractChatCompletionParts, extractAnthropicParts, toolCallPartsFromOpenAiMessage };