mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
## Problem
Selecting a reasoning effort on a Google (Gemini) chat model either got
dropped or broke the request outright, and even when the request
succeeded, Gemini chats never showed thinking blocks:
- The native `fantasygoogle` path had no case in `applyReasoningEffort`
at all, so effort was silently ignored. With only `include_thoughts`
configured, Gemini 3+ models default to no thinking, so chats produced
zero reasoning tokens.
- The path chat generation actually uses today routes Google providers
through aibridge as openai-compat clients and forwarded the raw effort
string as `reasoning_effort`. Google's OpenAI-compatible endpoint
validates that value against the model's supported thinking levels
instead of clamping, so out-of-range efforts fail the whole request with
HTTP 400. Verified against the live Gemini API: `gemini-3.1-pro-preview`
accepts low/medium/high and rejects none/minimal/xhigh/max. Because the
config's default effort is sent whenever the user selects nothing, a
config whose default falls outside the model's supported set failed
every no-selection message.
- Google's OpenAI-compatible endpoint never emits thought text for
`reasoning_effort` requests. Thoughts require
`extra_body.google.thinking_config` with `include_thoughts`, which is
mutually exclusive with `reasoning_effort`, and even then the thought
deltas come back inline in `content` (marked
`extra_content.google.thought`, wrapped in `<thought>` markers) rather
than in the `reasoning_content` field OpenAI-compatible clients parse.
So no reasoning parts were ever produced, persisted, or rendered for
Gemini chats (verified live in UAT).
## Fix
Reasoning effort:
- Map the global reasoning effort scale onto Google's `thinking_level`
in `applyReasoningEffort`, gated to Gemini 3+ models and clamped to each
model's supported subset (3.0 Pro launched with LOW/HIGH only, 3.1 Pro
added MEDIUM, Flash accepts all four through 3.6 while 3.7 and the
latest flash aliases drop MINIMAL, image models accept HIGH plus MINIMAL
for flash). Pre-Gemini-3 models reject `thinking_level`, so effort
degrades to a no-op for them and a config-pinned level is dropped at
call time instead of failing every generation.
- Add `thinking_level` to `ChatModelGoogleThinkingConfig` so admins can
pin a level for configs without user-selectable effort. A resolved
per-turn effort overrides the pinned level; an explicit
`thinking_budget` wins over both since the Google API rejects requests
that set `thinking_budget` and `thinking_level` together, and config
validation now rejects that combination up front. The admin panel picks
up the new field automatically through the generated model-options
schema.
Thinking blocks on the openai-compat path (the path chat generation
uses), translated in both directions at the existing Gemini transport
seam:
- chatd's OpenAI-compat request patch swaps `reasoning_effort` for an
equivalent `extra_body.google.thinking_config` with `include_thoughts`
enabled: `thinking_level` on Gemini 3+ (clamped to the model's supported
subset), `thinking_budget` on earlier models (effort `none` maps to
budget 0, which disables thinking). Non-Gemini models and non-Google
routes are untouched.
- The same transport rewrites Google's responses so thought output
surfaces as `reasoning_content`, which fantasy already converts into
reasoning parts: streaming bodies are rewritten per SSE line (preserving
streaming latency), JSON bodies in place, with the
`<thought>`/`</thought>` markers stripped at the transitions.
- aibridge's chat-completions interception preserves the `extra_body`
passthrough that openai-go's typed params silently drop, forwarding it
to Google upstreams only, so the chatd -> aibridge -> Google route
carries the thinking config end to end.
## Validation
- `go test ./coderd/x/chatd/... ./aibridge/... ./internal/googleopenai
./codersdk` pass; golangci-lint clean on touched packages.
- New unit tests cover the effort-to-level mapping for every effort
value, per-model clamping on both paths, the effort-to-thinking_config
request swap (level, budget, pinned-config precedence, non-Gemini
passthrough), SSE and JSON thought-response rewriting (fixtures mirror
live captures, including tool-call and no-thought streams), an
end-to-end fake-SSE test through the fantasy pipeline proving reasoning
deltas emerge with markers stripped, and aibridge `extra_body`
preservation (including proof the typed round trip drops it).
- Verified live against `generativelanguage.googleapis.com` on
`gemini-3-flash-preview` and `gemini-2.5-flash` through the real
chatprovider stack: reasoning deltas stream through with markers
stripped and answer text intact.
- Live UAT on a dogfood deployment (remote Coder Agents chat, 4 rounds):
effort clamping verified end to end (rounds 1-2), the missing-thoughts
limitation isolated (round 3), and the fix validated (round 4):
expandable, readable thinking blocks render for
`gemini-3.1-pro-preview`, `gemini-3-flash-preview`, and
`gemini-2.5-flash` at effort=high; no `<thought>` marker leaks;
effort=none produces no block and no 400; multi-turn history replay
works with a thinking block on each turn.
- `make gen` regenerated `typesGenerated.ts` and
`chatModelOptionsGenerated.json`; `pnpm -C site lint:types` passes.
> 🤖 Mux acted on Mike's behalf to create this pull request.
<!-- mux-attribution: model=claude-opus-4-6 thinking=high -->
195 lines
5.8 KiB
Go
195 lines
5.8 KiB
Go
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 <thought>...</thought>. OpenAI-compatible clients expect
|
|
// reasoning in the reasoning_content field instead, so these helpers
|
|
// rewrite responses at the transport boundary.
|
|
const (
|
|
thoughtOpenMarker = "<thought>"
|
|
thoughtCloseMarker = "</thought>"
|
|
)
|
|
|
|
// 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 <thought>-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 <thought> and </thought> 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
|
|
}
|