Files
coder/coderd/x/googleopenai/thoughts.go
T
Michael Suchacz a48aedd924 fix(coderd/x): surface Gemini malformed-function-call stream deaths as retryable errors (#28470)
## Problem

When Gemini's OpenAI-compatible endpoint rejects a model-generated
function call server-side, it ends the SSE stream cleanly with the
nonstandard finish reason `function_call_filter:
MALFORMED_FUNCTION_CALL` after streaming only thought summaries; the
rejected call never reaches the wire. chatd treated this as a normal
completion: fantasy maps the unrecognized finish reason to `unknown`,
the reasoning block never closes (the only non-thought delta is the
`</thought>` marker, which the transport seam strips to an empty string,
and the openaicompat hook only ends reasoning on a non-empty content
delta), so the step accumulates no content and the generation loop
finishes the turn as complete. The user sees the model think for ~40
seconds and then nothing: no assistant message, no `last_error`, chat
status `waiting`. Observed twice in a row in production on
gemini-3.7-flash, with "Resume" reproducing it identically.

## Fix

Two independent layers:

- `coderd/x/googleopenai`: the stream rewrite now converts any chunk
whose `finish_reason` starts with `function_call_filter` into an
OpenAI-style SSE `{"error": ...}` event embedding the raw reason.
openai-go turns error-bearing events into stream errors, so the failure
rides the existing stream-error path instead of ending the stream
cleanly.
- `coderd/x/chatd/chaterror`: classifies that injected error as
retryable (kind `generic`, provider `google`) with a clear user-facing
message, so the existing generation retry machinery re-runs the step and
persists a `last_error` if retries exhaust.
- `coderd/x/chatd/chatloop`: provider-agnostic guard: a step that
produced no user-visible content and no tool calls under a finish reason
of `unknown`, `error`, `other`, or `tool-calls` (a tool-calls finish
that delivered zero calls) now returns a retryable error instead of
silently completing the turn. `stop` and `length` finishes keep their
existing semantics.

Tests cover the seam rewrite (live-capture SSE shape plus standard
finish reason passthrough), the new classification, and the chatloop
guard (error cases plus preserved stop, length, text, and tool-call
behavior). Each layer was red-green verified independently.

Remote dogfood UAT ran against this exact commit: normal reasoning and
tool-call chats on a real model complete cleanly with no spurious guard
errors and no retry loops. The Google-side failure itself is not
deterministically triggerable against live Gemini and is owned by the
unit tests.

> Xum acted on Mike's (@ibetitsmike) behalf.

<!-- xum-attribution: model=claude-opus, thinking=enabled -->
2026-08-24 17:09:00 +02:00

242 lines
7.4 KiB
Go

package googleopenai
import (
"bufio"
"bytes"
"encoding/json"
"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
}
if errPayload := functionCallFilterErrorPayload(choices); errPayload != nil {
return assembleDataLine(errPayload, suffix)
}
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
}
}
return assembleDataLine(out, suffix)
}
func assembleDataLine(payload, suffix []byte) []byte {
rewritten := make([]byte, 0, len(streamDataPrefix)+len(payload)+len(suffix))
rewritten = append(rewritten, streamDataPrefix...)
rewritten = append(rewritten, payload...)
rewritten = append(rewritten, suffix...)
return rewritten
}
// Gemini uses this nonstandard finish-reason prefix when it discards a
// malformed generated function call.
const functionCallFilterFinishReasonPrefix = "function_call_filter"
// MalformedFunctionCallMessagePrefix distinguishes adapter-generated errors
// from unrelated provider errors.
const MalformedFunctionCallMessagePrefix = "gemini dropped the model's generated function call"
type streamErrorEvent struct {
Error streamErrorDetail `json:"error"`
}
type streamErrorDetail struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
}
// functionCallFilterErrorPayload turns the nonstandard finish reason into an
// SSE error so consumers do not accept the empty stream as successful.
func functionCallFilterErrorPayload(choices gjson.Result) []byte {
for _, choice := range choices.Array() {
reason := choice.Get("finish_reason")
if reason.Type != gjson.String || !strings.HasPrefix(reason.Str, functionCallFilterFinishReasonPrefix) {
continue
}
payload, err := json.Marshal(streamErrorEvent{Error: streamErrorDetail{
Message: MalformedFunctionCallMessagePrefix + " (finish_reason " + strconv.Quote(reason.Str) + ")",
Type: "invalid_response_error",
Code: "malformed_function_call",
}})
if err != nil {
return nil
}
return payload
}
return nil
}