mirror of
https://github.com/coder/coder.git
synced 2026-11-01 04:47:17 +08:00
## 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 -->
179 lines
6.5 KiB
Go
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
|
|
}
|
|
}
|