Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c8687dd
fix(coderd/x/chatd/chatprovider): map reasoning effort to Google thin…
ibetitsmike Aug 18, 2026
925b20e
feat: add thinking_level to Google chat model thinking config
ibetitsmike Aug 18, 2026
64c2cd5
fix(coderd/x/chatd/chatprovider): gate thinking_level to Gemini 3+ mo…
ibetitsmike Aug 18, 2026
123dbdc
fix(coderd/x/chatd/chatprovider): drop pinned thinking_level for pre-…
ibetitsmike Aug 18, 2026
d0ef34f
fix(coderd/x/chatd/chatprovider): clamp thinking_level to each Gemini…
ibetitsmike Aug 18, 2026
f896d35
fix(coderd/x/chatd/chatprovider): clamp Gemini reasoning_effort on th…
ibetitsmike Aug 19, 2026
0620042
Merge remote-tracking branch 'origin/main' into mike/chatd-google-rea…
ibetitsmike Aug 19, 2026
3b6a945
feat: surface Gemini thinking blocks on the OpenAI-compat chat path
ibetitsmike Aug 19, 2026
ba90c73
docs(coderd/x/chatd/chatprovider): note response rewriting in the com…
ibetitsmike Aug 19, 2026
79751f0
fix(coderd/x/chatd/chatprovider): gate the Gemini thinking_config rew…
ibetitsmike Aug 19, 2026
0862514
fix(coderd/x/chatd/chatprovider): restrict thinking_budget support to…
ibetitsmike Aug 19, 2026
48ded66
fix(coderd/x/chatd/chatprovider): forward pinned Google thinking conf…
ibetitsmike Aug 19, 2026
1b1833f
fix(coderd/x/chatd/chatprovider): request thought summaries on the na…
ibetitsmike Aug 19, 2026
b98252d
fix(coderd/x/chatd/chatprovider): clamp effort none to a low thinking…
ibetitsmike Aug 19, 2026
cea28de
fix(internal/googleopenai): gate the non-streaming thought rewrite on…
ibetitsmike Aug 19, 2026
831676f
fix(aibridge/intercept/chatcompletions): serialize Google blocking re…
ibetitsmike Aug 19, 2026
24a6c42
fix(coderd/x/chatd/chatprovider): drop the MINIMAL thinking level for…
ibetitsmike Aug 20, 2026
2b5e582
fix(coderd/x/chatd/chatprovider): fail closed for specialized Gemini …
ibetitsmike Aug 20, 2026
daa2a3a
refactor(coderd/x/googleopenai): move googleopenai out of the top-lev…
ibetitsmike Aug 20, 2026
b67cc8e
test(site/src/pages/AISettingsPage/ModelsPage): cover thinking level …
ibetitsmike Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion aibridge/intercept/chatcompletions/blocking.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
52 changes: 52 additions & 0 deletions aibridge/intercept/chatcompletions/blocking_internal_test.go
Original file line number Diff line number Diff line change
@@ -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":"<thought>hidden</thought>answer","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())
Comment thread
ibetitsmike marked this conversation as resolved.
require.Equal(t, int64(7), gjson.GetBytes(out, "usage.completion_tokens").Int())
})
}
14 changes: 12 additions & 2 deletions aibridge/intercept/chatcompletions/google_openai_compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Comment thread
ibetitsmike marked this conversation as resolved.
}
googleopenai.AddThoughtSignaturesToLatestTurn(payload)
patched, err := json.Marshal(payload)
if err != nil {
return nil, err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
11 changes: 11 additions & 0 deletions aibridge/intercept/chatcompletions/paramswrap.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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) {
Expand All @@ -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{
Expand Down
23 changes: 18 additions & 5 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
47 changes: 47 additions & 0 deletions coderd/exp_chats_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading