Files
coder/coderd/x/chatd/chatprovider/reasoningeffort.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

373 lines
13 KiB
Go

package chatprovider
import (
"slices"
"strconv"
"strings"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasyazure "charm.land/fantasy/providers/azure"
fantasybedrock "charm.land/fantasy/providers/bedrock"
fantasygoogle "charm.land/fantasy/providers/google"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
fantasyvercel "charm.land/fantasy/providers/vercel"
"github.com/coder/coder/v2/codersdk"
)
func reasoningEffortRank(value string) (int, bool) {
rank := slices.Index(codersdk.ChatModelReasoningEffortValues(), value)
return rank, rank >= 0
}
func IsValidReasoningEffort(value string) bool {
_, ok := reasoningEffortRank(value)
return ok
}
// ReasoningEffortLessOrEqual reports whether a is lower than or equal
// to b on the global effort scale. Unknown values return false.
func ReasoningEffortLessOrEqual(a, b string) bool {
aRank, aOK := reasoningEffortRank(a)
bRank, bOK := reasoningEffortRank(b)
return aOK && bOK && aRank <= bRank
}
// ResolveReasoningEffort computes the effective reasoning effort for a
// generation. The requested per-turn value wins over the config's default,
// and the result is clamped to the config's max on the global scale. Returns
// nil when the model config has no reasoning effort configured, no usable
// value remains, or the max is unknown.
func ResolveReasoningEffort(
requested *string,
config *codersdk.ChatModelReasoningEffortConfig,
) *string {
if config == nil {
return nil
}
effective := requested
var rank int
var ok bool
if effective != nil {
rank, ok = reasoningEffortRank(*effective)
}
if !ok {
effective = config.Default
if effective != nil {
rank, ok = reasoningEffortRank(*effective)
}
}
if !ok {
return nil
}
if config.Max != nil {
maxRank, ok := reasoningEffortRank(*config.Max)
if !ok {
return nil
}
if rank > maxRank {
return config.Max
}
}
return effective
}
func SelectableReasoningEfforts(
config *codersdk.ChatModelReasoningEffortConfig,
) []string {
if config == nil || config.Max == nil {
return nil
}
maxRank, ok := reasoningEffortRank(*config.Max)
if !ok {
return nil
}
values := codersdk.ChatModelReasoningEffortValues()
return values[:maxRank+1]
}
func applyReasoningEffort(
model Model,
options fantasy.ProviderOptions,
effort *string,
) fantasy.ProviderOptions {
if effort == nil || !model.Valid() {
return options
}
if options == nil {
options = fantasy.ProviderOptions{}
}
switch NormalizeProvider(model.Provider()) {
case fantasyopenai.Name, fantasyazure.Name:
providerEffort := fantasyopenai.ReasoningEffort(*effort)
switch opts := options[fantasyopenai.Name].(type) {
case *fantasyopenai.ResponsesProviderOptions:
opts.ReasoningEffort = &providerEffort
case *fantasyopenai.ProviderOptions:
opts.ReasoningEffort = &providerEffort
default:
if model.transport.UsesResponses() {
options[fantasyopenai.Name] = &fantasyopenai.ResponsesProviderOptions{
ReasoningEffort: &providerEffort,
}
return options
}
options[fantasyopenai.Name] = &fantasyopenai.ProviderOptions{
ReasoningEffort: &providerEffort,
}
}
case fantasyanthropic.Name, fantasybedrock.Name:
providerEffort := fantasyanthropic.Effort(*effort)
providerOptions := ensureProviderOptions[fantasyanthropic.ProviderOptions](options, fantasyanthropic.Name)
providerOptions.Effort = &providerEffort
case fantasygoogle.Name:
// Only Gemini 3+ accepts thinking_level; older generations reject
// requests carrying it, so keep dropping the effort for them.
supported := googleSupportedThinkingLevels(model.ModelID())
if len(supported) == 0 {
return options
}
providerOptions := ensureProviderOptions[fantasygoogle.ProviderOptions](options, fantasygoogle.Name)
if providerOptions.ThinkingConfig == nil {
providerOptions.ThinkingConfig = &fantasygoogle.ThinkingConfig{}
}
// A configured thinking budget wins: fantasy rejects requests that
// set both thinking_budget and thinking_level. The resolved effort
// overrides a config-pinned thinking_level so the user's effort
// selection stays meaningful.
if providerOptions.ThinkingConfig.ThinkingBudget == nil {
level := clampGoogleThinkingLevel(googleThinkingLevel(*effort), supported)
providerOptions.ThinkingConfig.ThinkingLevel = &level
}
// Google returns thought summaries only when they are requested, so
// default them on for reasoning-effort generations; an explicitly
// configured include_thoughts (either value) is preserved.
if providerOptions.ThinkingConfig.IncludeThoughts == nil {
includeThoughts := true
providerOptions.ThinkingConfig.IncludeThoughts = &includeThoughts
}
case fantasyopenaicompat.Name:
providerEffort := fantasyopenai.ReasoningEffort(*effort)
if compatEffort, ok := googleCompatReasoningEffort(model.ModelID(), *effort); ok {
providerEffort = fantasyopenai.ReasoningEffort(compatEffort)
}
providerOptions := ensureProviderOptions[fantasyopenaicompat.ProviderOptions](options, fantasyopenaicompat.Name)
providerOptions.ReasoningEffort = &providerEffort
case fantasyopenrouter.Name:
providerEffort := fantasyopenrouter.ReasoningEffort(*effort)
providerOptions := ensureProviderOptions[fantasyopenrouter.ProviderOptions](options, fantasyopenrouter.Name)
if providerOptions.Reasoning == nil {
providerOptions.Reasoning = &fantasyopenrouter.ReasoningOptions{}
}
providerOptions.Reasoning.Effort = &providerEffort
case fantasyvercel.Name:
providerEffort := fantasyvercel.ReasoningEffort(*effort)
providerOptions := ensureProviderOptions[fantasyvercel.ProviderOptions](options, fantasyvercel.Name)
if providerOptions.Reasoning == nil {
providerOptions.Reasoning = &fantasyvercel.ReasoningOptions{}
}
providerOptions.Reasoning.Effort = &providerEffort
}
return options
}
// googleCompatReasoningEffort maps the global reasoning effort scale onto the
// reasoning_effort values a Gemini model accepts behind an OpenAI-compatible
// endpoint, reporting ok=false for non-Gemini model IDs. Google's compat
// layer translates reasoning_effort into the model's thinking configuration
// but validates instead of clamping, so out-of-range values (including the
// Coder-only xhigh and max) fail the whole request with HTTP 400.
func googleCompatReasoningEffort(modelID, effort string) (string, bool) {
normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/")
normalized = strings.TrimPrefix(normalized, "google/")
if !strings.HasPrefix(normalized, "gemini-") {
return "", false
}
if supported := googleSupportedThinkingLevels(normalized); len(supported) > 0 {
level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported)
return strings.ToLower(level), true
}
// Pre-Gemini-3 models translate reasoning_effort into a thinking budget
// and accept only none/low/medium/high. Pro models cannot disable
// thinking, so "none" is rejected for them and clamps up to low.
isPro := slices.Contains(strings.Split(strings.TrimPrefix(normalized, "gemini-"), "-"), "pro")
switch effort {
case codersdk.ChatModelReasoningEffortNone:
if isPro {
return codersdk.ChatModelReasoningEffortLow, true
}
return codersdk.ChatModelReasoningEffortNone, true
case codersdk.ChatModelReasoningEffortMinimal, codersdk.ChatModelReasoningEffortLow:
return codersdk.ChatModelReasoningEffortLow, true
case codersdk.ChatModelReasoningEffortMedium:
return codersdk.ChatModelReasoningEffortMedium, true
default:
return codersdk.ChatModelReasoningEffortHigh, true
}
}
// googleThinkingLevelsAscending orders Google thinking levels from least to
// most thinking, for clamping into a model's supported subset.
var googleThinkingLevelsAscending = []fantasygoogle.ThinkingLevel{
fantasygoogle.ThinkingLevelMinimal,
fantasygoogle.ThinkingLevelLow,
fantasygoogle.ThinkingLevelMedium,
fantasygoogle.ThinkingLevelHigh,
}
// googleSupportedThinkingLevels returns the thinking_level values the Google
// model accepts in ascending order, or nil when the model does not accept
// thinking_level at all. Gemini introduced thinking_level in version 3, and
// each model supports a different subset: Gemini 3 Pro launched with LOW and
// HIGH, 3.1 Pro added MEDIUM, the Flash family accepted all four through 3.6
// but 3.7 dropped MINIMAL, and image models accept only HIGH (plus MINIMAL
// for flash). Versionless "-latest" aliases track the newest release of their
// family, which has been Gemini 3+ since the aliases were introduced.
// Only the Pro and Flash chat families qualify; specialized variants such
// as live, TTS, and audio models reject thinking_level or generateContent
// entirely (verified live), so unrecognized name tokens fail closed and
// keep the previous request shape, matching googleSupportsThinkingBudget.
func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel {
normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/")
rest, ok := strings.CutPrefix(normalized, "gemini-")
if !ok {
return nil
}
segments := strings.Split(rest, "-")
major, minor, hasVersion := parseGoogleModelVersion(segments[0])
isLatestAlias := !hasVersion && segments[len(segments)-1] == "latest"
if hasVersion && major < 3 {
return nil
}
if !hasVersion && !isLatestAlias {
return nil
}
family := segments
if hasVersion {
family = segments[1:]
}
isPro, isFlash, isImage := false, false, false
for _, segment := range family {
switch segment {
case "pro":
isPro = true
case "flash":
isFlash = true
case "image":
isImage = true
case "lite", "preview", "exp", "latest":
default:
if _, err := strconv.Atoi(segment); err != nil {
return nil
}
}
}
if !isPro && !isFlash {
return nil
}
switch {
case isImage && isFlash:
return []fantasygoogle.ThinkingLevel{
fantasygoogle.ThinkingLevelMinimal,
fantasygoogle.ThinkingLevelHigh,
}
case isImage:
return []fantasygoogle.ThinkingLevel{fantasygoogle.ThinkingLevelHigh}
case isFlash:
// Gemini 3.7 Flash rejects MINIMAL with HTTP 400 while 3 through
// 3.6 flash and flash-lite models accept it (live-verified
// 2026-08-20). Latest aliases and unknown future versions stay
// off MINIMAL too: gemini-flash-latest already tracks 3.7 and
// rejects it, and clamping up to LOW is accepted everywhere
// while an unsupported level fails the whole request.
if isLatestAlias || major > 3 || minor >= 7 {
return []fantasygoogle.ThinkingLevel{
fantasygoogle.ThinkingLevelLow,
fantasygoogle.ThinkingLevelMedium,
fantasygoogle.ThinkingLevelHigh,
}
}
return slices.Clone(googleThinkingLevelsAscending)
case hasVersion && major == 3 && minor == 0:
return []fantasygoogle.ThinkingLevel{
fantasygoogle.ThinkingLevelLow,
fantasygoogle.ThinkingLevelHigh,
}
default:
return []fantasygoogle.ThinkingLevel{
fantasygoogle.ThinkingLevelLow,
fantasygoogle.ThinkingLevelMedium,
fantasygoogle.ThinkingLevelHigh,
}
}
}
// parseGoogleModelVersion parses a Gemini version segment such as "3" or
// "3.7" into major and minor components.
func parseGoogleModelVersion(segment string) (major, minor int, ok bool) {
majorText, minorText, hasMinor := strings.Cut(segment, ".")
major, err := strconv.Atoi(majorText)
if err != nil {
return 0, 0, false
}
if hasMinor {
minor, err = strconv.Atoi(minorText)
if err != nil {
return 0, 0, false
}
}
return major, minor, true
}
// clampGoogleThinkingLevel snaps the desired level into the model's supported
// subset: the lowest supported level at or above the desired one, so at least
// the requested reasoning depth is preserved, else the highest supported.
func clampGoogleThinkingLevel(
desired fantasygoogle.ThinkingLevel,
supported []fantasygoogle.ThinkingLevel,
) fantasygoogle.ThinkingLevel {
desiredRank := slices.Index(googleThinkingLevelsAscending, desired)
for _, candidate := range supported {
if slices.Index(googleThinkingLevelsAscending, candidate) >= desiredRank {
return candidate
}
}
return supported[len(supported)-1]
}
// googleThinkingLevel maps the global reasoning effort scale to Google
// thinking levels. Google offers no way to disable thinking on Gemini 3+
// models and no levels above HIGH, so the scale clamps at both ends.
func googleThinkingLevel(effort string) fantasygoogle.ThinkingLevel {
switch effort {
case codersdk.ChatModelReasoningEffortNone, codersdk.ChatModelReasoningEffortMinimal:
return fantasygoogle.ThinkingLevelMinimal
case codersdk.ChatModelReasoningEffortLow:
return fantasygoogle.ThinkingLevelLow
case codersdk.ChatModelReasoningEffortMedium:
return fantasygoogle.ThinkingLevelMedium
default:
return fantasygoogle.ThinkingLevelHigh
}
}
func ensureProviderOptions[T any, PT interface {
*T
fantasy.ProviderOptionsData
}](options fantasy.ProviderOptions, name string) PT {
providerOptions, _ := options[name].(PT)
if providerOptions == nil {
providerOptions = PT(new(T))
options[name] = providerOptions
}
return providerOptions
}