Files
coder/coderd/x/chatd/chatprovider/google_compat_thinking.go
T
Michael Suchacz 8eb1e43f0b feat: apply reasoning effort and surface thinking blocks for Google chat models (#28273)
## Problem

Selecting a reasoning effort on a Google (Gemini) chat model either got
dropped or broke the request outright, and even when the request
succeeded, Gemini chats never showed thinking blocks:

- The native `fantasygoogle` path had no case in `applyReasoningEffort`
at all, so effort was silently ignored. With only `include_thoughts`
configured, Gemini 3+ models default to no thinking, so chats produced
zero reasoning tokens.
- The path chat generation actually uses today routes Google providers
through aibridge as openai-compat clients and forwarded the raw effort
string as `reasoning_effort`. Google's OpenAI-compatible endpoint
validates that value against the model's supported thinking levels
instead of clamping, so out-of-range efforts fail the whole request with
HTTP 400. Verified against the live Gemini API: `gemini-3.1-pro-preview`
accepts low/medium/high and rejects none/minimal/xhigh/max. Because the
config's default effort is sent whenever the user selects nothing, a
config whose default falls outside the model's supported set failed
every no-selection message.
- Google's OpenAI-compatible endpoint never emits thought text for
`reasoning_effort` requests. Thoughts require
`extra_body.google.thinking_config` with `include_thoughts`, which is
mutually exclusive with `reasoning_effort`, and even then the thought
deltas come back inline in `content` (marked
`extra_content.google.thought`, wrapped in `<thought>` markers) rather
than in the `reasoning_content` field OpenAI-compatible clients parse.
So no reasoning parts were ever produced, persisted, or rendered for
Gemini chats (verified live in UAT).

## Fix

Reasoning effort:

- Map the global reasoning effort scale onto Google's `thinking_level`
in `applyReasoningEffort`, gated to Gemini 3+ models and clamped to each
model's supported subset (3.0 Pro launched with LOW/HIGH only, 3.1 Pro
added MEDIUM, Flash accepts all four through 3.6 while 3.7 and the
latest flash aliases drop MINIMAL, image models accept HIGH plus MINIMAL
for flash). Pre-Gemini-3 models reject `thinking_level`, so effort
degrades to a no-op for them and a config-pinned level is dropped at
call time instead of failing every generation.
- Add `thinking_level` to `ChatModelGoogleThinkingConfig` so admins can
pin a level for configs without user-selectable effort. A resolved
per-turn effort overrides the pinned level; an explicit
`thinking_budget` wins over both since the Google API rejects requests
that set `thinking_budget` and `thinking_level` together, and config
validation now rejects that combination up front. The admin panel picks
up the new field automatically through the generated model-options
schema.

Thinking blocks on the openai-compat path (the path chat generation
uses), translated in both directions at the existing Gemini transport
seam:

- chatd's OpenAI-compat request patch swaps `reasoning_effort` for an
equivalent `extra_body.google.thinking_config` with `include_thoughts`
enabled: `thinking_level` on Gemini 3+ (clamped to the model's supported
subset), `thinking_budget` on earlier models (effort `none` maps to
budget 0, which disables thinking). Non-Gemini models and non-Google
routes are untouched.
- The same transport rewrites Google's responses so thought output
surfaces as `reasoning_content`, which fantasy already converts into
reasoning parts: streaming bodies are rewritten per SSE line (preserving
streaming latency), JSON bodies in place, with the
`<thought>`/`</thought>` markers stripped at the transitions.
- aibridge's chat-completions interception preserves the `extra_body`
passthrough that openai-go's typed params silently drop, forwarding it
to Google upstreams only, so the chatd -> aibridge -> Google route
carries the thinking config end to end.

## Validation

