mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +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 -->
373 lines
13 KiB
Go
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
|
|
}
|