diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go
index d5913557d04..10733ca5905 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/coderd/x/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..06614df15cd
--- /dev/null
+++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go
@@ -0,0 +1,52 @@
+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())
+ require.Equal(t, int64(7), gjson.GetBytes(out, "usage.completion_tokens").Int())
+ })
+}
diff --git a/aibridge/intercept/chatcompletions/google_openai_compat.go b/aibridge/intercept/chatcompletions/google_openai_compat.go
index 251cbc71a01..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) {
@@ -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..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) {
@@ -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/exp_chats.go b/coderd/exp_chats.go
index 8b3f69ee5c0..e401b50d34c 100644
--- a/coderd/exp_chats.go
+++ b/coderd/exp_chats.go
@@ -7505,15 +7505,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..3cf24c15dfb 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.
@@ -1124,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,
)
}
@@ -1144,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
}
@@ -1167,6 +1205,7 @@ func anthropicProviderOptionsFromChatConfig(
}
func googleProviderOptionsFromChatConfig(
+ modelID string,
options *codersdk.ChatModelGoogleProviderOptions,
) *fantasygoogle.ProviderOptions {
result := &fantasygoogle.ProviderOptions{
@@ -1178,6 +1217,18 @@ func googleProviderOptionsFromChatConfig(
ThinkingBudget: options.ThinkingConfig.ThinkingBudget,
IncludeThoughts: options.ThinkingConfig.IncludeThoughts,
}
+ // 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 {
result.SafetySettings = make(
diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go
index eccaf06d1cb..92905513c7e 100644
--- a/coderd/x/chatd/chatprovider/chatprovider_test.go
+++ b/coderd/x/chatd/chatprovider/chatprovider_test.go
@@ -405,6 +405,207 @@ 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("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("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()
+
+ 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/google_compat_thinking.go b/coderd/x/chatd/chatprovider/google_compat_thinking.go
new file mode 100644
index 00000000000..795936f4a3f
--- /dev/null
+++ b/coderd/x/chatd/chatprovider/google_compat_thinking.go
@@ -0,0 +1,178 @@
+package chatprovider
+
+import (
+ "slices"
+ "strconv"
+ "strings"
+
+ fantasygoogle "charm.land/fantasy/providers/google"
+
+ "github.com/coder/coder/v2/codersdk"
+)
+
+// rewriteGoogleCompatThinkingConfig swaps reasoning_effort for an explicit
+// Google thinking_config on requests for thinking-capable Gemini models.
+// Google's OpenAI-compatible endpoint rejects requests carrying both fields,
+// and it never emits thought text unless include_thoughts is requested
+// through thinking_config, so this is the only way to surface Gemini
+// reasoning in the chat UI on this path. Models without known thinking
+// support (pre-2.5 or unrecognized Gemini variants) keep their previous
+// request shape untouched.
+func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool {
+ modelID, _ := payload["model"].(string)
+ normalized, supported, capable := googleCompatThinkingSupport(modelID)
+ if !capable {
+ return false
+ }
+
+ thinkingConfig := map[string]any{"include_thoughts": true}
+ if effort, ok := payload["reasoning_effort"].(string); ok {
+ if len(supported) > 0 {
+ level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported)
+ thinkingConfig["thinking_level"] = strings.ToLower(level)
+ } else if budget, ok := googleCompatThinkingBudget(normalized, effort); ok {
+ thinkingConfig["thinking_budget"] = budget
+ } else {
+ // Unknown effort value: keep the request untouched rather than
+ // guessing a thinking budget.
+ return false
+ }
+ }
+
+ extraBody, _ := payload["extra_body"].(map[string]any)
+ if extraBody == nil {
+ extraBody = map[string]any{}
+ payload["extra_body"] = extraBody
+ }
+ google, _ := extraBody["google"].(map[string]any)
+ if google == nil {
+ google = map[string]any{}
+ extraBody["google"] = google
+ }
+ // Merge with a config-pinned thinking_config using the same precedence
+ // as the native Google path: a pinned thinking_budget wins over the
+ // per-turn effort, while the effort overrides a pinned thinking_level.
+ // reasoning_effort must go in every case because Google rejects it in
+ // combination with thinking_config.
+ if pinned, ok := google["thinking_config"].(map[string]any); ok {
+ if _, hasBudget := pinned["thinking_budget"]; !hasBudget {
+ if level, ok := thinkingConfig["thinking_level"]; ok {
+ pinned["thinking_level"] = level
+ } else if budget, ok := thinkingConfig["thinking_budget"]; ok {
+ pinned["thinking_budget"] = budget
+ }
+ }
+ } else {
+ google["thinking_config"] = thinkingConfig
+ }
+ delete(payload, "reasoning_effort")
+ return true
+}
+
+// googleCompatThinkingSupport reports whether a model ID on the
+// OpenAI-compatible path is a thinking-capable Gemini model, returning the
+// normalized model ID and its supported thinking levels (empty for the
+// budget-based 2.5 families).
+func googleCompatThinkingSupport(modelID string) (string, []fantasygoogle.ThinkingLevel, bool) {
+ normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/")
+ normalized = strings.TrimPrefix(normalized, "google/")
+ if !strings.HasPrefix(normalized, "gemini-") {
+ return "", nil, false
+ }
+ supported := googleSupportedThinkingLevels(normalized)
+ if len(supported) == 0 && !googleSupportsThinkingBudget(normalized) {
+ return "", nil, false
+ }
+ return normalized, supported, true
+}
+
+// googleCompatExtraBodyFromThinkingConfig translates a config-pinned Google
+// thinking configuration into the extra_body payload for Gemini models routed
+// through the OpenAI-compatible client, which ignores the native Google
+// provider options. include_thoughts defaults to enabled so pinned
+// configurations still surface thinking in the chat UI. Returns nil when the
+// model has no thinking support.
+func googleCompatExtraBodyFromThinkingConfig(
+ modelID string,
+ config *codersdk.ChatModelGoogleThinkingConfig,
+) map[string]any {
+ if config == nil {
+ return nil
+ }
+ _, supported, capable := googleCompatThinkingSupport(modelID)
+ if !capable {
+ return nil
+ }
+
+ includeThoughts := true
+ if config.IncludeThoughts != nil {
+ includeThoughts = *config.IncludeThoughts
+ }
+ thinkingConfig := map[string]any{"include_thoughts": includeThoughts}
+ if config.ThinkingBudget != nil {
+ thinkingConfig["thinking_budget"] = *config.ThinkingBudget
+ } else if pinned := GoogleThinkingLevelFromChat(config.ThinkingLevel); pinned != nil && len(supported) > 0 {
+ level := clampGoogleThinkingLevel(*pinned, supported)
+ thinkingConfig["thinking_level"] = strings.ToLower(level)
+ }
+ return map[string]any{
+ "extra_body": map[string]any{
+ "google": map[string]any{"thinking_config": thinkingConfig},
+ },
+ }
+}
+
+// googleSupportsThinkingBudget reports whether a Gemini model predating
+// thinking_level thinks via thinking_budget. Only the Gemini 2.5 Pro, Flash,
+// and Flash-Lite chat families qualify; specialized 2.5 variants such as
+// image and TTS models reject thinking_config outright ("Thinking is not
+// enabled for this model", verified live). Unrecognized name tokens fail
+// closed so new specialized variants keep their previous request shape.
+func googleSupportsThinkingBudget(normalized string) bool {
+ rest, ok := strings.CutPrefix(normalized, "gemini-")
+ if !ok {
+ return false
+ }
+ segments := strings.Split(rest, "-")
+ major, minor, hasVersion := parseGoogleModelVersion(segments[0])
+ if !hasVersion || major != 2 || minor != 5 {
+ return false
+ }
+ family := false
+ for _, segment := range segments[1:] {
+ switch segment {
+ case "pro", "flash":
+ family = true
+ case "lite", "preview", "exp", "latest":
+ default:
+ if _, err := strconv.Atoi(segment); err != nil {
+ return false
+ }
+ }
+ }
+ return family
+}
+
+// googleCompatThinkingBudget maps the global reasoning effort scale onto the
+// thinking budgets Google's OpenAI-compatible endpoint uses when translating
+// reasoning_effort for pre-Gemini-3 models, keeping effort semantics intact
+// when reasoning_effort is replaced by an explicit thinking_config. Pro
+// models cannot disable thinking (budget 0 is rejected: "This model only
+// works in thinking mode"), so "none" clamps up to the low budget for them.
+func googleCompatThinkingBudget(modelID string, effort string) (int, bool) {
+ switch effort {
+ case codersdk.ChatModelReasoningEffortNone:
+ if slices.Contains(strings.Split(strings.TrimPrefix(modelID, "gemini-"), "-"), "pro") {
+ return 1024, true
+ }
+ return 0, true
+ case codersdk.ChatModelReasoningEffortMinimal, codersdk.ChatModelReasoningEffortLow:
+ return 1024, true
+ case codersdk.ChatModelReasoningEffortMedium:
+ return 8192, true
+ case codersdk.ChatModelReasoningEffortHigh, codersdk.ChatModelReasoningEffortXHigh, codersdk.ChatModelReasoningEffortMax:
+ return 24576, true
+ default:
+ return 0, false
+ }
+}
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..9c6c034e2cf
--- /dev/null
+++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go
@@ -0,0 +1,241 @@
+package chatprovider
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/codersdk"
+)
+
+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()
+ 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("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"}
+ 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")
+ })
+}
+
+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",
+ // 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",
+ // 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()
+
+ 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")
+ })
+ }
+}
+
+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.go b/coderd/x/chatd/chatprovider/openai_compat_patches.go
index 8c60f2a16b6..e9bd5fe143e 100644
--- a/coderd/x/chatd/chatprovider/openai_compat_patches.go
+++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go
@@ -7,12 +7,13 @@ 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
-// 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,
@@ -64,7 +65,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 +95,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..07cfffcb3cd 100644
--- a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go
+++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go
@@ -8,11 +8,14 @@ import (
"testing"
"charm.land/fantasy"
+ fantasyopenai "charm.land/fantasy/providers/openai"
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/internal/googleopenai"
+ "github.com/coder/coder/v2/coderd/x/googleopenai"
+ "github.com/coder/coder/v2/codersdk"
)
func TestModelFromConfig_GeminiOpenAICompatThoughtSignatures(t *testing.T) {
@@ -49,7 +52,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 +74,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 +191,185 @@ 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
+}
+
+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"])
+}
diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go
index 52e8a016067..89f2e4e0fb9 100644
--- a/coderd/x/chatd/chatprovider/reasoningeffort.go
+++ b/coderd/x/chatd/chatprovider/reasoningeffort.go
@@ -2,11 +2,14 @@ package chatprovider
import (
"slices"
+ "strconv"
+ "strings"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasyazure "charm.land/fantasy/providers/azure"
fantasybedrock "charm.land/fantasy/providers/bedrock"
+ fantasygoogle "charm.land/fantasy/providers/google"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
@@ -122,8 +125,37 @@ func applyReasoningEffort(
providerEffort := fantasyanthropic.Effort(*effort)
providerOptions := ensureProviderOptions[fantasyanthropic.ProviderOptions](options, fantasyanthropic.Name)
providerOptions.Effort = &providerEffort
+ case fantasygoogle.Name:
+ // Only Gemini 3+ accepts thinking_level; older generations reject
+ // requests carrying it, so keep dropping the effort for them.
+ supported := googleSupportedThinkingLevels(model.ModelID())
+ if len(supported) == 0 {
+ return options
+ }
+ providerOptions := ensureProviderOptions[fantasygoogle.ProviderOptions](options, fantasygoogle.Name)
+ if providerOptions.ThinkingConfig == nil {
+ providerOptions.ThinkingConfig = &fantasygoogle.ThinkingConfig{}
+ }
+ // A configured thinking budget wins: fantasy rejects requests that
+ // set both thinking_budget and thinking_level. The resolved effort
+ // overrides a config-pinned thinking_level so the user's effort
+ // selection stays meaningful.
+ if providerOptions.ThinkingConfig.ThinkingBudget == nil {
+ level := clampGoogleThinkingLevel(googleThinkingLevel(*effort), supported)
+ providerOptions.ThinkingConfig.ThinkingLevel = &level
+ }
+ // Google returns thought summaries only when they are requested, so
+ // default them on for reasoning-effort generations; an explicitly
+ // configured include_thoughts (either value) is preserved.
+ if providerOptions.ThinkingConfig.IncludeThoughts == nil {
+ includeThoughts := true
+ providerOptions.ThinkingConfig.IncludeThoughts = &includeThoughts
+ }
case fantasyopenaicompat.Name:
providerEffort := fantasyopenai.ReasoningEffort(*effort)
+ if compatEffort, ok := googleCompatReasoningEffort(model.ModelID(), *effort); ok {
+ providerEffort = fantasyopenai.ReasoningEffort(compatEffort)
+ }
providerOptions := ensureProviderOptions[fantasyopenaicompat.ProviderOptions](options, fantasyopenaicompat.Name)
providerOptions.ReasoningEffort = &providerEffort
case fantasyopenrouter.Name:
@@ -144,6 +176,189 @@ 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{
+ fantasygoogle.ThinkingLevelMinimal,
+ fantasygoogle.ThinkingLevelLow,
+ fantasygoogle.ThinkingLevelMedium,
+ fantasygoogle.ThinkingLevelHigh,
+}
+
+// googleSupportedThinkingLevels returns the thinking_level values the Google
+// model accepts in ascending order, or nil when the model does not accept
+// thinking_level at all. Gemini introduced thinking_level in version 3, and
+// each model supports a different subset: Gemini 3 Pro launched with LOW and
+// HIGH, 3.1 Pro added MEDIUM, the Flash family accepted all four through 3.6
+// but 3.7 dropped MINIMAL, and image models accept only HIGH (plus MINIMAL
+// for flash). Versionless "-latest" aliases track the newest release of their
+// family, which has been Gemini 3+ since the aliases were introduced.
+// Only the Pro and Flash chat families qualify; specialized variants such
+// as live, TTS, and audio models reject thinking_level or generateContent
+// entirely (verified live), so unrecognized name tokens fail closed and
+// keep the previous request shape, matching googleSupportsThinkingBudget.
+func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel {
+ normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/")
+ rest, ok := strings.CutPrefix(normalized, "gemini-")
+ if !ok {
+ return nil
+ }
+ segments := strings.Split(rest, "-")
+
+ major, minor, hasVersion := parseGoogleModelVersion(segments[0])
+ isLatestAlias := !hasVersion && segments[len(segments)-1] == "latest"
+ if hasVersion && major < 3 {
+ return nil
+ }
+ if !hasVersion && !isLatestAlias {
+ return nil
+ }
+
+ family := segments
+ if hasVersion {
+ family = segments[1:]
+ }
+ isPro, isFlash, isImage := false, false, false
+ for _, segment := range family {
+ switch segment {
+ case "pro":
+ isPro = true
+ case "flash":
+ isFlash = true
+ case "image":
+ isImage = true
+ case "lite", "preview", "exp", "latest":
+ default:
+ if _, err := strconv.Atoi(segment); err != nil {
+ return nil
+ }
+ }
+ }
+ if !isPro && !isFlash {
+ return nil
+ }
+
+ switch {
+ case isImage && isFlash:
+ return []fantasygoogle.ThinkingLevel{
+ fantasygoogle.ThinkingLevelMinimal,
+ fantasygoogle.ThinkingLevelHigh,
+ }
+ case isImage:
+ return []fantasygoogle.ThinkingLevel{fantasygoogle.ThinkingLevelHigh}
+ case isFlash:
+ // Gemini 3.7 Flash rejects MINIMAL with HTTP 400 while 3 through
+ // 3.6 flash and flash-lite models accept it (live-verified
+ // 2026-08-20). Latest aliases and unknown future versions stay
+ // off MINIMAL too: gemini-flash-latest already tracks 3.7 and
+ // rejects it, and clamping up to LOW is accepted everywhere
+ // while an unsupported level fails the whole request.
+ if isLatestAlias || major > 3 || minor >= 7 {
+ return []fantasygoogle.ThinkingLevel{
+ fantasygoogle.ThinkingLevelLow,
+ fantasygoogle.ThinkingLevelMedium,
+ fantasygoogle.ThinkingLevelHigh,
+ }
+ }
+ return slices.Clone(googleThinkingLevelsAscending)
+ case hasVersion && major == 3 && minor == 0:
+ return []fantasygoogle.ThinkingLevel{
+ fantasygoogle.ThinkingLevelLow,
+ fantasygoogle.ThinkingLevelHigh,
+ }
+ default:
+ return []fantasygoogle.ThinkingLevel{
+ fantasygoogle.ThinkingLevelLow,
+ fantasygoogle.ThinkingLevelMedium,
+ fantasygoogle.ThinkingLevelHigh,
+ }
+ }
+}
+
+// parseGoogleModelVersion parses a Gemini version segment such as "3" or
+// "3.7" into major and minor components.
+func parseGoogleModelVersion(segment string) (major, minor int, ok bool) {
+ majorText, minorText, hasMinor := strings.Cut(segment, ".")
+ major, err := strconv.Atoi(majorText)
+ if err != nil {
+ return 0, 0, false
+ }
+ if hasMinor {
+ minor, err = strconv.Atoi(minorText)
+ if err != nil {
+ return 0, 0, false
+ }
+ }
+ return major, minor, true
+}
+
+// clampGoogleThinkingLevel snaps the desired level into the model's supported
+// subset: the lowest supported level at or above the desired one, so at least
+// the requested reasoning depth is preserved, else the highest supported.
+func clampGoogleThinkingLevel(
+ desired fantasygoogle.ThinkingLevel,
+ supported []fantasygoogle.ThinkingLevel,
+) fantasygoogle.ThinkingLevel {
+ desiredRank := slices.Index(googleThinkingLevelsAscending, desired)
+ for _, candidate := range supported {
+ if slices.Index(googleThinkingLevelsAscending, candidate) >= desiredRank {
+ return candidate
+ }
+ }
+ return supported[len(supported)-1]
+}
+
+// googleThinkingLevel maps the global reasoning effort scale to Google
+// thinking levels. Google offers no way to disable thinking on Gemini 3+
+// models and no levels above HIGH, so the scale clamps at both ends.
+func googleThinkingLevel(effort string) fantasygoogle.ThinkingLevel {
+ switch effort {
+ case codersdk.ChatModelReasoningEffortNone, codersdk.ChatModelReasoningEffortMinimal:
+ return fantasygoogle.ThinkingLevelMinimal
+ case codersdk.ChatModelReasoningEffortLow:
+ return fantasygoogle.ThinkingLevelLow
+ case codersdk.ChatModelReasoningEffortMedium:
+ return fantasygoogle.ThinkingLevelMedium
+ default:
+ return fantasygoogle.ThinkingLevelHigh
+ }
+}
+
func ensureProviderOptions[T any, PT interface {
*T
fantasy.ProviderOptionsData
diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go
index b7b4110a58d..004baca7a15 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"
@@ -67,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",
@@ -92,6 +94,65 @@ func TestApplyReasoningEffort(t *testing.T) {
require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort)
},
},
+ {
+ 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])
+ 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)
+ },
+ },
+ {
+ 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)
+ require.NotNil(t, providerOptions.ThinkingConfig.ThinkingLevel)
+ require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel)
+ },
+ },
+ {
+ 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: "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,
+ modelName: "gemini-2.5-flash",
+ assert: func(t *testing.T, got fantasy.ProviderOptions) {
+ require.Nil(t, got[fantasygoogle.Name])
+ },
+ },
{
name: "CreatesOpenAICompatEntry",
provider: fantasyopenaicompat.Name,
@@ -163,8 +224,177 @@ 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)
})
}
+
+ 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 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.
+ {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) {
+ t.Parallel()
+
+ minimal := fantasygoogle.ThinkingLevelMinimal
+ low := fantasygoogle.ThinkingLevelLow
+ medium := fantasygoogle.ThinkingLevelMedium
+ high := fantasygoogle.ThinkingLevelHigh
+
+ tests := []struct {
+ modelID string
+ want []fantasygoogle.ThinkingLevel
+ }{
+ {modelID: "gemini-3-flash-preview", 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}},
+ {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}},
+ // 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},
+ {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, 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.6-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))
+ })
+ }
+}
+
+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)
+ }
}
diff --git a/internal/googleopenai/thought_signature.go b/coderd/x/googleopenai/thought_signature.go
similarity index 89%
rename from internal/googleopenai/thought_signature.go
rename to coderd/x/googleopenai/thought_signature.go
index 84467bec183..5fd46460e36 100644
--- a/internal/googleopenai/thought_signature.go
+++ b/coderd/x/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/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/coderd/x/googleopenai/thoughts.go b/coderd/x/googleopenai/thoughts.go
new file mode 100644
index 00000000000..613e71a785a
--- /dev/null
+++ b/coderd/x/googleopenai/thoughts.go
@@ -0,0 +1,194 @@
+package googleopenai
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "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: errors.Join(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 }
+
+// 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")
+ // 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):]
+ 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/coderd/x/googleopenai/thoughts_test.go b/coderd/x/googleopenai/thoughts_test.go
new file mode 100644
index 00000000000..62e4e7b8957
--- /dev/null
+++ b/coderd/x/googleopenai/thoughts_test.go
@@ -0,0 +1,160 @@
+package googleopenai_test
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+
+ "github.com/coder/coder/v2/coderd/x/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.","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."}}]}`
+ 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))))
+ })
+}
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 56b0f4825bd..556fa048ab3 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;
}
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",