From c8687ddd3af1ff3b492ca29396f424ce1eeb8810 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:09:20 +0000 Subject: [PATCH 01/19] fix(coderd/x/chatd/chatprovider): map reasoning effort to Google thinking level applyReasoningEffort had no case for the google provider, so a chat's selected reasoning effort was silently dropped for Gemini models. With only include_thoughts configured, Gemini 3.7 Flash never thinks (reasoning_tokens is 0 on every step), so no reasoning parts stream, persist, or render. Map the global effort scale onto thinking_level (clamped to MINIMAL..HIGH), preserving any explicitly configured thinking_budget since fantasy rejects requests carrying both. --- .../x/chatd/chatprovider/reasoningeffort.go | 28 ++++++++++ .../reasoningeffort_internal_test.go | 51 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 52e8a016067..cc5510fe5f5 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -7,6 +7,7 @@ import ( 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" @@ -122,6 +123,17 @@ func applyReasoningEffort( providerEffort := fantasyanthropic.Effort(*effort) providerOptions := ensureProviderOptions[fantasyanthropic.ProviderOptions](options, fantasyanthropic.Name) providerOptions.Effort = &providerEffort + case fantasygoogle.Name: + 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. + if providerOptions.ThinkingConfig.ThinkingBudget == nil { + level := googleThinkingLevel(*effort) + providerOptions.ThinkingConfig.ThinkingLevel = &level + } case fantasyopenaicompat.Name: providerEffort := fantasyopenai.ReasoningEffort(*effort) providerOptions := ensureProviderOptions[fantasyopenaicompat.ProviderOptions](options, fantasyopenaicompat.Name) @@ -144,6 +156,22 @@ func applyReasoningEffort( return options } +// 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 diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index b7b4110a58d..0cd3d6c8ae1 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -6,6 +6,7 @@ import ( "charm.land/fantasy" fantasyanthropic "charm.land/fantasy/providers/anthropic" + fantasygoogle "charm.land/fantasy/providers/google" fantasyopenai "charm.land/fantasy/providers/openai" fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" fantasyopenrouter "charm.land/fantasy/providers/openrouter" @@ -92,6 +93,38 @@ func TestApplyReasoningEffort(t *testing.T) { require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) }, }, + { + name: "CreatesGoogleEntry", + provider: fantasygoogle.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok, "%T", got[fantasygoogle.Name]) + require.NotNil(t, providerOptions.ThinkingConfig) + require.NotNil(t, providerOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel) + }, + }, + { + name: "PreservesGoogleEntry", + provider: fantasygoogle.Name, + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{IncludeThoughts: ptr.Ref(true)}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, *providerOptions.ThinkingConfig.IncludeThoughts) + require.NotNil(t, providerOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel) + }, + }, + { + name: "GoogleExplicitBudgetWins", + provider: fantasygoogle.Name, + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{ThinkingBudget: ptr.Ref(int64(1024))}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.Equal(t, int64(1024), *providerOptions.ThinkingConfig.ThinkingBudget) + require.Nil(t, providerOptions.ThinkingConfig.ThinkingLevel) + }, + }, { name: "CreatesOpenAICompatEntry", provider: fantasyopenaicompat.Name, @@ -168,3 +201,21 @@ func TestApplyReasoningEffort(t *testing.T) { }) } } + +func TestGoogleThinkingLevel(t *testing.T) { + t.Parallel() + + want := map[string]fantasygoogle.ThinkingLevel{ + codersdk.ChatModelReasoningEffortNone: fantasygoogle.ThinkingLevelMinimal, + codersdk.ChatModelReasoningEffortMinimal: fantasygoogle.ThinkingLevelMinimal, + codersdk.ChatModelReasoningEffortLow: fantasygoogle.ThinkingLevelLow, + codersdk.ChatModelReasoningEffortMedium: fantasygoogle.ThinkingLevelMedium, + codersdk.ChatModelReasoningEffortHigh: fantasygoogle.ThinkingLevelHigh, + codersdk.ChatModelReasoningEffortXHigh: fantasygoogle.ThinkingLevelHigh, + codersdk.ChatModelReasoningEffortMax: fantasygoogle.ThinkingLevelHigh, + } + for _, effort := range codersdk.ChatModelReasoningEffortValues() { + require.Contains(t, want, effort, "effort %q missing an expected Google thinking level", effort) + require.Equal(t, want[effort], googleThinkingLevel(effort), "effort %q", effort) + } +} From 925b20ed23a7cbd6f10bd8d6b60583a5d68fea8b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:31:37 +0000 Subject: [PATCH 02/19] feat: add thinking_level to Google chat model thinking config Admins can pin a Gemini 3+ thinking level via provider_options.google.thinking_config.thinking_level (minimal, low, medium, high). The pinned level applies when the model config offers no reasoning effort selection; a resolved per-turn effort overrides it, and thinking_budget remains mutually exclusive per the Google API contract, enforced at config validation. --- coderd/exp_chats.go | 23 +++- coderd/exp_chats_internal_test.go | 47 +++++++ coderd/x/chatd/chatprovider/chatprovider.go | 22 +++ .../x/chatd/chatprovider/chatprovider_test.go | 128 ++++++++++++++++++ .../x/chatd/chatprovider/reasoningeffort.go | 4 +- codersdk/chats.go | 5 +- site/src/api/chatModelOptionsGenerated.json | 15 +- site/src/api/typesGenerated.ts | 1 + 8 files changed, 235 insertions(+), 10 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index f98a22d11f3..47c3d56a538 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7295,15 +7295,28 @@ func validateChatModelReasoningEffortConfig(modelConfig *codersdk.ChatModelCallC } func validateChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) error { - if options == nil || options.Anthropic == nil || options.Anthropic.ThinkingDisplay == nil { + if options == nil { return nil } - if strings.TrimSpace(*options.Anthropic.ThinkingDisplay) == "" || - chatprovider.AnthropicThinkingDisplayFromChat(options.Anthropic.ThinkingDisplay) != nil { - return nil + if options.Anthropic != nil && options.Anthropic.ThinkingDisplay != nil && + strings.TrimSpace(*options.Anthropic.ThinkingDisplay) != "" && + chatprovider.AnthropicThinkingDisplayFromChat(options.Anthropic.ThinkingDisplay) == nil { + return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted") + } + + if options.Google != nil && options.Google.ThinkingConfig != nil && + options.Google.ThinkingConfig.ThinkingLevel != nil && + strings.TrimSpace(*options.Google.ThinkingConfig.ThinkingLevel) != "" { + if chatprovider.GoogleThinkingLevelFromChat(options.Google.ThinkingConfig.ThinkingLevel) == nil { + return xerrors.Errorf("provider_options.google.thinking_config.thinking_level must be one of minimal, low, medium, high") + } + if options.Google.ThinkingConfig.ThinkingBudget != nil { + return xerrors.Errorf("provider_options.google.thinking_config.thinking_level cannot be combined with thinking_budget") + } } - return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted") + + return nil } func unmarshalChatModelCallConfig( diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index da8ba38286c..9b134a21b2e 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -283,6 +283,53 @@ func TestValidateChatModelProviderOptions_AnthropicThinkingDisplay(t *testing.T) } } +func TestValidateChatModelProviderOptions_GoogleThinkingLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level *string + budget *int64 + wantErr string + }{ + {name: "Minimal", level: ptr.Ref("minimal")}, + {name: "High", level: ptr.Ref(" HIGH ")}, + {name: "Empty", level: ptr.Ref(" ")}, + {name: "NilLevelWithBudget", budget: ptr.Ref(int64(2048))}, + {name: "EmptyLevelWithBudget", level: ptr.Ref(""), budget: ptr.Ref(int64(2048))}, + { + name: "Invalid", + level: ptr.Ref("ultra"), + wantErr: "provider_options.google.thinking_config.thinking_level must be one of minimal, low, medium, high", + }, + { + name: "LevelWithBudget", + level: ptr.Ref("high"), + budget: ptr.Ref(int64(2048)), + wantErr: "provider_options.google.thinking_config.thinking_level cannot be combined with thinking_budget", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateChatModelProviderOptions(&codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: tt.level, + ThinkingBudget: tt.budget, + }, + }, + }) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + func TestValidateChatModelConfigProviderModel(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index b96180eb54a..b34ff0c834f 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -810,6 +810,27 @@ func AnthropicThinkingDisplayFromChat(value *string) *fantasyanthropic.ThinkingD return &valueCopy } +// GoogleThinkingLevelFromChat normalizes chat-config thinking level values +// for Google and returns the canonical provider level value. +func GoogleThinkingLevelFromChat(value *string) *fantasygoogle.ThinkingLevel { + if value == nil { + return nil + } + + normalized := strings.ToLower(strings.TrimSpace(*value)) + if normalized == "" { + return nil + } + + return chatutil.NormalizedEnumValue( + normalized, + fantasygoogle.ThinkingLevelMinimal, + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelMedium, + fantasygoogle.ThinkingLevelHigh, + ) +} + // Header constants sent on upstream LLM API requests so that // intermediaries (e.g. aibridged) can correlate traffic back to // Coder entities. @@ -1176,6 +1197,7 @@ func googleProviderOptionsFromChatConfig( if options.ThinkingConfig != nil { result.ThinkingConfig = &fantasygoogle.ThinkingConfig{ ThinkingBudget: options.ThinkingConfig.ThinkingBudget, + ThinkingLevel: GoogleThinkingLevelFromChat(options.ThinkingConfig.ThinkingLevel), IncludeThoughts: options.ThinkingConfig.IncludeThoughts, } } diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index eccaf06d1cb..6e1e279b869 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -405,6 +405,134 @@ func TestProviderOptionsForCall_AnthropicThinkingDisplay(t *testing.T) { require.Equal(t, fantasyanthropic.ThinkingDisplaySummarized, *anthropicOptions.ThinkingDisplay) } +func TestGoogleThinkingLevelFromChat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input *string + want *fantasygoogle.ThinkingLevel + }{ + { + name: "Minimal", + input: ptr.Ref(" MINIMAL "), + want: ptr.Ref(fantasygoogle.ThinkingLevelMinimal), + }, + { + name: "Low", + input: ptr.Ref("low"), + want: ptr.Ref(fantasygoogle.ThinkingLevelLow), + }, + { + name: "Medium", + input: ptr.Ref("Medium"), + want: ptr.Ref(fantasygoogle.ThinkingLevelMedium), + }, + { + name: "High", + input: ptr.Ref("high"), + want: ptr.Ref(fantasygoogle.ThinkingLevelHigh), + }, + { + name: "InvalidReturnsNil", + input: ptr.Ref("ultra"), + }, + { + name: "EmptyReturnsNil", + input: ptr.Ref(" "), + }, + { + name: "NilInputReturnsNil", + input: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := chatprovider.GoogleThinkingLevelFromChat(tt.input) + require.Equal(t, tt.want, got) + }) + } +} + +func TestProviderOptionsForCall_GoogleThinkingConfig(t *testing.T) { + t.Parallel() + + googleModel := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-3.7-flash", + }, nil) + + t.Run("PinnedLevelWithoutEffortConfig", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref(" MEDIUM "), + }, + }, + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelMedium, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + + t.Run("EffortOverridesPinnedLevel", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("medium"), + }, + }, + }, + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }, ptr.Ref(codersdk.ChatModelReasoningEffortHigh)) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + + t.Run("ConfiguredBudgetSuppressesEffortLevel", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingBudget: ptr.Ref(int64(2048)), + }, + }, + }, + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.Equal(t, int64(2048), *googleOptions.ThinkingConfig.ThinkingBudget) + require.Nil(t, googleOptions.ThinkingConfig.ThinkingLevel) + }) +} + func TestResolveUserProviderKeys_UnavailableReason(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index cc5510fe5f5..646977ebe76 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -129,7 +129,9 @@ func applyReasoningEffort( providerOptions.ThinkingConfig = &fantasygoogle.ThinkingConfig{} } // A configured thinking budget wins: fantasy rejects requests that - // set both thinking_budget and thinking_level. + // 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 := googleThinkingLevel(*effort) providerOptions.ThinkingConfig.ThinkingLevel = &level diff --git a/codersdk/chats.go b/codersdk/chats.go index ea03c48a0bf..21f14b9235b 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1391,8 +1391,9 @@ type ChatModelAnthropicProviderOptions struct { // ChatModelGoogleThinkingConfig configures Google thinking behavior. type ChatModelGoogleThinkingConfig struct { - ThinkingBudget *int64 `json:"thinking_budget,omitempty" description:"Maximum number of tokens the model may use for thinking"` - IncludeThoughts *bool `json:"include_thoughts,omitempty" description:"Whether to include thinking content in the response"` + ThinkingBudget *int64 `json:"thinking_budget,omitempty" description:"Maximum number of tokens the model may use for thinking (cannot be used with thinking_level)" conflicts_with:"thinking_config.thinking_level"` + ThinkingLevel *string `json:"thinking_level,omitempty" description:"Thinking level for Gemini 3+ models, used when the user has not selected a reasoning effort (cannot be used with thinking_budget)" enum:"minimal,low,medium,high" conflicts_with:"thinking_config.thinking_budget"` + IncludeThoughts *bool `json:"include_thoughts,omitempty" description:"Whether to include thinking content in the response"` } // ChatModelGoogleSafetySetting configures Google safety filtering. diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index dd3e3555cf2..5dc86f2f4bb 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -165,9 +165,20 @@ "json_name": "thinking_config.thinking_budget", "go_name": "ThinkingConfig.ThinkingBudget", "type": "integer", - "description": "Maximum number of tokens the model may use for thinking", + "description": "Maximum number of tokens the model may use for thinking (cannot be used with thinking_level)", "required": false, - "input_type": "input" + "input_type": "input", + "conflicts_with": ["thinking_config.thinking_level"] + }, + { + "json_name": "thinking_config.thinking_level", + "go_name": "ThinkingConfig.ThinkingLevel", + "type": "string", + "description": "Thinking level for Gemini 3+ models, used when the user has not selected a reasoning effort (cannot be used with thinking_budget)", + "required": false, + "enum": ["minimal", "low", "medium", "high"], + "input_type": "select", + "conflicts_with": ["thinking_config.thinking_budget"] }, { "json_name": "thinking_config.include_thoughts", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 54a1e5dfd61..3bd2c16f0b9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2898,6 +2898,7 @@ export interface ChatModelGoogleSafetySetting { */ export interface ChatModelGoogleThinkingConfig { readonly thinking_budget?: number; + readonly thinking_level?: string; readonly include_thoughts?: boolean; } From 64c2cd507302bb6cb2188ea1a685ebff9606f48d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:40:54 +0000 Subject: [PATCH 03/19] fix(coderd/x/chatd/chatprovider): gate thinking_level to Gemini 3+ models Gemini 2.5 and older reject requests carrying thinking_level, so applying it for every Google model would turn a configured reasoning effort into a hard generation failure on those models. Parse the major version from the model ID and keep dropping the effort for pre-3 Gemini, non-Gemini, and unrecognized model IDs. --- .../x/chatd/chatprovider/reasoningeffort.go | 23 +++++++ .../reasoningeffort_internal_test.go | 67 +++++++++++++++---- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 646977ebe76..1fe47b15de9 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -2,6 +2,8 @@ package chatprovider import ( "slices" + "strconv" + "strings" "charm.land/fantasy" fantasyanthropic "charm.land/fantasy/providers/anthropic" @@ -124,6 +126,11 @@ func applyReasoningEffort( 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. + if !googleSupportsThinkingLevel(model.ModelID()) { + return options + } providerOptions := ensureProviderOptions[fantasygoogle.ProviderOptions](options, fantasygoogle.Name) if providerOptions.ThinkingConfig == nil { providerOptions.ThinkingConfig = &fantasygoogle.ThinkingConfig{} @@ -158,6 +165,22 @@ func applyReasoningEffort( return options } +// googleSupportsThinkingLevel reports whether the Google model accepts the +// thinking_level generation option, which Gemini introduced in version 3. +// Non-Gemini and unrecognized model IDs return false so reasoning effort +// degrades to a no-op instead of a rejected request. +func googleSupportsThinkingLevel(modelID string) bool { + normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") + version, ok := strings.CutPrefix(normalized, "gemini-") + if !ok { + return false + } + version, _, _ = strings.Cut(version, "-") + major, _, _ := strings.Cut(version, ".") + majorVersion, err := strconv.Atoi(major) + return err == nil && majorVersion >= 3 +} + // 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. diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index 0cd3d6c8ae1..8938edce8f5 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -68,10 +68,11 @@ func TestApplyReasoningEffort(t *testing.T) { }) tests := []struct { - name string - provider string - options fantasy.ProviderOptions - assert func(*testing.T, fantasy.ProviderOptions) + name string + provider string + modelName string + options fantasy.ProviderOptions + assert func(*testing.T, fantasy.ProviderOptions) }{ { name: "CreatesAnthropicEntry", @@ -94,8 +95,9 @@ func TestApplyReasoningEffort(t *testing.T) { }, }, { - name: "CreatesGoogleEntry", - provider: fantasygoogle.Name, + name: "CreatesGoogleEntry", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", assert: func(t *testing.T, got fantasy.ProviderOptions) { providerOptions, ok := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) require.True(t, ok, "%T", got[fantasygoogle.Name]) @@ -105,9 +107,10 @@ func TestApplyReasoningEffort(t *testing.T) { }, }, { - name: "PreservesGoogleEntry", - provider: fantasygoogle.Name, - options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{IncludeThoughts: ptr.Ref(true)}}}, + name: "PreservesGoogleEntry", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{IncludeThoughts: ptr.Ref(true)}}}, assert: func(t *testing.T, got fantasy.ProviderOptions) { providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) require.True(t, *providerOptions.ThinkingConfig.IncludeThoughts) @@ -116,15 +119,24 @@ func TestApplyReasoningEffort(t *testing.T) { }, }, { - name: "GoogleExplicitBudgetWins", - provider: fantasygoogle.Name, - options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{ThinkingBudget: ptr.Ref(int64(1024))}}}, + name: "GoogleExplicitBudgetWins", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{ThinkingBudget: ptr.Ref(int64(1024))}}}, assert: func(t *testing.T, got fantasy.ProviderOptions) { providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) require.Equal(t, int64(1024), *providerOptions.ThinkingConfig.ThinkingBudget) require.Nil(t, providerOptions.ThinkingConfig.ThinkingLevel) }, }, + { + name: "GoogleGemini25GetsNoLevel", + provider: fantasygoogle.Name, + modelName: "gemini-2.5-flash", + assert: func(t *testing.T, got fantasy.ProviderOptions) { + require.Nil(t, got[fantasygoogle.Name]) + }, + }, { name: "CreatesOpenAICompatEntry", provider: fantasyopenaicompat.Name, @@ -196,12 +208,41 @@ func TestApplyReasoningEffort(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: tt.provider}, nil), tt.options, new(codersdk.ChatModelReasoningEffortHigh)) + got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: tt.provider, ModelName: tt.modelName}, nil), tt.options, new(codersdk.ChatModelReasoningEffortHigh)) tt.assert(t, got) }) } } +func TestGoogleSupportsThinkingLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + modelID string + want bool + }{ + {modelID: "gemini-3.7-flash", want: true}, + {modelID: "gemini-3-pro-preview", want: true}, + {modelID: "models/gemini-3.7-flash", want: true}, + {modelID: " Gemini-4-Flash ", want: true}, + {modelID: "gemini-10.5-pro", want: true}, + {modelID: "gemini-2.5-flash", want: false}, + {modelID: "gemini-2.0-flash", want: false}, + {modelID: "gemini-1.5-pro", want: false}, + {modelID: "gemini-exp-1206", want: false}, + {modelID: "gemma-3-27b-it", want: false}, + {modelID: "learnlm-2.0-flash", want: false}, + {modelID: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.modelID, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, googleSupportsThinkingLevel(tt.modelID)) + }) + } +} + func TestGoogleThinkingLevel(t *testing.T) { t.Parallel() From 123dbdc9c1b31c4a9c282cb0d849ac2b59afd1a8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:48:43 +0000 Subject: [PATCH 04/19] fix(coderd/x/chatd/chatprovider): drop pinned thinking_level for pre-Gemini-3 models The config conversion path copied a pinned thinking_level for every Google model, so an admin pinning a level on a Gemini 2.5 config would fail every generation. Gate conversion on the same model capability check as the reasoning-effort path. Gating at call time rather than config save time also covers updates that switch a config's model without resubmitting options. --- coderd/x/chatd/chatprovider/chatprovider.go | 14 +++++++++- .../x/chatd/chatprovider/chatprovider_test.go | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index b34ff0c834f..3e3ff02c97c 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -1145,7 +1145,12 @@ func providerOptionsFromChatModelConfig( ) } if options.Google != nil { + var modelID string + if model.Valid() { + modelID = model.ModelID() + } result[fantasygoogle.Name] = googleProviderOptionsFromChatConfig( + modelID, options.Google, ) } @@ -1188,6 +1193,7 @@ func anthropicProviderOptionsFromChatConfig( } func googleProviderOptionsFromChatConfig( + modelID string, options *codersdk.ChatModelGoogleProviderOptions, ) *fantasygoogle.ProviderOptions { result := &fantasygoogle.ProviderOptions{ @@ -1197,9 +1203,15 @@ func googleProviderOptionsFromChatConfig( if options.ThinkingConfig != nil { result.ThinkingConfig = &fantasygoogle.ThinkingConfig{ ThinkingBudget: options.ThinkingConfig.ThinkingBudget, - ThinkingLevel: GoogleThinkingLevelFromChat(options.ThinkingConfig.ThinkingLevel), IncludeThoughts: options.ThinkingConfig.IncludeThoughts, } + // Models predating Gemini 3 reject requests carrying + // thinking_level, so drop a pinned level for them. Gating here + // rather than at config save time also covers updates that + // switch a config's model without resubmitting options. + if googleSupportsThinkingLevel(modelID) { + result.ThinkingConfig.ThinkingLevel = GoogleThinkingLevelFromChat(options.ThinkingConfig.ThinkingLevel) + } } if options.SafetySettings != nil { result.SafetySettings = make( diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 6e1e279b869..0e9b61bb1d8 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -484,6 +484,32 @@ func TestProviderOptionsForCall_GoogleThinkingConfig(t *testing.T) { require.Equal(t, fantasygoogle.ThinkingLevelMedium, *googleOptions.ThinkingConfig.ThinkingLevel) }) + t.Run("PinnedLevelDroppedForGemini25", func(t *testing.T) { + t.Parallel() + + gemini25 := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-2.5-flash", + }, nil) + + providerOptions := chatprovider.ProviderOptionsForCall(gemini25, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("medium"), + IncludeThoughts: ptr.Ref(true), + }, + }, + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.Nil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.True(t, *googleOptions.ThinkingConfig.IncludeThoughts) + }) + t.Run("EffortOverridesPinnedLevel", func(t *testing.T) { t.Parallel() From d0ef34fa2ce539115a4b8a5e7b55dc7b3200532b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:59:19 +0000 Subject: [PATCH 05/19] fix(coderd/x/chatd/chatprovider): clamp thinking_level to each Gemini model's supported set Gemini models accept different thinking_level subsets: 3 Pro launched with LOW and HIGH only, 3.1 Pro added MEDIUM, the Flash family accepts all four, and image models accept only HIGH (plus MINIMAL for flash). Replace the boolean Gemini 3+ gate with a per-model supported set and round-up clamping so an effort or pinned level maps to the nearest level with at least the requested depth instead of a rejected request. Versionless -latest aliases are recognized as current-generation models; unknown Gemini 3+ variants fall back to LOW and HIGH, the intersection of every documented non-image set. --- coderd/x/chatd/chatprovider/chatprovider.go | 17 ++- .../x/chatd/chatprovider/chatprovider_test.go | 47 +++++++ .../x/chatd/chatprovider/reasoningeffort.go | 115 ++++++++++++++++-- .../reasoningeffort_internal_test.go | 72 ++++++++--- 4 files changed, 217 insertions(+), 34 deletions(-) diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 3e3ff02c97c..b56199e6a64 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -1205,12 +1205,17 @@ func googleProviderOptionsFromChatConfig( ThinkingBudget: options.ThinkingConfig.ThinkingBudget, IncludeThoughts: options.ThinkingConfig.IncludeThoughts, } - // Models predating Gemini 3 reject requests carrying - // thinking_level, so drop a pinned level for them. Gating here - // rather than at config save time also covers updates that - // switch a config's model without resubmitting options. - if googleSupportsThinkingLevel(modelID) { - result.ThinkingConfig.ThinkingLevel = GoogleThinkingLevelFromChat(options.ThinkingConfig.ThinkingLevel) + // Each Gemini model accepts a different thinking_level subset and + // pre-Gemini-3 models reject the field entirely, so clamp a pinned + // level into the model's supported set and drop it for models + // without support. Gating here rather than at config save time + // also covers updates that switch a config's model without + // resubmitting options. + if pinned := GoogleThinkingLevelFromChat(options.ThinkingConfig.ThinkingLevel); pinned != nil { + if supported := googleSupportedThinkingLevels(modelID); len(supported) > 0 { + level := clampGoogleThinkingLevel(*pinned, supported) + result.ThinkingConfig.ThinkingLevel = &level + } } } if options.SafetySettings != nil { diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 0e9b61bb1d8..92905513c7e 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -510,6 +510,53 @@ func TestProviderOptionsForCall_GoogleThinkingConfig(t *testing.T) { require.True(t, *googleOptions.ThinkingConfig.IncludeThoughts) }) + t.Run("PinnedLevelClampedForGemini3Pro", func(t *testing.T) { + t.Parallel() + + gemini3Pro := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-3-pro-preview", + }, nil) + + providerOptions := chatprovider.ProviderOptionsForCall(gemini3Pro, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("minimal"), + }, + }, + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelLow, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + + t.Run("EffortClampedForGemini3Pro", func(t *testing.T) { + t.Parallel() + + gemini3Pro := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-3-pro-preview", + }, nil) + + providerOptions := chatprovider.ProviderOptionsForCall(gemini3Pro, codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }, ptr.Ref(codersdk.ChatModelReasoningEffortMedium)) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + t.Run("EffortOverridesPinnedLevel", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 1fe47b15de9..4de943d883d 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -128,7 +128,8 @@ func applyReasoningEffort( case fantasygoogle.Name: // Only Gemini 3+ accepts thinking_level; older generations reject // requests carrying it, so keep dropping the effort for them. - if !googleSupportsThinkingLevel(model.ModelID()) { + supported := googleSupportedThinkingLevels(model.ModelID()) + if len(supported) == 0 { return options } providerOptions := ensureProviderOptions[fantasygoogle.ProviderOptions](options, fantasygoogle.Name) @@ -140,7 +141,7 @@ func applyReasoningEffort( // overrides a config-pinned thinking_level so the user's effort // selection stays meaningful. if providerOptions.ThinkingConfig.ThinkingBudget == nil { - level := googleThinkingLevel(*effort) + level := clampGoogleThinkingLevel(googleThinkingLevel(*effort), supported) providerOptions.ThinkingConfig.ThinkingLevel = &level } case fantasyopenaicompat.Name: @@ -165,20 +166,108 @@ func applyReasoningEffort( return options } -// googleSupportsThinkingLevel reports whether the Google model accepts the -// thinking_level generation option, which Gemini introduced in version 3. -// Non-Gemini and unrecognized model IDs return false so reasoning effort -// degrades to a no-op instead of a rejected request. -func googleSupportsThinkingLevel(modelID string) bool { +// 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 accepts all four, 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. Non-Gemini and unrecognized model IDs +// return nil so reasoning effort degrades to a no-op instead of a rejected +// request. +func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel { normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") - version, ok := strings.CutPrefix(normalized, "gemini-") + rest, ok := strings.CutPrefix(normalized, "gemini-") if !ok { - return false + 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 + } + + isPro := slices.Contains(segments, "pro") + isFlash := slices.Contains(segments, "flash") + isImage := slices.Contains(segments, "image") + + switch { + case isImage && isFlash: + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelMinimal, + fantasygoogle.ThinkingLevelHigh, + } + case isImage: + return []fantasygoogle.ThinkingLevel{fantasygoogle.ThinkingLevelHigh} + case isFlash: + return slices.Clone(googleThinkingLevelsAscending) + case isPro && hasVersion && major == 3 && minor == 0: + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelHigh, + } + case isPro: + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelMedium, + fantasygoogle.ThinkingLevelHigh, + } + default: + // Unknown Gemini 3+ variant: LOW and HIGH are the intersection of + // every documented non-image model's supported set. + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelLow, + 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 + } } - version, _, _ = strings.Cut(version, "-") - major, _, _ := strings.Cut(version, ".") - majorVersion, err := strconv.Atoi(major) - return err == nil && majorVersion >= 3 + return supported[len(supported)-1] } // googleThinkingLevel maps the global reasoning effort scale to Google diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index 8938edce8f5..adcbf882442 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -214,31 +214,73 @@ func TestApplyReasoningEffort(t *testing.T) { } } -func TestGoogleSupportsThinkingLevel(t *testing.T) { +func TestGoogleSupportedThinkingLevels(t *testing.T) { t.Parallel() + minimal := fantasygoogle.ThinkingLevelMinimal + low := fantasygoogle.ThinkingLevelLow + medium := fantasygoogle.ThinkingLevelMedium + high := fantasygoogle.ThinkingLevelHigh + tests := []struct { modelID string - want bool + want []fantasygoogle.ThinkingLevel }{ - {modelID: "gemini-3.7-flash", want: true}, - {modelID: "gemini-3-pro-preview", want: true}, - {modelID: "models/gemini-3.7-flash", want: true}, - {modelID: " Gemini-4-Flash ", want: true}, - {modelID: "gemini-10.5-pro", want: true}, - {modelID: "gemini-2.5-flash", want: false}, - {modelID: "gemini-2.0-flash", want: false}, - {modelID: "gemini-1.5-pro", want: false}, - {modelID: "gemini-exp-1206", want: false}, - {modelID: "gemma-3-27b-it", want: false}, - {modelID: "learnlm-2.0-flash", want: false}, - {modelID: "", want: false}, + {modelID: "gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3-flash-preview", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "models/gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: " Gemini-4-Flash ", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-flash-latest", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-flash-lite-latest", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3-pro-preview", want: []fantasygoogle.ThinkingLevel{low, high}}, + {modelID: "gemini-3.1-pro-preview", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-10.5-pro", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-pro-latest", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-3-pro-image-preview", want: []fantasygoogle.ThinkingLevel{high}}, + {modelID: "gemini-3.1-flash-image", want: []fantasygoogle.ThinkingLevel{minimal, high}}, + {modelID: "gemini-3-ultra", want: []fantasygoogle.ThinkingLevel{low, high}}, + {modelID: "gemini-2.5-flash", want: nil}, + {modelID: "gemini-2.0-flash", want: nil}, + {modelID: "gemini-1.5-flash-latest", want: nil}, + {modelID: "gemini-exp-1206", want: nil}, + {modelID: "gemma-3-27b-it", want: nil}, + {modelID: "learnlm-2.0-flash", want: nil}, + {modelID: "", want: nil}, } for _, tt := range tests { t.Run(tt.modelID, func(t *testing.T) { t.Parallel() - require.Equal(t, tt.want, googleSupportsThinkingLevel(tt.modelID)) + require.Equal(t, tt.want, googleSupportedThinkingLevels(tt.modelID)) + }) + } +} + +func TestClampGoogleThinkingLevel(t *testing.T) { + t.Parallel() + + gemini3Pro := googleSupportedThinkingLevels("gemini-3-pro-preview") + proImage := googleSupportedThinkingLevels("gemini-3-pro-image-preview") + flash := googleSupportedThinkingLevels("gemini-3.7-flash") + + tests := []struct { + name string + desired fantasygoogle.ThinkingLevel + supported []fantasygoogle.ThinkingLevel + want fantasygoogle.ThinkingLevel + }{ + {name: "MinimalRoundsUpToLowOnPro", desired: fantasygoogle.ThinkingLevelMinimal, supported: gemini3Pro, want: fantasygoogle.ThinkingLevelLow}, + {name: "MediumRoundsUpToHighOnPro", desired: fantasygoogle.ThinkingLevelMedium, supported: gemini3Pro, want: fantasygoogle.ThinkingLevelHigh}, + {name: "HighExactOnPro", desired: fantasygoogle.ThinkingLevelHigh, supported: gemini3Pro, want: fantasygoogle.ThinkingLevelHigh}, + {name: "LowRoundsUpToHighOnProImage", desired: fantasygoogle.ThinkingLevelLow, supported: proImage, want: fantasygoogle.ThinkingLevelHigh}, + {name: "MediumExactOnFlash", desired: fantasygoogle.ThinkingLevelMedium, supported: flash, want: fantasygoogle.ThinkingLevelMedium}, + {name: "MinimalExactOnFlash", desired: fantasygoogle.ThinkingLevelMinimal, supported: flash, want: fantasygoogle.ThinkingLevelMinimal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, clampGoogleThinkingLevel(tt.desired, tt.supported)) }) } } From f896d3531e8539a515e02b949ba71ae03d57ded7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:50:48 +0000 Subject: [PATCH 06/19] fix(coderd/x/chatd/chatprovider): clamp Gemini reasoning_effort on the openai-compat path Google AI providers route through aibridge as openai-compat clients, so the native thinking_level path never runs for chat generation. Google's OpenAI-compatible endpoint translates reasoning_effort into the model's thinking configuration but validates instead of clamping, so efforts outside the model's supported set (none/minimal/xhigh/max on Gemini 3.1 Pro, verified live) fail the whole request with HTTP 400. Clamp the effort into each Gemini model's supported set before dispatch, reusing the thinking-level tables from the native path. --- .../x/chatd/chatprovider/reasoningeffort.go | 38 +++++++++++ .../reasoningeffort_internal_test.go | 66 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 4de943d883d..779bb44a0f0 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -146,6 +146,9 @@ func applyReasoningEffort( } 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: @@ -166,6 +169,41 @@ func applyReasoningEffort( 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{ diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index adcbf882442..03df571b5d5 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -212,6 +212,72 @@ func TestApplyReasoningEffort(t *testing.T) { tt.assert(t, got) }) } + + t.Run("OpenAICompatClampsGeminiEffort", func(t *testing.T) { + t.Parallel() + + got := applyReasoningEffort( + NewModel(&chattest.FakeModel{ProviderName: fantasyopenaicompat.Name, ModelName: "gemini-3-pro-preview"}, nil), + nil, + new(codersdk.ChatModelReasoningEffortMedium), + ) + providerOptions, ok := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenaicompat.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) +} + +func TestGoogleCompatReasoningEffort(t *testing.T) { + t.Parallel() + + tests := []struct { + modelID string + effort string + want string + wantOK bool + }{ + // Gemini 3.1 Pro supports LOW/MEDIUM/HIGH; Google rejects + // out-of-range values instead of clamping. + {modelID: "gemini-3.1-pro-preview", effort: "none", want: "low", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "minimal", want: "low", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "low", want: "low", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "medium", want: "medium", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "high", want: "high", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "xhigh", want: "high", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "max", want: "high", wantOK: true}, + // Gemini 3.0 Pro supports only LOW/HIGH. + {modelID: "gemini-3-pro-preview", effort: "medium", want: "high", wantOK: true}, + {modelID: "gemini-3-pro-preview", effort: "minimal", want: "low", wantOK: true}, + // The Gemini 3 Flash family supports all four levels. + {modelID: "gemini-3-flash-preview", effort: "minimal", want: "minimal", wantOK: true}, + {modelID: "gemini-3-flash-preview", effort: "medium", want: "medium", wantOK: true}, + {modelID: "gemini-3-flash-preview", effort: "max", want: "high", wantOK: true}, + // Pre-Gemini-3 models accept none/low/medium/high; none stays + // usable on Flash but clamps to low on Pro, which cannot + // disable thinking. + {modelID: "gemini-2.5-flash", effort: "none", want: "none", wantOK: true}, + {modelID: "gemini-2.5-flash", effort: "minimal", want: "low", wantOK: true}, + {modelID: "gemini-2.5-flash", effort: "xhigh", want: "high", wantOK: true}, + {modelID: "gemini-2.5-pro", effort: "none", want: "low", wantOK: true}, + {modelID: "gemini-2.5-pro", effort: "high", want: "high", wantOK: true}, + // Model ID prefixes used by gateways and the Google API. + {modelID: "models/gemini-3.1-pro-preview", effort: "xhigh", want: "high", wantOK: true}, + {modelID: "google/gemini-3.1-pro-preview", effort: "xhigh", want: "high", wantOK: true}, + // Non-Gemini models keep the caller's effort untouched. + {modelID: "gpt-5", effort: "xhigh", wantOK: false}, + {modelID: "deepseek/deepseek-v4", effort: "high", wantOK: false}, + {modelID: "", effort: "high", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.modelID+"/"+tt.effort, func(t *testing.T) { + t.Parallel() + got, ok := googleCompatReasoningEffort(tt.modelID, tt.effort) + require.Equal(t, tt.wantOK, ok) + require.Equal(t, tt.want, got) + }) + } } func TestGoogleSupportedThinkingLevels(t *testing.T) { From 3b6a945551490d7c32f00cb81c6dd36461b7c8e2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:07:30 +0000 Subject: [PATCH 07/19] feat: surface Gemini thinking blocks on the OpenAI-compat chat path Google's OpenAI-compatible endpoint never emits thought text for reasoning_effort requests: thoughts require google.thinking_config with include_thoughts, which is mutually exclusive with reasoning_effort, and the thought deltas come back inline in content marked by extra_content.google.thought plus markers rather than in the reasoning_content field OpenAI-compatible clients parse. Chats on Gemini models therefore showed no thinking blocks at all (verified live in UAT round 3). Translate 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 (thinking_level on Gemini 3+, thinking_budget before that) with include_thoughts enabled, and rewrites responses so thought output surfaces as reasoning_content, which fantasy already converts into reasoning parts. Streaming bodies are rewritten per SSE line to preserve latency. - aibridge's chat-completions interception preserves the extra_body passthrough that openai-go's typed params drop, forwarding it to Google upstreams only. Verified live against generativelanguage.googleapis.com on gemini-3-flash-preview and gemini-2.5-flash: reasoning deltas stream through the full chatprovider stack with markers stripped and answer text intact. --- .../chatcompletions/google_openai_compat.go | 12 +- .../google_openai_compat_internal_test.go | 54 +++++ .../intercept/chatcompletions/paramswrap.go | 11 + .../chatprovider/google_compat_thinking.go | 72 +++++++ .../google_compat_thinking_internal_test.go | 82 ++++++++ .../chatprovider/openai_compat_patches.go | 7 +- .../openai_compat_patches_test.go | 133 +++++++++++- internal/googleopenai/thought_signature.go | 18 -- internal/googleopenai/thoughts.go | 198 ++++++++++++++++++ internal/googleopenai/thoughts_test.go | 151 +++++++++++++ 10 files changed, 715 insertions(+), 23 deletions(-) create mode 100644 coderd/x/chatd/chatprovider/google_compat_thinking.go create mode 100644 coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go create mode 100644 internal/googleopenai/thoughts.go create mode 100644 internal/googleopenai/thoughts_test.go diff --git a/aibridge/intercept/chatcompletions/google_openai_compat.go b/aibridge/intercept/chatcompletions/google_openai_compat.go index 251cbc71a01..fea6a78d8de 100644 --- a/aibridge/intercept/chatcompletions/google_openai_compat.go +++ b/aibridge/intercept/chatcompletions/google_openai_compat.go @@ -17,7 +17,17 @@ func (i *interceptionBase) chatCompletionRequestBody() ([]byte, error) { if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) { return body, nil } - patched, _, err := googleopenai.PatchThoughtSignatures(body) + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + // Reattach the extra_body passthrough dropped by the typed params so + // Gemini settings such as thinking_config reach Google. + if len(i.req.ExtraBody) > 0 { + payload["extra_body"] = i.req.ExtraBody + } + googleopenai.AddThoughtSignaturesToLatestTurn(payload) + patched, err := json.Marshal(payload) if err != nil { return nil, err } diff --git a/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go index a8acd5397ac..24e10764456 100644 --- a/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go +++ b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go @@ -98,3 +98,57 @@ func googleThoughtSignatureFromBody(t *testing.T, body []byte, messageIndex int, signature, _ := google["thought_signature"].(string) return signature } + +func TestGoogleOpenAICompatExtraBodySurvivesParamRoundTrip(t *testing.T) { + t.Parallel() + + raw := []byte(`{ + "model":"gemini-3-flash-preview", + "stream":true, + "extra_body":{"google":{"thinking_config":{"include_thoughts":true,"thinking_level":"high"}}}, + "messages":[{"role":"user","content":"current turn"}] + }`) + + var req ChatCompletionNewParamsWrapper + require.NoError(t, json.Unmarshal(raw, &req)) + + roundTripped, err := json.Marshal(req.ChatCompletionNewParams) + require.NoError(t, err) + require.NotContains(t, string(roundTripped), "extra_body", + "openai-go drops extra_body during the typed param round-trip") + + body, err := (&interceptionBase{ + req: &req, + cfg: intercept.Config{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"}, + }).chatCompletionRequestBody() + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + require.Equal(t, map[string]any{ + "google": map[string]any{ + "thinking_config": map[string]any{ + "include_thoughts": true, + "thinking_level": "high", + }, + }, + }, payload["extra_body"]) +} + +func TestGoogleOpenAICompatExtraBodyNotForwardedToOtherUpstreams(t *testing.T) { + t.Parallel() + + var req ChatCompletionNewParamsWrapper + require.NoError(t, json.Unmarshal([]byte(`{ + "model":"gpt-4o", + "extra_body":{"google":{"thinking_config":{"include_thoughts":true}}}, + "messages":[{"role":"user","content":"current turn"}] + }`), &req)) + + body, err := (&interceptionBase{ + req: &req, + cfg: intercept.Config{BaseURL: "https://api.openai.com/v1"}, + }).chatCompletionRequestBody() + require.NoError(t, err) + require.NotContains(t, string(body), "extra_body") +} diff --git a/aibridge/intercept/chatcompletions/paramswrap.go b/aibridge/intercept/chatcompletions/paramswrap.go index 8b9efbbf4fd..da1d0a3cfa1 100644 --- a/aibridge/intercept/chatcompletions/paramswrap.go +++ b/aibridge/intercept/chatcompletions/paramswrap.go @@ -1,6 +1,8 @@ package chatcompletions import ( + "encoding/json" + "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/packages/param" "github.com/tidwall/gjson" @@ -13,6 +15,11 @@ import ( type ChatCompletionNewParamsWrapper struct { openai.ChatCompletionNewParams `json:""` Stream bool `json:"stream,omitempty"` + // ExtraBody preserves the OpenAI SDK's extra_body passthrough object, + // which the typed params drop on unmarshal. It is forwarded only to + // Google upstreams, which read provider-specific settings such as + // Gemini's thinking_config from it. + ExtraBody json.RawMessage `json:"-"` } func (c ChatCompletionNewParamsWrapper) MarshalJSON() ([]byte, error) { @@ -28,6 +35,10 @@ func (c *ChatCompletionNewParamsWrapper) UnmarshalJSON(raw []byte) error { return err } + if extraBody := gjson.GetBytes(raw, "extra_body"); extraBody.IsObject() { + c.ExtraBody = json.RawMessage(extraBody.Raw) + } + c.Stream = gjson.GetBytes(raw, "stream").Bool() if c.Stream { c.ChatCompletionNewParams.StreamOptions = openai.ChatCompletionStreamOptionsParam{ diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking.go b/coderd/x/chatd/chatprovider/google_compat_thinking.go new file mode 100644 index 00000000000..74b822f2a72 --- /dev/null +++ b/coderd/x/chatd/chatprovider/google_compat_thinking.go @@ -0,0 +1,72 @@ +package chatprovider + +import ( + "strings" + + "github.com/coder/coder/v2/codersdk" +) + +// rewriteGoogleCompatThinkingConfig swaps reasoning_effort for an explicit +// Google thinking_config on Gemini requests. 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. +func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { + modelID, _ := payload["model"].(string) + normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") + normalized = strings.TrimPrefix(normalized, "google/") + if !strings.HasPrefix(normalized, "gemini-") { + return false + } + + thinkingConfig := map[string]any{"include_thoughts": true} + if effort, ok := payload["reasoning_effort"].(string); ok { + if supported := googleSupportedThinkingLevels(normalized); len(supported) > 0 { + level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported) + thinkingConfig["thinking_level"] = strings.ToLower(level) + } else if budget, ok := googleCompatThinkingBudget(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 + } + // An explicitly configured thinking_config wins, but reasoning_effort + // must go regardless because Google rejects the combination. + if _, exists := google["thinking_config"]; !exists { + google["thinking_config"] = thinkingConfig + } + delete(payload, "reasoning_effort") + return true +} + +// 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. +func googleCompatThinkingBudget(effort string) (int, bool) { + switch effort { + case codersdk.ChatModelReasoningEffortNone: + 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 + } +} diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go new file mode 100644 index 00000000000..6e5def47774 --- /dev/null +++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go @@ -0,0 +1,82 @@ +package chatprovider + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRewriteGoogleCompatThinkingConfig(t *testing.T) { + t.Parallel() + + thinkingConfig := func(payload map[string]any) map[string]any { + extraBody, _ := payload["extra_body"].(map[string]any) + google, _ := extraBody["google"].(map[string]any) + config, _ := google["thinking_config"].(map[string]any) + return config + } + + t.Run("Gemini3EffortBecomesThinkingLevel", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-3-flash-preview", "reasoning_effort": "medium"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "medium"}, thinkingConfig(payload)) + }) + + t.Run("Gemini3ProClampsUnsupportedLevel", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-3.0-pro", "reasoning_effort": "minimal"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "low"}, thinkingConfig(payload)) + }) + + t.Run("PreGemini3EffortBecomesThinkingBudget", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-2.5-flash", "reasoning_effort": "medium"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 8192}, thinkingConfig(payload)) + }) + + t.Run("NoEffortStillIncludesThoughts", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-2.5-flash"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true}, thinkingConfig(payload)) + }) + + t.Run("ProviderPrefixedModelID", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "google/gemini-3.1-pro-preview", "reasoning_effort": "high"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "high"}, thinkingConfig(payload)) + }) + + t.Run("ExplicitThinkingConfigWins", func(t *testing.T) { + t.Parallel() + pinned := map[string]any{"thinking_budget": float64(128)} + payload := map[string]any{ + "model": "gemini-2.5-pro", + "reasoning_effort": "high", + "extra_body": map[string]any{"google": map[string]any{"thinking_config": pinned}}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + require.Equal(t, pinned, thinkingConfig(payload)) + }) + + t.Run("NonGeminiUntouched", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gpt-4o", "reasoning_effort": "high"} + require.False(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"model": "gpt-4o", "reasoning_effort": "high"}, payload) + }) + + t.Run("UnknownEffortUntouched", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-2.5-flash", "reasoning_effort": "turbo"} + require.False(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Contains(t, payload, "reasoning_effort") + }) +} diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches.go b/coderd/x/chatd/chatprovider/openai_compat_patches.go index 8c60f2a16b6..cded58f1c83 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go @@ -64,7 +64,11 @@ func (t *openAICompatRequestPatchTransport) RoundTrip(req *http.Request) (*http. return io.NopCloser(bytes.NewReader(patched)), nil } - return base.RoundTrip(patchedReq) + resp, err := base.RoundTrip(patchedReq) + if err == nil && googleopenai.ShouldPatchOpenAICompatRequest(t.BaseURL, t.ModelID) { + googleopenai.RewriteThoughtResponse(resp) + } + return resp, err } func (t *openAICompatRequestPatchTransport) base() http.RoundTripper { @@ -90,6 +94,7 @@ func patchOpenAICompatChatCompletionsBody(body []byte, baseURL string, modelID s changed := rewriteOpenAICompatSingleToolChoice(payload) if googleopenai.ShouldPatchOpenAICompatRequest(baseURL, modelID) { changed = googleopenai.AddThoughtSignaturesToLatestTurn(payload) || changed + changed = rewriteGoogleCompatThinkingConfig(payload) || changed } if !changed { return body diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go index e8d5194ef89..444c8374a19 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go @@ -8,6 +8,7 @@ import ( "testing" "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" "github.com/stretchr/testify/require" @@ -49,7 +50,7 @@ func TestModelFromConfig_GeminiOpenAICompatThoughtSignatures(t *testing.T) { }) } -func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string) map[string]any { +func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string, providerOptions ...fantasy.ProviderOptions) map[string]any { t.Helper() transport := &captureChatCompletionTransport{} @@ -71,9 +72,13 @@ func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string) m ) require.NoError(t, err) - _, err = model.LanguageModel().Generate(t.Context(), fantasy.Call{ + call := fantasy.Call{ Prompt: geminiOpenAICompatToolPrompt(), - }) + } + if len(providerOptions) > 0 { + call.ProviderOptions = providerOptions[0] + } + _, err = model.LanguageModel().Generate(t.Context(), call) require.NoError(t, err) require.NotNil(t, transport.body) return transport.body @@ -184,3 +189,125 @@ func thoughtSignature(t *testing.T, rawMessage any, toolCallIndex int) string { signature, _ := google["thought_signature"].(string) return signature } + +func TestModelFromConfig_GeminiOpenAICompatThinkingConfig(t *testing.T) { + t.Parallel() + + effort := fantasyopenai.ReasoningEffort("medium") + effortOptions := fantasy.ProviderOptions{ + fantasyopenaicompat.Name: &fantasyopenaicompat.ProviderOptions{ReasoningEffort: &effort}, + } + + t.Run("Gemini endpoint swaps reasoning_effort for thinking_config", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "https://generativelanguage.googleapis.com/v1beta/openai/", "gemini-3-flash-preview", effortOptions) + + require.NotContains(t, body, "reasoning_effort") + extraBody := body["extra_body"].(map[string]any) + google := extraBody["google"].(map[string]any) + require.Equal(t, map[string]any{ + "include_thoughts": true, + "thinking_level": "medium", + }, google["thinking_config"]) + }) + + t.Run("Coder AI Bridge Gemini route swaps reasoning_effort for thinking_config", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "http://coder-aibridge/v1", "gemini-2.5-flash", effortOptions) + + require.NotContains(t, body, "reasoning_effort") + extraBody := body["extra_body"].(map[string]any) + google := extraBody["google"].(map[string]any) + require.Equal(t, map[string]any{ + "include_thoughts": true, + "thinking_budget": float64(8192), + }, google["thinking_config"]) + }) + + t.Run("Vercel OpenAI-compatible Gemini route is unchanged", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "https://gateway.vercel.ai/v1", "google/gemini-3-flash-preview", effortOptions) + + require.Equal(t, "medium", body["reasoning_effort"]) + require.NotContains(t, body, "extra_body") + }) +} + +func TestModelFromConfig_GeminiOpenAICompatThoughtStreaming(t *testing.T) { + t.Parallel() + + stream := strings.Join([]string{ + `data: {"choices":[{"delta":{"role":"assistant","content":"**Calculating**\n\nStep one.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"More thinking.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"The answer is "},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"391."},"index":0,"finish_reason":"stop"}],"object":"chat.completion.chunk"}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + transport := &sseChatCompletionTransport{stream: stream} + model, err := chatprovider.ModelFromConfig( + fantasyopenaicompat.Name, + "gemini-3-flash-preview", + chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{ + fantasyopenaicompat.Name: "test-key", + }, + BaseURLByProvider: map[string]string{ + fantasyopenaicompat.Name: "https://generativelanguage.googleapis.com/v1beta/openai/", + }, + }, + chatprovider.UserAgent(), + nil, + &http.Client{Transport: transport}, + nil, + ) + require.NoError(t, err) + + parts, err := model.LanguageModel().Stream(t.Context(), fantasy.Call{ + Prompt: []fantasy.Message{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "What is 17*23?"}}, + }}, + }) + require.NoError(t, err) + + var reasoning, text strings.Builder + sawReasoningEnd := false + for part := range parts { + switch part.Type { + case fantasy.StreamPartTypeReasoningDelta: + _, _ = reasoning.WriteString(part.Delta) + case fantasy.StreamPartTypeReasoningEnd: + sawReasoningEnd = true + case fantasy.StreamPartTypeTextDelta: + _, _ = text.WriteString(part.Delta) + } + } + + require.Equal(t, "**Calculating**\n\nStep one.More thinking.", reasoning.String()) + require.True(t, sawReasoningEnd) + require.Equal(t, "The answer is 391.", text.String()) +} + +type sseChatCompletionTransport struct { + stream string +} + +func (st *sseChatCompletionTransport) RoundTrip(req *http.Request) (*http.Response, error) { + _, _ = io.ReadAll(req.Body) + _ = req.Body.Close() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + }, + Body: io.NopCloser(strings.NewReader(st.stream)), + }, nil +} diff --git a/internal/googleopenai/thought_signature.go b/internal/googleopenai/thought_signature.go index 84467bec183..5fd46460e36 100644 --- a/internal/googleopenai/thought_signature.go +++ b/internal/googleopenai/thought_signature.go @@ -3,7 +3,6 @@ package googleopenai import ( - "encoding/json" "net/url" "strings" ) @@ -72,23 +71,6 @@ func isGeminiModelID(modelID string) bool { return strings.HasPrefix(modelID, "gemini-") || strings.Contains(modelID, "/gemini-") } -// PatchThoughtSignatures adds fallback thought signatures to Gemini tool-call -// history in body. It returns changed=false when no patch is needed. -func PatchThoughtSignatures(body []byte) ([]byte, bool, error) { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - return nil, false, err - } - if !AddThoughtSignaturesToLatestTurn(payload) { - return body, false, nil - } - patched, err := json.Marshal(payload) - if err != nil { - return nil, false, err - } - return patched, true, nil -} - // AddThoughtSignaturesToLatestTurn patches only the current turn because // completed tool-call/result pairs from earlier turns are not validated by // Google as active function calls. diff --git a/internal/googleopenai/thoughts.go b/internal/googleopenai/thoughts.go new file mode 100644 index 00000000000..fac0ba75b42 --- /dev/null +++ b/internal/googleopenai/thoughts.go @@ -0,0 +1,198 @@ +package googleopenai + +import ( + "bufio" + "bytes" + "io" + "net/http" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Gemini's OpenAI-compatible endpoint emits thought text inline in the +// regular content field, marked with extra_content.google.thought and +// wrapped in .... OpenAI-compatible clients expect +// reasoning in the reasoning_content field instead, so these helpers +// rewrite responses at the transport boundary. +const ( + thoughtOpenMarker = "" + thoughtCloseMarker = "" +) + +// RewriteThoughtResponse rewrites a Gemini OpenAI-compatible chat +// completions response so thought output surfaces as reasoning_content. +// Streaming bodies are rewritten incrementally; JSON bodies are rewritten +// in place. Responses without thought output pass through unchanged. +func RewriteThoughtResponse(resp *http.Response) { + if resp == nil || resp.Body == nil || resp.StatusCode != http.StatusOK { + return + } + contentType := resp.Header.Get("Content-Type") + switch { + case strings.HasPrefix(contentType, "text/event-stream"): + resp.Body = &thoughtStreamBody{ + reader: bufio.NewReader(resp.Body), + closer: resp.Body, + inThought: map[int]bool{}, + } + case strings.HasPrefix(contentType, "application/json"): + body, err := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err != nil || closeErr != nil { + // The client re-reads the replaced body, so surface read + // failures there instead of swallowing them here. + resp.Body = io.NopCloser(&errorReader{err: firstError(err, closeErr)}) + return + } + rewritten := RewriteThoughtCompletion(body) + resp.Body = io.NopCloser(bytes.NewReader(rewritten)) + resp.ContentLength = int64(len(rewritten)) + resp.Header.Set("Content-Length", strconv.Itoa(len(rewritten))) + } +} + +type errorReader struct{ err error } + +func (r *errorReader) Read([]byte) (int, error) { return 0, r.err } + +func firstError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} + +// RewriteThoughtCompletion rewrites a non-streaming chat completion, +// splitting -marked message content into reasoning_content and +// the visible answer. +func RewriteThoughtCompletion(body []byte) []byte { + choices := gjson.GetBytes(body, "choices") + if !choices.IsArray() { + return body + } + out := body + for index, choice := range choices.Array() { + content := choice.Get("message.content") + if content.Type != gjson.String || !strings.HasPrefix(content.Str, thoughtOpenMarker) { + continue + } + reasoning := content.Str[len(thoughtOpenMarker):] + answer := "" + if markerIndex := strings.Index(reasoning, thoughtCloseMarker); markerIndex >= 0 { + answer = reasoning[markerIndex+len(thoughtCloseMarker):] + reasoning = reasoning[:markerIndex] + } + prefix := "choices." + strconv.Itoa(index) + ".message." + updated, err := sjson.SetBytes(out, prefix+"reasoning_content", reasoning) + if err != nil { + return body + } + updated, err = sjson.SetBytes(updated, prefix+"content", answer) + if err != nil { + return body + } + out = updated + } + return out +} + +// thoughtStreamBody rewrites SSE chat completion chunks line by line as the +// client reads them, preserving streaming latency. +type thoughtStreamBody struct { + reader *bufio.Reader + closer io.Closer + pending []byte + err error + // inThought tracks, per choice index, whether the previous delta was + // thought output, so the and markers can be + // stripped at the transitions. + inThought map[int]bool +} + +func (b *thoughtStreamBody) Read(p []byte) (int, error) { + for len(b.pending) == 0 { + if b.err != nil { + return 0, b.err + } + line, err := b.reader.ReadBytes('\n') + if len(line) > 0 { + b.pending = b.rewriteLine(line) + } + b.err = err + } + n := copy(p, b.pending) + b.pending = b.pending[n:] + return n, nil +} + +func (b *thoughtStreamBody) Close() error { + return b.closer.Close() +} + +var streamDataPrefix = []byte("data: ") + +func (b *thoughtStreamBody) rewriteLine(line []byte) []byte { + payload := bytes.TrimPrefix(line, streamDataPrefix) + if len(payload) == len(line) || !bytes.HasPrefix(payload, []byte("{")) { + return line + } + suffixLength := len(payload) - len(bytes.TrimRight(payload, "\r\n")) + suffix := payload[len(payload)-suffixLength:] + payload = payload[:len(payload)-suffixLength] + + choices := gjson.GetBytes(payload, "choices") + if !choices.IsArray() { + return line + } + out := payload + for index, choice := range choices.Array() { + delta := choice.Get("delta") + if !delta.Exists() { + continue + } + deltaPath := "choices." + strconv.Itoa(index) + ".delta" + content := delta.Get("content") + if delta.Get("extra_content.google.thought").Bool() { + text := content.Str + if !b.inThought[index] { + text = strings.TrimPrefix(text, thoughtOpenMarker) + b.inThought[index] = true + } + updated, err := sjson.SetBytes(out, deltaPath+".reasoning_content", text) + if err != nil { + return line + } + updated, err = sjson.DeleteBytes(updated, deltaPath+".content") + if err != nil { + return line + } + out = updated + continue + } + if !b.inThought[index] { + continue + } + if content.Type == gjson.String && content.Str != "" { + b.inThought[index] = false + if strings.HasPrefix(content.Str, thoughtCloseMarker) { + updated, err := sjson.SetBytes(out, deltaPath+".content", strings.TrimPrefix(content.Str, thoughtCloseMarker)) + if err != nil { + return line + } + out = updated + } + } else if delta.Get("tool_calls").Exists() { + b.inThought[index] = false + } + } + rewritten := make([]byte, 0, len(streamDataPrefix)+len(out)+len(suffix)) + rewritten = append(rewritten, streamDataPrefix...) + rewritten = append(rewritten, out...) + rewritten = append(rewritten, suffix...) + return rewritten +} diff --git a/internal/googleopenai/thoughts_test.go b/internal/googleopenai/thoughts_test.go new file mode 100644 index 00000000000..2af2ca72a88 --- /dev/null +++ b/internal/googleopenai/thoughts_test.go @@ -0,0 +1,151 @@ +package googleopenai_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/internal/googleopenai" +) + +// The fixtures mirror live captures from Google's OpenAI-compatible endpoint +// with include_thoughts enabled: thought deltas carry +// extra_content.google.thought and the text is wrapped in markers, +// with the closing marker prefixed onto the first answer delta. +func TestRewriteThoughtResponse_Stream(t *testing.T) { + t.Parallel() + + lines := []string{ + `data: {"choices":[{"delta":{"role":"assistant","content":"**Calculating**\n\nStep one.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"More thinking.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"The answer is ","extra_content":null},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"391."},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":null,"extra_content":{"google":{"thought_signature":"sig123"}}},"finish_reason":"stop","index":0}],"usage":{"completion_tokens":9,"prompt_tokens":16,"total_tokens":627}}`, + ``, + `data: [DONE]`, + ``, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n"))), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + out := strings.Split(string(rewritten), "\n") + require.Len(t, out, len(lines)) + + first := gjson.Get(strings.TrimPrefix(out[0], "data: "), "choices.0.delta") + require.Equal(t, "**Calculating**\n\nStep one.", first.Get("reasoning_content").String()) + require.False(t, first.Get("content").Exists()) + require.Equal(t, "assistant", first.Get("role").String()) + + second := gjson.Get(strings.TrimPrefix(out[2], "data: "), "choices.0.delta") + require.Equal(t, "More thinking.", second.Get("reasoning_content").String()) + require.False(t, second.Get("content").Exists()) + + third := gjson.Get(strings.TrimPrefix(out[4], "data: "), "choices.0.delta") + require.Equal(t, "The answer is ", third.Get("content").String()) + require.False(t, third.Get("reasoning_content").Exists()) + + // Untouched lines pass through byte for byte. + require.Equal(t, lines[6], out[6]) + require.Equal(t, lines[8], out[8]) + require.Equal(t, "data: [DONE]", out[10]) +} + +func TestRewriteThoughtResponse_StreamWithoutThoughts(t *testing.T) { + t.Parallel() + + body := "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"index\":0}]}\n\ndata: [DONE]\n\n" + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, body, string(rewritten)) +} + +func TestRewriteThoughtResponse_StreamToolCallsEndThought(t *testing.T) { + t.Parallel() + + lines := []string{ + `data: {"choices":[{"delta":{"content":"Pick a tool.","extra_content":{"google":{"thought":true}}},"index":0}]}`, + ``, + `data: {"choices":[{"delta":{"content":null,"tool_calls":[{"index":0,"id":"call_1","function":{"name":"f","arguments":"{}"}}]},"index":0}]}`, + ``, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n"))), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + out := strings.Split(string(rewritten), "\n") + + require.Equal(t, "Pick a tool.", gjson.Get(strings.TrimPrefix(out[0], "data: "), "choices.0.delta.reasoning_content").String()) + require.Equal(t, lines[2], out[2]) +} + +func TestRewriteThoughtResponse_JSONCompletion(t *testing.T) { + t.Parallel() + + body := `{"choices":[{"message":{"role":"assistant","content":"**Quick**\n\nThinking here.\n17 * 23 = **391**","extra_content":{"google":{"thought":true,"thought_signature":"sig"}}},"index":0}],"object":"chat.completion"}` + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, resp.ContentLength, int64(len(rewritten))) + + message := gjson.GetBytes(rewritten, "choices.0.message") + require.Equal(t, "**Quick**\n\nThinking here.\n", message.Get("reasoning_content").String()) + require.Equal(t, "17 * 23 = **391**", message.Get("content").String()) + require.Equal(t, "sig", message.Get("extra_content.google.thought_signature").String()) +} + +func TestRewriteThoughtCompletion(t *testing.T) { + t.Parallel() + + t.Run("ThoughtOnlyContent", func(t *testing.T) { + t.Parallel() + out := googleopenai.RewriteThoughtCompletion([]byte(`{"choices":[{"message":{"content":"All thought, no close marker."}}]}`)) + message := gjson.GetBytes(out, "choices.0.message") + require.Equal(t, "All thought, no close marker.", message.Get("reasoning_content").String()) + require.Equal(t, "", message.Get("content").String()) + }) + + t.Run("NoThoughtsUnchanged", func(t *testing.T) { + t.Parallel() + body := `{"choices":[{"message":{"content":"Just an answer."}}]}` + require.Equal(t, body, string(googleopenai.RewriteThoughtCompletion([]byte(body)))) + }) + + t.Run("InvalidJSONUnchanged", func(t *testing.T) { + t.Parallel() + body := `not json` + require.Equal(t, body, string(googleopenai.RewriteThoughtCompletion([]byte(body)))) + }) +} From ba90c731c3b9c4c967760552a15ae9538b5a6031 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:32:28 +0000 Subject: [PATCH 08/19] docs(coderd/x/chatd/chatprovider): note response rewriting in the compat patch overview --- coderd/x/chatd/chatprovider/openai_compat_patches.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches.go b/coderd/x/chatd/chatprovider/openai_compat_patches.go index cded58f1c83..b6b15af6c98 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go @@ -11,8 +11,9 @@ import ( ) // OpenAI-compatible providers share an API shape but differ in the exact JSON -// they accept. These patches adjust Fantasy's serialized request body at the -// transport boundary so higher-level generation code can stay provider agnostic. +// they accept and emit. These patches adjust Fantasy's serialized request body +// and, for Gemini endpoints, the response body at the transport boundary so +// higher-level generation code can stay provider agnostic. func withOpenAICompatRequestPatches( client *http.Client, From 79751f00a4c7c3736cbc58e7e79661acd0496a04 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:42:40 +0000 Subject: [PATCH 09/19] fix(coderd/x/chatd/chatprovider): gate the Gemini thinking_config rewrite on model capability Pre-2.5 and unrecognized Gemini variants have no thinking support, so sending thinking_config could reject previously working requests. Restrict the rewrite to models with a known thinking_level set or the Gemini 2.5 thinking_budget family and leave other Gemini requests untouched. --- .../chatprovider/google_compat_thinking.go | 31 ++++++++++++++++--- .../google_compat_thinking_internal_test.go | 25 +++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking.go b/coderd/x/chatd/chatprovider/google_compat_thinking.go index 74b822f2a72..17dc8002586 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking.go @@ -7,10 +7,13 @@ import ( ) // rewriteGoogleCompatThinkingConfig swaps reasoning_effort for an explicit -// Google thinking_config on Gemini requests. 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. +// 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 := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") @@ -19,9 +22,14 @@ func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { return false } + supported := googleSupportedThinkingLevels(normalized) + if len(supported) == 0 && !googleSupportsThinkingBudget(normalized) { + return false + } + thinkingConfig := map[string]any{"include_thoughts": true} if effort, ok := payload["reasoning_effort"].(string); ok { - if supported := googleSupportedThinkingLevels(normalized); len(supported) > 0 { + if len(supported) > 0 { level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported) thinkingConfig["thinking_level"] = strings.ToLower(level) } else if budget, ok := googleCompatThinkingBudget(effort); ok { @@ -52,6 +60,19 @@ func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { return true } +// googleSupportsThinkingBudget reports whether a Gemini model predating +// thinking_level supports thinking via thinking_budget. Gemini 2.5 is the +// only such family; older and unrecognized variants have no thinking support +// and reject or ignore thinking_config. +func googleSupportsThinkingBudget(normalized string) bool { + rest, ok := strings.CutPrefix(normalized, "gemini-") + if !ok { + return false + } + major, minor, hasVersion := parseGoogleModelVersion(strings.Split(rest, "-")[0]) + return hasVersion && major == 2 && minor >= 5 +} + // 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 diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go index 6e5def47774..506b49a48c5 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go @@ -80,3 +80,28 @@ func TestRewriteGoogleCompatThinkingConfig(t *testing.T) { require.Contains(t, payload, "reasoning_effort") }) } + +func TestRewriteGoogleCompatThinkingConfig_NonThinkingModelsUntouched(t *testing.T) { + t.Parallel() + + // Models without known thinking support must keep their previous request + // shape, whether or not an effort was configured. + for _, modelID := range []string{ + "gemini-1.5-flash", + "gemini-2.0-flash", + "gemini-exp-1206", + } { + t.Run(modelID, func(t *testing.T) { + t.Parallel() + + plain := map[string]any{"model": modelID} + require.False(t, rewriteGoogleCompatThinkingConfig(plain)) + require.NotContains(t, plain, "extra_body") + + withEffort := map[string]any{"model": modelID, "reasoning_effort": "low"} + require.False(t, rewriteGoogleCompatThinkingConfig(withEffort)) + require.Equal(t, "low", withEffort["reasoning_effort"]) + require.NotContains(t, withEffort, "extra_body") + }) + } +} From 0862514684e338675b20c48382352331e9b4697c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:53:19 +0000 Subject: [PATCH 10/19] fix(coderd/x/chatd/chatprovider): restrict thinking_budget support to the 2.5 chat families Specialized Gemini 2.5 variants such as image and TTS models reject thinking_config outright, so a version-only predicate would break previously working requests for them. Recognize only the Pro, Flash, and Flash-Lite family name shapes and fail closed on unknown tokens. --- .../chatprovider/google_compat_thinking.go | 29 +++++++++++++++---- .../google_compat_thinking_internal_test.go | 23 ++++++++++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking.go b/coderd/x/chatd/chatprovider/google_compat_thinking.go index 17dc8002586..bee813aaf91 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking.go @@ -1,6 +1,7 @@ package chatprovider import ( + "strconv" "strings" "github.com/coder/coder/v2/codersdk" @@ -61,16 +62,34 @@ func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { } // googleSupportsThinkingBudget reports whether a Gemini model predating -// thinking_level supports thinking via thinking_budget. Gemini 2.5 is the -// only such family; older and unrecognized variants have no thinking support -// and reject or ignore thinking_config. +// 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 } - major, minor, hasVersion := parseGoogleModelVersion(strings.Split(rest, "-")[0]) - return hasVersion && major == 2 && minor >= 5 + 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 diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go index 506b49a48c5..f7aa90ce283 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go @@ -33,10 +33,17 @@ func TestRewriteGoogleCompatThinkingConfig(t *testing.T) { t.Run("PreGemini3EffortBecomesThinkingBudget", func(t *testing.T) { t.Parallel() - payload := map[string]any{"model": "gemini-2.5-flash", "reasoning_effort": "medium"} - require.True(t, rewriteGoogleCompatThinkingConfig(payload)) - require.NotContains(t, payload, "reasoning_effort") - require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 8192}, thinkingConfig(payload)) + for _, modelID := range []string{ + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-flash-preview-09-2025", + "gemini-2.5-pro-preview-06-05", + } { + payload := map[string]any{"model": modelID, "reasoning_effort": "medium"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload), modelID) + require.NotContains(t, payload, "reasoning_effort", modelID) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 8192}, thinkingConfig(payload), modelID) + } }) t.Run("NoEffortStillIncludesThoughts", func(t *testing.T) { @@ -90,6 +97,14 @@ func TestRewriteGoogleCompatThinkingConfig_NonThinkingModelsUntouched(t *testing "gemini-1.5-flash", "gemini-2.0-flash", "gemini-exp-1206", + // Specialized 2.5 variants reject thinking_config ("Thinking is not + // enabled for this model") even though their version matches the + // thinking-capable 2.5 chat families. + "gemini-2.5-flash-image", + "gemini-2.5-flash-image-preview", + "gemini-2.5-flash-preview-tts", + "gemini-2.5-flash-native-audio-preview-09-2025", + "gemini-2.5-computer-use-preview-10-2025", } { t.Run(modelID, func(t *testing.T) { t.Parallel() From 48ded662d4bb1c72fb2f00daa255b31601d7c7e4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:05:03 +0000 Subject: [PATCH 11/19] fix(coderd/x/chatd/chatprovider): forward pinned Google thinking config on the AI Bridge route Google models backed by an AI Provider route through the OpenAI-compatible client, which ignores the fantasygoogle options key, so an administrator's pinned thinking_level or thinking_budget was silently dropped. Translate the pinned config into the compat request's extra_body and merge it in the transport patch with native-path precedence: a pinned budget wins over the per-turn effort, the effort overrides a pinned level, and an explicitly disabled include_thoughts is preserved. --- coderd/x/chatd/chatprovider/chatprovider.go | 12 +++ .../chatprovider/google_compat_thinking.go | 81 ++++++++++++-- .../google_compat_thinking_internal_test.go | 101 ++++++++++++++++++ .../openai_compat_patches_test.go | 62 +++++++++++ 4 files changed, 245 insertions(+), 11 deletions(-) diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index b56199e6a64..3cf24c15dfb 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -1170,6 +1170,18 @@ func providerOptionsFromChatModelConfig( ) } + // Google models backed by an AI Provider route through the + // OpenAI-compatible client, which ignores the fantasygoogle options key, + // so a pinned thinking configuration must also travel as the compat + // request's extra_body for the transport patch to honor it. + if options.Google != nil && options.Google.ThinkingConfig != nil && + model.Valid() && NormalizeProvider(model.Provider()) == fantasyopenaicompat.Name { + if extraBody := googleCompatExtraBodyFromThinkingConfig(model.ModelID(), options.Google.ThinkingConfig); extraBody != nil { + compatOptions := ensureProviderOptions[fantasyopenaicompat.ProviderOptions](result, fantasyopenaicompat.Name) + compatOptions.ExtraBody = extraBody + } + } + if len(result) == 0 { return nil } diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking.go b/coderd/x/chatd/chatprovider/google_compat_thinking.go index bee813aaf91..f1853031a54 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking.go @@ -4,6 +4,8 @@ import ( "strconv" "strings" + fantasygoogle "charm.land/fantasy/providers/google" + "github.com/coder/coder/v2/codersdk" ) @@ -17,14 +19,8 @@ import ( // request shape untouched. func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { modelID, _ := payload["model"].(string) - normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") - normalized = strings.TrimPrefix(normalized, "google/") - if !strings.HasPrefix(normalized, "gemini-") { - return false - } - - supported := googleSupportedThinkingLevels(normalized) - if len(supported) == 0 && !googleSupportsThinkingBudget(normalized) { + supported, capable := googleCompatThinkingSupport(modelID) + if !capable { return false } @@ -52,15 +48,78 @@ func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { google = map[string]any{} extraBody["google"] = google } - // An explicitly configured thinking_config wins, but reasoning_effort - // must go regardless because Google rejects the combination. - if _, exists := google["thinking_config"]; !exists { + // 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 its +// supported thinking levels (empty for the budget-based 2.5 families). +func googleCompatThinkingSupport(modelID 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 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 diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go index f7aa90ce283..a92e9eeaaf9 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" ) func TestRewriteGoogleCompatThinkingConfig(t *testing.T) { @@ -120,3 +122,102 @@ func TestRewriteGoogleCompatThinkingConfig_NonThinkingModelsUntouched(t *testing }) } } + +func TestGoogleCompatExtraBodyFromThinkingConfig(t *testing.T) { + t.Parallel() + + int64Ptr := func(v int64) *int64 { return &v } + strPtr := func(v string) *string { return &v } + boolPtr := func(v bool) *bool { return &v } + thinkingConfig := func(extraBody map[string]any) map[string]any { + body, _ := extraBody["extra_body"].(map[string]any) + google, _ := body["google"].(map[string]any) + config, _ := google["thinking_config"].(map[string]any) + return config + } + + t.Run("PinnedLevelClampedWithThoughtsDefaultOn", func(t *testing.T) { + t.Parallel() + extraBody := googleCompatExtraBodyFromThinkingConfig("gemini-3.0-pro", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: strPtr("minimal"), + }) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "low"}, thinkingConfig(extraBody)) + }) + + t.Run("PinnedBudgetWithExplicitThoughtsOff", func(t *testing.T) { + t.Parallel() + extraBody := googleCompatExtraBodyFromThinkingConfig("gemini-2.5-flash", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingBudget: int64Ptr(2048), + IncludeThoughts: boolPtr(false), + }) + require.Equal(t, map[string]any{"include_thoughts": false, "thinking_budget": int64(2048)}, thinkingConfig(extraBody)) + }) + + t.Run("PinnedLevelDroppedForBudgetModels", func(t *testing.T) { + t.Parallel() + extraBody := googleCompatExtraBodyFromThinkingConfig("gemini-2.5-flash", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: strPtr("high"), + }) + require.Equal(t, map[string]any{"include_thoughts": true}, thinkingConfig(extraBody)) + }) + + t.Run("NonThinkingModelNil", func(t *testing.T) { + t.Parallel() + require.Nil(t, googleCompatExtraBodyFromThinkingConfig("gemini-2.5-flash-image", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: strPtr("high"), + })) + }) + + t.Run("NilConfigNil", func(t *testing.T) { + t.Parallel() + require.Nil(t, googleCompatExtraBodyFromThinkingConfig("gemini-3-flash-preview", nil)) + }) +} + +func TestRewriteGoogleCompatThinkingConfig_PinnedConfigPrecedence(t *testing.T) { + t.Parallel() + + t.Run("EffortOverridesPinnedLevel", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "model": "gemini-3-flash-preview", + "reasoning_effort": "low", + "extra_body": map[string]any{"google": map[string]any{ + "thinking_config": map[string]any{"include_thoughts": false, "thinking_level": "high"}, + }}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + google := payload["extra_body"].(map[string]any)["google"].(map[string]any) + require.Equal(t, map[string]any{"include_thoughts": false, "thinking_level": "low"}, google["thinking_config"]) + }) + + t.Run("PinnedBudgetWinsOverEffort", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "model": "gemini-2.5-flash", + "reasoning_effort": "high", + "extra_body": map[string]any{"google": map[string]any{ + "thinking_config": map[string]any{"include_thoughts": true, "thinking_budget": int64(2048)}, + }}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + google := payload["extra_body"].(map[string]any)["google"].(map[string]any) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": int64(2048)}, google["thinking_config"]) + }) + + t.Run("EffortAddsBudgetToPinnedThoughtsOnly", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "model": "gemini-2.5-flash", + "reasoning_effort": "medium", + "extra_body": map[string]any{"google": map[string]any{ + "thinking_config": map[string]any{"include_thoughts": true}, + }}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + google := payload["extra_body"].(map[string]any)["google"].(map[string]any) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 8192}, google["thinking_config"]) + }) +} diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go index 444c8374a19..590416125f2 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go @@ -12,7 +12,9 @@ import ( fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/internal/googleopenai" ) @@ -311,3 +313,63 @@ func (st *sseChatCompletionTransport) RoundTrip(req *http.Request) (*http.Respon Body: io.NopCloser(strings.NewReader(st.stream)), }, nil } + +func TestModelFromConfig_GeminiOpenAICompatPinnedThinkingConfig(t *testing.T) { + t.Parallel() + + transport := &captureChatCompletionTransport{} + model, err := chatprovider.ModelFromConfig( + fantasyopenaicompat.Name, + "gemini-3-flash-preview", + chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{ + fantasyopenaicompat.Name: "test-key", + }, + BaseURLByProvider: map[string]string{ + fantasyopenaicompat.Name: "http://coder-aibridge/v1", + }, + }, + chatprovider.UserAgent(), + nil, + &http.Client{Transport: transport}, + nil, + ) + require.NoError(t, err) + + // A Google-configured pinned thinking level must reach the compat + // request as extra_body, and the per-turn effort must override it. + requestedEffort := "low" + providerOptions := chatprovider.ProviderOptionsForCall(model, codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: &requestedEffort, + Max: ptr.Ref("max"), + }, + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("high"), + IncludeThoughts: ptr.Ref(false), + }, + }, + }, + }, &requestedEffort) + + _, err = model.LanguageModel().Generate(t.Context(), fantasy.Call{ + Prompt: []fantasy.Message{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "current turn"}}, + }}, + ProviderOptions: providerOptions, + }) + require.NoError(t, err) + require.NotNil(t, transport.body) + + require.NotContains(t, transport.body, "reasoning_effort") + extraBody, ok := transport.body["extra_body"].(map[string]any) + require.True(t, ok, "pinned Google thinking config must reach the request as extra_body") + google := extraBody["google"].(map[string]any) + require.Equal(t, map[string]any{ + "include_thoughts": false, + "thinking_level": "low", + }, google["thinking_config"]) +} From 1b1833f36791777af2e037bf8eac92724a7e9263 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:19:31 +0000 Subject: [PATCH 12/19] fix(coderd/x/chatd/chatprovider): request thought summaries on the native Google path Google returns thought summaries only when include_thoughts is requested, so reasoning-effort generations on the native fantasygoogle route performed thinking without surfacing it in the chat UI. Default include_thoughts on when the effort populates the thinking config, matching the OpenAI-compatible path, and preserve an explicitly configured value in either direction. --- coderd/x/chatd/chatprovider/reasoningeffort.go | 7 +++++++ .../reasoningeffort_internal_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 779bb44a0f0..116fc1981c1 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -144,6 +144,13 @@ func applyReasoningEffort( 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 { diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index 03df571b5d5..2e11f6f551a 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -104,6 +104,10 @@ func TestApplyReasoningEffort(t *testing.T) { require.NotNil(t, providerOptions.ThinkingConfig) require.NotNil(t, providerOptions.ThinkingConfig.ThinkingLevel) require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel) + // Google returns thought summaries only when requested, so + // effort generations default include_thoughts on. + require.NotNil(t, providerOptions.ThinkingConfig.IncludeThoughts) + require.True(t, *providerOptions.ThinkingConfig.IncludeThoughts) }, }, { @@ -129,6 +133,18 @@ func TestApplyReasoningEffort(t *testing.T) { require.Nil(t, providerOptions.ThinkingConfig.ThinkingLevel) }, }, + { + name: "GoogleExplicitThoughtsOffPreserved", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{IncludeThoughts: ptr.Ref(false)}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.NotNil(t, providerOptions.ThinkingConfig.IncludeThoughts) + require.False(t, *providerOptions.ThinkingConfig.IncludeThoughts) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel) + }, + }, { name: "GoogleGemini25GetsNoLevel", provider: fantasygoogle.Name, From b98252d603a196ba0b9703dcdee9af2b2dce7307 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:31:16 +0000 Subject: [PATCH 13/19] fix(coderd/x/chatd/chatprovider): clamp effort none to a low thinking budget on Gemini 2.5 Pro Gemini 2.5 Pro cannot disable thinking and rejects thinking_budget 0 ("This model only works in thinking mode", verified live), so effort none on the explicit thinking_config rewrite failed the whole generation. Clamp none up to the low budget for Pro models, matching the reasoning_effort clamp path. --- .../chatprovider/google_compat_thinking.go | 29 ++++++++++++------- .../google_compat_thinking_internal_test.go | 13 +++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking.go b/coderd/x/chatd/chatprovider/google_compat_thinking.go index f1853031a54..795936f4a3f 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking.go @@ -1,6 +1,7 @@ package chatprovider import ( + "slices" "strconv" "strings" @@ -19,7 +20,7 @@ import ( // request shape untouched. func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { modelID, _ := payload["model"].(string) - supported, capable := googleCompatThinkingSupport(modelID) + normalized, supported, capable := googleCompatThinkingSupport(modelID) if !capable { return false } @@ -29,7 +30,7 @@ func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { if len(supported) > 0 { level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported) thinkingConfig["thinking_level"] = strings.ToLower(level) - } else if budget, ok := googleCompatThinkingBudget(effort); ok { + } else if budget, ok := googleCompatThinkingBudget(normalized, effort); ok { thinkingConfig["thinking_budget"] = budget } else { // Unknown effort value: keep the request untouched rather than @@ -69,19 +70,20 @@ func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { } // googleCompatThinkingSupport reports whether a model ID on the -// OpenAI-compatible path is a thinking-capable Gemini model, returning its -// supported thinking levels (empty for the budget-based 2.5 families). -func googleCompatThinkingSupport(modelID string) ([]fantasygoogle.ThinkingLevel, bool) { +// 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 + return "", nil, false } supported := googleSupportedThinkingLevels(normalized) if len(supported) == 0 && !googleSupportsThinkingBudget(normalized) { - return nil, false + return "", nil, false } - return supported, true + return normalized, supported, true } // googleCompatExtraBodyFromThinkingConfig translates a config-pinned Google @@ -97,7 +99,7 @@ func googleCompatExtraBodyFromThinkingConfig( if config == nil { return nil } - supported, capable := googleCompatThinkingSupport(modelID) + _, supported, capable := googleCompatThinkingSupport(modelID) if !capable { return nil } @@ -154,10 +156,15 @@ func googleSupportsThinkingBudget(normalized string) bool { // 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. -func googleCompatThinkingBudget(effort string) (int, bool) { +// 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 diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go index a92e9eeaaf9..06a9b2261fb 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go @@ -48,6 +48,19 @@ func TestRewriteGoogleCompatThinkingConfig(t *testing.T) { } }) + t.Run("EffortNoneBudget", func(t *testing.T) { + t.Parallel() + // Flash can disable thinking; Pro rejects budget 0 ("This model only + // works in thinking mode", verified live) so none clamps to low. + payload := map[string]any{"model": "gemini-2.5-flash", "reasoning_effort": "none"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 0}, thinkingConfig(payload)) + + payload = map[string]any{"model": "gemini-2.5-pro", "reasoning_effort": "none"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 1024}, thinkingConfig(payload)) + }) + t.Run("NoEffortStillIncludesThoughts", func(t *testing.T) { t.Parallel() payload := map[string]any{"model": "gemini-2.5-flash"} From cea28de27aa26715ee07fafd870ac73b2d1c76ac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:40:17 +0000 Subject: [PATCH 14/19] fix(internal/googleopenai): gate the non-streaming thought rewrite on Gemini's thought metadata An answer that legitimately begins with the marker text was reclassified as reasoning and truncated. Real thought output always carries message.extra_content.google.thought, which the streaming path already requires, so demand it before splitting non-streaming content. --- internal/googleopenai/thoughts.go | 6 +++++- internal/googleopenai/thoughts_test.go | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/googleopenai/thoughts.go b/internal/googleopenai/thoughts.go index fac0ba75b42..853afce930f 100644 --- a/internal/googleopenai/thoughts.go +++ b/internal/googleopenai/thoughts.go @@ -78,7 +78,11 @@ func RewriteThoughtCompletion(body []byte) []byte { out := body for index, choice := range choices.Array() { content := choice.Get("message.content") - if content.Type != gjson.String || !strings.HasPrefix(content.Str, thoughtOpenMarker) { + // The thought metadata gates the rewrite so an answer that + // legitimately begins with the marker text is left alone; Gemini sets + // it on every message that carries thought output. + if content.Type != gjson.String || !strings.HasPrefix(content.Str, thoughtOpenMarker) || + !choice.Get("message.extra_content.google.thought").Bool() { continue } reasoning := content.Str[len(thoughtOpenMarker):] diff --git a/internal/googleopenai/thoughts_test.go b/internal/googleopenai/thoughts_test.go index 2af2ca72a88..c8b257b6cb8 100644 --- a/internal/googleopenai/thoughts_test.go +++ b/internal/googleopenai/thoughts_test.go @@ -131,12 +131,21 @@ func TestRewriteThoughtCompletion(t *testing.T) { t.Run("ThoughtOnlyContent", func(t *testing.T) { t.Parallel() - out := googleopenai.RewriteThoughtCompletion([]byte(`{"choices":[{"message":{"content":"All thought, no close marker."}}]}`)) + out := googleopenai.RewriteThoughtCompletion([]byte(`{"choices":[{"message":{"content":"All thought, no close marker.","extra_content":{"google":{"thought":true}}}}]}`)) message := gjson.GetBytes(out, "choices.0.message") require.Equal(t, "All thought, no close marker.", message.Get("reasoning_content").String()) require.Equal(t, "", message.Get("content").String()) }) + t.Run("MarkerTextWithoutThoughtMetadataUnchanged", func(t *testing.T) { + t.Parallel() + // An answer that legitimately begins with the marker text must not + // be reclassified as reasoning; real thought output always carries + // extra_content.google.thought. + body := `{"choices":[{"message":{"content":"example XML is the requested format."}}]}` + require.Equal(t, body, string(googleopenai.RewriteThoughtCompletion([]byte(body)))) + }) + t.Run("NoThoughtsUnchanged", func(t *testing.T) { t.Parallel() body := `{"choices":[{"message":{"content":"Just an answer."}}]}` From 831676f14aa54cdd245af947eabb41d710b709b7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:52:22 +0000 Subject: [PATCH 15/19] fix(aibridge/intercept/chatcompletions): serialize Google blocking responses from the raw upstream body The typed openai.ChatCompletion round trip drops provider-specific fields, so a blocking Gemini response with thought output lost its extra_content thought metadata while keeping the markers inline, leaking them to clients as answer text. Serialize the final blocking response from the raw body for Google upstreams, mirroring marshalChunk on the streaming path, with the same ID and usage overrides. --- .../intercept/chatcompletions/blocking.go | 27 +++++++++- .../chatcompletions/blocking_internal_test.go | 51 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 aibridge/intercept/chatcompletions/blocking_internal_test.go diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go index d5913557d04..d5c66f25335 100644 --- a/aibridge/intercept/chatcompletions/blocking.go +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" + "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" @@ -23,6 +24,7 @@ import ( "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/aibridge/recorder" "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/internal/googleopenai" ) type BlockingInterception struct { @@ -247,7 +249,7 @@ func (i *BlockingInterception) ProcessRequest(w http.ResponseWriter, r *http.Req } w.Header().Set("Content-Type", "application/json") - out, err := json.Marshal(completion) + out, err := i.marshalCompletion(completion) if err != nil { out, _ = json.Marshal(i.newErrorResponse(xerrors.Errorf("failed to marshal response: %w", err))) w.WriteHeader(http.StatusInternalServerError) @@ -260,6 +262,29 @@ func (i *BlockingInterception) ProcessRequest(w http.ResponseWriter, r *http.Req return nil } +// marshalCompletion renders the final blocking response. Google responses are +// serialized from the raw upstream body, mirroring marshalChunk on the +// streaming path, because the typed round trip drops provider-specific fields +// such as Gemini's extra_content thought metadata, which clients need to +// separate thought output from the answer. The ID and usage overrides applied +// to the typed completion are re-applied on top of the raw body. +func (i *BlockingInterception) marshalCompletion(completion *openai.ChatCompletion) ([]byte, error) { + if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) || completion.RawJSON() == "" { + return json.Marshal(completion) + } + sj, err := sjson.Set(completion.RawJSON(), "id", completion.ID) + if err != nil { + return nil, xerrors.Errorf("marshal completion id failed: %w", err) + } + if completion.Usage.CompletionTokens > 0 { + sj, err = sjson.Set(sj, "usage", completion.Usage) + if err != nil { + return nil, xerrors.Errorf("marshal completion usage failed: %w", err) + } + } + return []byte(sj), nil +} + // newChatCompletion routes by credential type, returning the upstream // completion, the number of key attempts made for this call, and any error. A // centralized key pool fails over across keys, while BYOK authenticates with a diff --git a/aibridge/intercept/chatcompletions/blocking_internal_test.go b/aibridge/intercept/chatcompletions/blocking_internal_test.go new file mode 100644 index 00000000000..fcec8f5a51e --- /dev/null +++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go @@ -0,0 +1,51 @@ +package chatcompletions + +import ( + "encoding/json" + "testing" + + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/aibridge/intercept" +) + +// The typed openai.ChatCompletion round trip drops provider-specific fields, +// so Google blocking responses must be serialized from the raw upstream body +// or Gemini's thought metadata never reaches the client. +func TestBlockingMarshalCompletionPreservesGoogleExtraContent(t *testing.T) { + t.Parallel() + + raw := `{"id":"upstream-id","object":"chat.completion","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hiddenanswer","extra_content":{"google":{"thought":true,"thought_signature":"sig"}}}}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}` + var completion openai.ChatCompletion + require.NoError(t, json.Unmarshal([]byte(raw), &completion)) + completion.ID = "bridge-id" + completion.Usage.CompletionTokens = 7 + + t.Run("GoogleUpstreamKeepsRawFields", func(t *testing.T) { + t.Parallel() + + out, err := (&BlockingInterception{interceptionBase: interceptionBase{ + cfg: intercept.Config{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"}, + }}).marshalCompletion(&completion) + require.NoError(t, err) + + require.True(t, gjson.GetBytes(out, "choices.0.message.extra_content.google.thought").Bool()) + require.Equal(t, "sig", gjson.GetBytes(out, "choices.0.message.extra_content.google.thought_signature").String()) + require.Equal(t, "bridge-id", gjson.GetBytes(out, "id").String()) + require.Equal(t, int64(7), gjson.GetBytes(out, "usage.completion_tokens").Int()) + }) + + t.Run("OtherUpstreamsUseTypedMarshal", func(t *testing.T) { + t.Parallel() + + out, err := (&BlockingInterception{interceptionBase: interceptionBase{ + cfg: intercept.Config{BaseURL: "https://api.openai.com/v1"}, + }}).marshalCompletion(&completion) + require.NoError(t, err) + + require.False(t, gjson.GetBytes(out, "choices.0.message.extra_content").Exists()) + require.Equal(t, "bridge-id", gjson.GetBytes(out, "id").String()) + }) +} From 24a6c429a168e22dc6a4712670aedf3198d9b3ea Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:21:23 +0000 Subject: [PATCH 16/19] fix(coderd/x/chatd/chatprovider): drop the MINIMAL thinking level for Gemini 3.7+ Flash gemini-3.7-flash rejects thinking_level MINIMAL with HTTP 400, so the default reasoning effort (none) failed every generation. Versions 3 through 3.6 of the flash and flash-lite families still accept it (live-verified against the Google API). The versionless latest aliases and unknown future flash versions also stay off MINIMAL because gemini-flash-latest already tracks 3.7 and returns the same 400, while clamping up to LOW is accepted by every flash release. --- .../x/chatd/chatprovider/reasoningeffort.go | 25 ++++++++++++++----- .../reasoningeffort_internal_test.go | 23 +++++++++++------ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 116fc1981c1..39541a7b03d 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -224,12 +224,12 @@ var googleThinkingLevelsAscending = []fantasygoogle.ThinkingLevel{ // 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 accepts all four, 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. Non-Gemini and unrecognized model IDs -// return nil so reasoning effort degrades to a no-op instead of a rejected -// request. +// 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. +// Non-Gemini and unrecognized model IDs return nil so reasoning effort +// degrades to a no-op instead of a rejected request. func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel { normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") rest, ok := strings.CutPrefix(normalized, "gemini-") @@ -260,6 +260,19 @@ func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel 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 isPro && hasVersion && major == 3 && minor == 0: return []fantasygoogle.ThinkingLevel{ diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index 2e11f6f551a..3f9c726f47f 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -265,10 +265,16 @@ func TestGoogleCompatReasoningEffort(t *testing.T) { // Gemini 3.0 Pro supports only LOW/HIGH. {modelID: "gemini-3-pro-preview", effort: "medium", want: "high", wantOK: true}, {modelID: "gemini-3-pro-preview", effort: "minimal", want: "low", wantOK: true}, - // The Gemini 3 Flash family supports all four levels. + // The Gemini 3 Flash family supports all four levels through 3.6. {modelID: "gemini-3-flash-preview", effort: "minimal", want: "minimal", wantOK: true}, {modelID: "gemini-3-flash-preview", effort: "medium", want: "medium", wantOK: true}, {modelID: "gemini-3-flash-preview", effort: "max", want: "high", wantOK: true}, + {modelID: "gemini-3.6-flash", effort: "none", want: "minimal", wantOK: true}, + // Gemini 3.7 Flash dropped MINIMAL, so the lowest efforts clamp + // up to low instead of failing the request. + {modelID: "gemini-3.7-flash", effort: "none", want: "low", wantOK: true}, + {modelID: "gemini-3.7-flash", effort: "minimal", want: "low", wantOK: true}, + {modelID: "gemini-3.7-flash", effort: "medium", want: "medium", wantOK: true}, // Pre-Gemini-3 models accept none/low/medium/high; none stays // usable on Flash but clamps to low on Pro, which cannot // disable thinking. @@ -308,12 +314,15 @@ func TestGoogleSupportedThinkingLevels(t *testing.T) { modelID string want []fantasygoogle.ThinkingLevel }{ - {modelID: "gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, {modelID: "gemini-3-flash-preview", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, - {modelID: "models/gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, - {modelID: " Gemini-4-Flash ", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, - {modelID: "gemini-flash-latest", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, - {modelID: "gemini-flash-lite-latest", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.5-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.5-flash-lite", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.6-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "models/gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: " Gemini-4-Flash ", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-flash-latest", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-flash-lite-latest", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, {modelID: "gemini-3-pro-preview", want: []fantasygoogle.ThinkingLevel{low, high}}, {modelID: "gemini-3.1-pro-preview", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, {modelID: "gemini-10.5-pro", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, @@ -343,7 +352,7 @@ func TestClampGoogleThinkingLevel(t *testing.T) { gemini3Pro := googleSupportedThinkingLevels("gemini-3-pro-preview") proImage := googleSupportedThinkingLevels("gemini-3-pro-image-preview") - flash := googleSupportedThinkingLevels("gemini-3.7-flash") + flash := googleSupportedThinkingLevels("gemini-3.6-flash") tests := []struct { name string From 2b5e582154715f8c7188077ae95cf704952c6a0f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:37:39 +0000 Subject: [PATCH 17/19] fix(coderd/x/chatd/chatprovider): fail closed for specialized Gemini 3 variants Codex found that the thinking-level map classified every Gemini 3+ model containing the flash token as thinking-capable, so specialized variants like gemini-3.1-flash-live-preview and gemini-3.1-flash-tts-preview got thinking_config injected on the compat path and rejected the request. Live-verified: the tts variant returns 400 (thinking level not supported), the live variant does not serve generateContent, and gemini-omni-flash-preview only supports the Interactions API. Gate the Gemini 3+ set to the Pro and Flash chat families with the same unknown-token fail-closed rule as googleSupportsThinkingBudget. Also address human review feedback: assert the rewritten completion keeps the bridge-side usage override, and join read and close errors with errors.Join instead of a hand-rolled firstError helper. --- .../chatcompletions/blocking_internal_test.go | 1 + .../google_compat_thinking_internal_test.go | 5 +++ .../x/chatd/chatprovider/reasoningeffort.go | 43 +++++++++++++------ .../reasoningeffort_internal_test.go | 7 ++- internal/googleopenai/thoughts.go | 12 +----- 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/aibridge/intercept/chatcompletions/blocking_internal_test.go b/aibridge/intercept/chatcompletions/blocking_internal_test.go index fcec8f5a51e..06614df15cd 100644 --- a/aibridge/intercept/chatcompletions/blocking_internal_test.go +++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go @@ -47,5 +47,6 @@ func TestBlockingMarshalCompletionPreservesGoogleExtraContent(t *testing.T) { require.False(t, gjson.GetBytes(out, "choices.0.message.extra_content").Exists()) require.Equal(t, "bridge-id", gjson.GetBytes(out, "id").String()) + require.Equal(t, int64(7), gjson.GetBytes(out, "usage.completion_tokens").Int()) }) } diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go index 06a9b2261fb..9c6c034e2cf 100644 --- a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go +++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go @@ -120,6 +120,11 @@ func TestRewriteGoogleCompatThinkingConfig_NonThinkingModelsUntouched(t *testing "gemini-2.5-flash-preview-tts", "gemini-2.5-flash-native-audio-preview-09-2025", "gemini-2.5-computer-use-preview-10-2025", + // Specialized Gemini 3 variants likewise reject thinking_level or + // do not serve generateContent at all. + "gemini-3.1-flash-live-preview", + "gemini-3.1-flash-tts-preview", + "gemini-omni-flash-preview", } { t.Run(modelID, func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 39541a7b03d..89f2e4e0fb9 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -228,8 +228,10 @@ var googleThinkingLevelsAscending = []fantasygoogle.ThinkingLevel{ // 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. -// Non-Gemini and unrecognized model IDs return nil so reasoning effort -// degrades to a no-op instead of a rejected request. +// 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-") @@ -247,9 +249,29 @@ func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel return nil } - isPro := slices.Contains(segments, "pro") - isFlash := slices.Contains(segments, "flash") - isImage := slices.Contains(segments, "image") + 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: @@ -274,22 +296,15 @@ func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel } } return slices.Clone(googleThinkingLevelsAscending) - case isPro && hasVersion && major == 3 && minor == 0: + case hasVersion && major == 3 && minor == 0: return []fantasygoogle.ThinkingLevel{ fantasygoogle.ThinkingLevelLow, fantasygoogle.ThinkingLevelHigh, } - case isPro: - return []fantasygoogle.ThinkingLevel{ - fantasygoogle.ThinkingLevelLow, - fantasygoogle.ThinkingLevelMedium, - fantasygoogle.ThinkingLevelHigh, - } default: - // Unknown Gemini 3+ variant: LOW and HIGH are the intersection of - // every documented non-image model's supported set. return []fantasygoogle.ThinkingLevel{ fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelMedium, fantasygoogle.ThinkingLevelHigh, } } diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index 3f9c726f47f..004baca7a15 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -329,7 +329,12 @@ func TestGoogleSupportedThinkingLevels(t *testing.T) { {modelID: "gemini-pro-latest", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, {modelID: "gemini-3-pro-image-preview", want: []fantasygoogle.ThinkingLevel{high}}, {modelID: "gemini-3.1-flash-image", want: []fantasygoogle.ThinkingLevel{minimal, high}}, - {modelID: "gemini-3-ultra", want: []fantasygoogle.ThinkingLevel{low, high}}, + // Specialized variants reject thinking_level or generateContent + // outright, and unknown families fail closed with them. + {modelID: "gemini-3.1-flash-live-preview", want: nil}, + {modelID: "gemini-3.1-flash-tts-preview", want: nil}, + {modelID: "gemini-omni-flash-preview", want: nil}, + {modelID: "gemini-3-ultra", want: nil}, {modelID: "gemini-2.5-flash", want: nil}, {modelID: "gemini-2.0-flash", want: nil}, {modelID: "gemini-1.5-flash-latest", want: nil}, diff --git a/internal/googleopenai/thoughts.go b/internal/googleopenai/thoughts.go index 853afce930f..613e71a785a 100644 --- a/internal/googleopenai/thoughts.go +++ b/internal/googleopenai/thoughts.go @@ -3,6 +3,7 @@ package googleopenai import ( "bufio" "bytes" + "errors" "io" "net/http" "strconv" @@ -44,7 +45,7 @@ func RewriteThoughtResponse(resp *http.Response) { if err != nil || closeErr != nil { // The client re-reads the replaced body, so surface read // failures there instead of swallowing them here. - resp.Body = io.NopCloser(&errorReader{err: firstError(err, closeErr)}) + resp.Body = io.NopCloser(&errorReader{err: errors.Join(err, closeErr)}) return } rewritten := RewriteThoughtCompletion(body) @@ -58,15 +59,6 @@ type errorReader struct{ err error } func (r *errorReader) Read([]byte) (int, error) { return 0, r.err } -func firstError(errs ...error) error { - for _, err := range errs { - if err != nil { - return err - } - } - return nil -} - // RewriteThoughtCompletion rewrites a non-streaming chat completion, // splitting -marked message content into reasoning_content and // the visible answer. From daa2a3a8d04dbe3a3c8a1f3829b912eefa127230 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:42:06 +0000 Subject: [PATCH 18/19] refactor(coderd/x/googleopenai): move googleopenai out of the top-level internal package Review feedback: avoid a top-level internal package. Mechanical git mv plus import path rewrites, no code changes. --- aibridge/intercept/chatcompletions/blocking.go | 2 +- aibridge/intercept/chatcompletions/google_openai_compat.go | 2 +- .../chatcompletions/google_openai_compat_internal_test.go | 2 +- coderd/x/chatd/chatprovider/openai_compat_patches.go | 2 +- coderd/x/chatd/chatprovider/openai_compat_patches_test.go | 2 +- {internal => coderd/x}/googleopenai/thought_signature.go | 0 {internal => coderd/x}/googleopenai/thought_signature_test.go | 2 +- {internal => coderd/x}/googleopenai/thoughts.go | 0 {internal => coderd/x}/googleopenai/thoughts_test.go | 2 +- 9 files changed, 7 insertions(+), 7 deletions(-) rename {internal => coderd/x}/googleopenai/thought_signature.go (100%) rename {internal => coderd/x}/googleopenai/thought_signature_test.go (98%) rename {internal => coderd/x}/googleopenai/thoughts.go (100%) rename {internal => coderd/x}/googleopenai/thoughts_test.go (99%) diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go index d5c66f25335..10733ca5905 100644 --- a/aibridge/intercept/chatcompletions/blocking.go +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -24,7 +24,7 @@ import ( "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/aibridge/recorder" "github.com/coder/coder/v2/aibridge/tracing" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) type BlockingInterception struct { diff --git a/aibridge/intercept/chatcompletions/google_openai_compat.go b/aibridge/intercept/chatcompletions/google_openai_compat.go index fea6a78d8de..41cce98200e 100644 --- a/aibridge/intercept/chatcompletions/google_openai_compat.go +++ b/aibridge/intercept/chatcompletions/google_openai_compat.go @@ -6,7 +6,7 @@ import ( "github.com/openai/openai-go/v3/option" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) func (i *interceptionBase) chatCompletionRequestBody() ([]byte, error) { diff --git a/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go index 24e10764456..fdf659e7a25 100644 --- a/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go +++ b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/aibridge/intercept" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) func TestGoogleOpenAICompatThoughtSignaturePatchSurvivesParamRoundTrip(t *testing.T) { diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches.go b/coderd/x/chatd/chatprovider/openai_compat_patches.go index b6b15af6c98..e9bd5fe143e 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go @@ -7,7 +7,7 @@ import ( "net/http" "strings" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) // OpenAI-compatible providers share an API shape but differ in the exact JSON diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go index 590416125f2..07cfffcb3cd 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go @@ -14,8 +14,8 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/coderd/x/googleopenai" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/internal/googleopenai" ) func TestModelFromConfig_GeminiOpenAICompatThoughtSignatures(t *testing.T) { diff --git a/internal/googleopenai/thought_signature.go b/coderd/x/googleopenai/thought_signature.go similarity index 100% rename from internal/googleopenai/thought_signature.go rename to coderd/x/googleopenai/thought_signature.go diff --git a/internal/googleopenai/thought_signature_test.go b/coderd/x/googleopenai/thought_signature_test.go similarity index 98% rename from internal/googleopenai/thought_signature_test.go rename to coderd/x/googleopenai/thought_signature_test.go index c73bf25ee7f..ce9d7e89e93 100644 --- a/internal/googleopenai/thought_signature_test.go +++ b/coderd/x/googleopenai/thought_signature_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) func TestShouldPatchOpenAICompatRequest(t *testing.T) { diff --git a/internal/googleopenai/thoughts.go b/coderd/x/googleopenai/thoughts.go similarity index 100% rename from internal/googleopenai/thoughts.go rename to coderd/x/googleopenai/thoughts.go diff --git a/internal/googleopenai/thoughts_test.go b/coderd/x/googleopenai/thoughts_test.go similarity index 99% rename from internal/googleopenai/thoughts_test.go rename to coderd/x/googleopenai/thoughts_test.go index c8b257b6cb8..62e4e7b8957 100644 --- a/internal/googleopenai/thoughts_test.go +++ b/coderd/x/googleopenai/thoughts_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/tidwall/gjson" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) // The fixtures mirror live captures from Google's OpenAI-compatible endpoint From b67cc8e54df864c7727adc14b81d7009a957bc68 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:00:31 +0000 Subject: [PATCH 19/19] test(site/src/pages/AISettingsPage/ModelsPage): cover thinking level and budget mutual exclusion Codex asked for interaction coverage of the new Google thinking_level selector's conflicts_with wiring. The story selects a level and asserts the budget input is disabled, clears it, sets a budget, and asserts the level select is disabled, covering both directions of the exclusion. --- .../components/ModelForm.stories.tsx | 38 +++++++++++++++++++ .../AISettingsPage/ModelsPage/testFixtures.ts | 16 ++++++++ 2 files changed, 54 insertions(+) diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 7c1c3bf54ca..55b4a429204 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -9,6 +9,7 @@ import { MockAnthropicProviderState, MockAzureProviderState, MockDisabledProviderState, + MockGoogleProviderState, MockOpenAIProviderState, mockClaude, mockGPT5, @@ -423,6 +424,43 @@ export const ReasoningEffortValidationError: Story = { }, }; +// thinking_level and thinking_budget are mutually exclusive on Google +// models: setting either one disables the other until it is cleared. +export const GoogleThinkingLevelBudgetMutualExclusion: Story = { + args: { + providerStates: [MockGoogleProviderState], + selectedProviderState: MockGoogleProviderState, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: /provider configuration/i }), + ); + const budget = canvas.getByLabelText(/thinking config thinking budget/i); + const level = canvas.getByRole("combobox", { + name: /thinking config thinking level/i, + }); + await expect(budget).toBeEnabled(); + await expect(level).toBeEnabled(); + + await userEvent.click(level); + await userEvent.click(await screen.findByRole("option", { name: "Low" })); + await expect(budget).toBeDisabled(); + + await userEvent.click(level); + await userEvent.click( + await screen.findByRole("option", { name: "Default" }), + ); + await expect(budget).toBeEnabled(); + + await userEvent.type(budget, "2048"); + await expect(level).toBeDisabled(); + + await userEvent.clear(budget); + await expect(level).toBeEnabled(); + }, +}; + // The catalog fallback covers an entitled deployment with no matching price // book row. Values come from the baked-in catalog and must not be submitted. export const CostEstimateFieldsAreImmutable: Story = { diff --git a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts index 48097953489..7c7e49af191 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts +++ b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts @@ -82,6 +82,22 @@ export const MockAnthropicProviderState: ProviderState = { modelConfigs: [mockClaude], }; +const MockGoogleProviderConfig: ChatProviderConfig = { + ...MockOpenAIProviderConfig, + id: "prov-google", + provider: "google", + display_name: "Google", +}; + +export const MockGoogleProviderState: ProviderState = { + ...MockOpenAIProviderState, + key: "prov-google", + provider: "google", + label: "Google", + providerConfig: MockGoogleProviderConfig, + modelConfigs: [], +}; + const MockBedrockProviderConfig: ChatProviderConfig = { ...MockOpenAIProviderConfig, id: "prov-bedrock",