- `go test ./coderd/x/chatd/... ./aibridge/... ./internal/googleopenai
./codersdk` pass; golangci-lint clean on touched packages.
- New unit tests cover the effort-to-level mapping for every effort
value, per-model clamping on both paths, the effort-to-thinking_config
request swap (level, budget, pinned-config precedence, non-Gemini
passthrough), SSE and JSON thought-response rewriting (fixtures mirror
live captures, including tool-call and no-thought streams), an
end-to-end fake-SSE test through the fantasy pipeline proving reasoning
deltas emerge with markers stripped, and aibridge `extra_body`
preservation (including proof the typed round trip drops it).
- Verified live against `generativelanguage.googleapis.com` on
`gemini-3-flash-preview` and `gemini-2.5-flash` through the real
chatprovider stack: reasoning deltas stream through with markers
stripped and answer text intact.
- Live UAT on a dogfood deployment (remote Coder Agents chat, 4 rounds):
effort clamping verified end to end (rounds 1-2), the missing-thoughts
limitation isolated (round 3), and the fix validated (round 4):
expandable, readable thinking blocks render for
`gemini-3.1-pro-preview`, `gemini-3-flash-preview`, and
`gemini-2.5-flash` at effort=high; no `<thought>` marker leaks;
effort=none produces no block and no 400; multi-turn history replay
works with a thinking block on each turn.
- `make gen` regenerated `typesGenerated.ts` and
`chatModelOptionsGenerated.json`; `pnpm -C site lint:types` passes.

> 🤖 Mux acted on Mike's behalf to create this pull request.

<!-- mux-attribution: model=claude-opus-4-6 thinking=high -->
2026-08-20 16:36:57 +02:00

179 lines
6.5 KiB
Go

package chatprovider
import (
"slices"
"strconv"
"strings"
fantasygoogle "charm.land/fantasy/providers/google"
"github.com/coder/coder/v2/codersdk"
)
// rewriteGoogleCompatThinkingConfig swaps reasoning_effort for an explicit
// Google thinking_config on requests for thinking-capable Gemini models.
// Google's OpenAI-compatible endpoint rejects requests carrying both fields,
// and it never emits thought text unless include_thoughts is requested
// through thinking_config, so this is the only way to surface Gemini
// reasoning in the chat UI on this path. Models without known thinking
// support (pre-2.5 or unrecognized Gemini variants) keep their previous
// request shape untouched.
func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool {
modelID, _ := payload["model"].(string)
normalized, supported, capable := googleCompatThinkingSupport(modelID)
if !capable {
return false
}
thinkingConfig := map[string]any{"include_thoughts": true}
if effort, ok := payload["reasoning_effort"].(string); ok {
if len(supported) > 0 {
level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported)
thinkingConfig["thinking_level"] = strings.ToLower(level)
} else if budget, ok := googleCompatThinkingBudget(normalized, effort); ok {
thinkingConfig["thinking_budget"] = budget
} else {
// Unknown effort value: keep the request untouched rather than
// guessing a thinking budget.
return false
}
}
extraBody, _ := payload["extra_body"].(map[string]any)
if extraBody == nil {
extraBody = map[string]any{}
payload["extra_body"] = extraBody
}
google, _ := extraBody["google"].(map[string]any)
if google == nil {
google = map[string]any{}
extraBody["google"] = google
}
// Merge with a config-pinned thinking_config using the same precedence
// as the native Google path: a pinned thinking_budget wins over the
// per-turn effort, while the effort overrides a pinned thinking_level.
// reasoning_effort must go in every case because Google rejects it in
// combination with thinking_config.
if pinned, ok := google["thinking_config"].(map[string]any); ok {
if _, hasBudget := pinned["thinking_budget"]; !hasBudget {
if level, ok := thinkingConfig["thinking_level"]; ok {
pinned["thinking_level"] = level
} else if budget, ok := thinkingConfig["thinking_budget"]; ok {
pinned["thinking_budget"] = budget
}
}
} else {
google["thinking_config"] = thinkingConfig
}
delete(payload, "reasoning_effort")
return true
}
// googleCompatThinkingSupport reports whether a model ID on the
// OpenAI-compatible path is a thinking-capable Gemini model, returning the
// normalized model ID and its supported thinking levels (empty for the
// budget-based 2.5 families).
func googleCompatThinkingSupport(modelID string) (string, []fantasygoogle.ThinkingLevel, bool) {
normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/")
normalized = strings.TrimPrefix(normalized, "google/")
if !strings.HasPrefix(normalized, "gemini-") {
return "", nil, false
}
supported := googleSupportedThinkingLevels(normalized)
if len(supported) == 0 && !googleSupportsThinkingBudget(normalized) {
return "", nil, false
}
return normalized, supported, true
}
// googleCompatExtraBodyFromThinkingConfig translates a config-pinned Google
// thinking configuration into the extra_body payload for Gemini models routed
// through the OpenAI-compatible client, which ignores the native Google
// provider options. include_thoughts defaults to enabled so pinned
// configurations still surface thinking in the chat UI. Returns nil when the
// model has no thinking support.
func googleCompatExtraBodyFromThinkingConfig(
modelID string,
config *codersdk.ChatModelGoogleThinkingConfig,
) map[string]any {
if config == nil {
return nil
}
_, supported, capable := googleCompatThinkingSupport(modelID)
if !capable {
return nil
}
includeThoughts := true
if config.IncludeThoughts != nil {
includeThoughts = *config.IncludeThoughts
}
thinkingConfig := map[string]any{"include_thoughts": includeThoughts}
if config.ThinkingBudget != nil {
thinkingConfig["thinking_budget"] = *config.ThinkingBudget
} else if pinned := GoogleThinkingLevelFromChat(config.ThinkingLevel); pinned != nil && len(supported) > 0 {
level := clampGoogleThinkingLevel(*pinned, supported)
thinkingConfig["thinking_level"] = strings.ToLower(level)
}
return map[string]any{
"extra_body": map[string]any{
"google": map[string]any{"thinking_config": thinkingConfig},
},
}
}
// googleSupportsThinkingBudget reports whether a Gemini model predating
// thinking_level thinks via thinking_budget. Only the Gemini 2.5 Pro, Flash,
// and Flash-Lite chat families qualify; specialized 2.5 variants such as
// image and TTS models reject thinking_config outright ("Thinking is not
// enabled for this model", verified live). Unrecognized name tokens fail
// closed so new specialized variants keep their previous request shape.
func googleSupportsThinkingBudget(normalized string) bool {
rest, ok := strings.CutPrefix(normalized, "gemini-")
if !ok {
return false
}
segments := strings.Split(rest, "-")
major, minor, hasVersion := parseGoogleModelVersion(segments[0])
if !hasVersion || major != 2 || minor != 5 {
return false
}
family := false
for _, segment := range segments[1:] {
switch segment {
case "pro", "flash":
family = true
case "lite", "preview", "exp", "latest":
default:
if _, err := strconv.Atoi(segment); err != nil {
return false
}
}
}
return family
}
// googleCompatThinkingBudget maps the global reasoning effort scale onto the
// thinking budgets Google's OpenAI-compatible endpoint uses when translating
// reasoning_effort for pre-Gemini-3 models, keeping effort semantics intact
// when reasoning_effort is replaced by an explicit thinking_config. Pro
// models cannot disable thinking (budget 0 is rejected: "This model only
// works in thinking mode"), so "none" clamps up to the low budget for them.
func googleCompatThinkingBudget(modelID string, effort string) (int, bool) {
switch effort {
case codersdk.ChatModelReasoningEffortNone:
if slices.Contains(strings.Split(strings.TrimPrefix(modelID, "gemini-"), "-"), "pro") {
return 1024, true
}
return 0, true
case codersdk.ChatModelReasoningEffortMinimal, codersdk.ChatModelReasoningEffortLow:
return 1024, true
case codersdk.ChatModelReasoningEffortMedium:
return 8192, true
case codersdk.ChatModelReasoningEffortHigh, codersdk.ChatModelReasoningEffortXHigh, codersdk.ChatModelReasoningEffortMax:
return 24576, true
default:
return 0, false
}
}