gateway: Gemini's thoughtSignature stays on the call and text it signed through translation, and goes back instead of the skip value (#1445, XYenon)

What changed: parseGemini keeps a functionCall's and a text part's
thoughtSignature. geminiParts and buildCodeAssistSent write them back; a
later call of a signed step goes without one, and only an unsigned step
or Claude on Antigravity gets skip_thought_signature_validator. The Code
Assist decoder puts a call's signature in its id (signedID, as chat does
since #687) and a text's in KTextSig; the Gemini encoder and renderer
give them to a Gemini client where Gemini put them, with a clean id.
Chat, Messages and Responses renderers leave a signature-only text part
out.

Why: a Gemini client on a Code Assist or translated path had every call's
signature replaced by the skip value and text signatures dropped.

Run: the new tests in reasoning_state_gemini_test.go (Gemini's
documented thought-signature shapes) fail on the old code and pass now; a
Chat client's call id carries the signature back to Google, and no other
API's client gets the text's. gofmt -l; go vet -tags nogui on darwin,
linux and windows; the full internal/gateway tests in a scratch HOME: ok.
Not tried with a real account.
This commit is contained in:
yetone committed 2026-10-10 13:29:45 +08:00
1 parent 13356b9da7
commit d7186e51b9
4 files changed
+234 -12

No files matched your search

+3
View File
@@ -1119,6 +1119,9 @@ func renderChat(res Result, model string) []byte {
for _, p := range res.Parts {
switch p.Kind {
case Text:
if p.Text == "" && p.Signature != "" {
continue // only Gemini's signature on its text, which is Gemini's
}
if msg["content"] == nil {
msg["content"] = p.Text
} else {
+35 -5
View File
@@ -160,11 +160,24 @@ func buildCodeAssistSent(r *Request, sent, agent string) []byte {
role = "model"
}
var parts, after []map[string]any // after: tools' images held back
// signed: a step Gemini signed, whose later calls go without, as
// Gemini signs the first (#1445, as chat's #687)
signed := false
for _, p := range m.Parts {
if p.Kind == ToolCall && p.Signature != "" {
signed = true
}
}
for _, p := range m.Parts {
switch p.Kind {
case Text:
if p.Text != "" {
parts = append(parts, map[string]any{"text": p.Text})
if p.Text != "" || (p.Signature != "" && !claude) {
part := map[string]any{"text": p.Text}
if p.Signature != "" && !claude {
// Gemini's signature on its text, back as it gave it
part["thoughtSignature"] = p.Signature
}
parts = append(parts, part)
}
case Image, File:
if p.Data != "" {
@@ -178,7 +191,17 @@ func buildCodeAssistSent(r *Request, sent, agent string) []byte {
if id := toolID(p.ID); id != "" {
call["id"] = id
}
parts = append(parts, map[string]any{"functionCall": call, "thoughtSignature": skipSignature})
part := map[string]any{"functionCall": call}
switch {
case claude:
part["thoughtSignature"] = skipSignature
case p.Signature != "":
// Gemini's own signature, back as it gave it (#1445)
part["thoughtSignature"] = p.Signature
case !signed:
part["thoughtSignature"] = skipSignature
}
parts = append(parts, part)
case ToolResult:
name := names[p.CallID]
if name == "" {
@@ -633,7 +656,9 @@ func (d *codeAssistDecoder) decode(data string, emit func(Event)) error {
args = json.RawMessage("{}")
}
d.tools = true
emit(Event{Kind: KToolStart, ID: id, Name: toolOfCall(p.FunctionCall.Name)})
// its signature rides in the id, and comes back off it
// (signedID, unsignCalls), as on chat (#687, #1445)
emit(Event{Kind: KToolStart, ID: signedID(id, p.Signature), Name: toolOfCall(p.FunctionCall.Name)})
emit(Event{Kind: KToolArgs, Text: string(args)})
case p.Thought:
d.flush(emit)
@@ -649,8 +674,13 @@ func (d *codeAssistDecoder) decode(data string, emit func(Event)) error {
// reasoning above (#620)
d.flush(emit)
emit(Event{Kind: KImage, Name: p.InlineData.MimeType, Text: p.InlineData.Data})
case p.Text != "":
case p.Text != "" || p.Signature != "":
d.text(p.Text, emit)
if p.Signature != "" {
// Gemini's signature on its text, after it (#1445)
d.flush(emit)
emit(Event{Kind: KTextSig, Text: p.Signature})
}
}
}
if g := cand.GroundingMetadata; g != nil && !d.searched {
+21 -7
View File
@@ -265,7 +265,8 @@ func parseGemini(body []byte) (*Request, error) {
id = "call_" + newID()
}
names[fc.Name] = id
msg.Parts = append(msg.Parts, Part{Kind: ToolCall, ID: id, Name: fc.Name, Args: parseArgs(string(fc.Args))})
// its thought signature goes back to Gemini as it came (#1445)
msg.Parts = append(msg.Parts, Part{Kind: ToolCall, ID: id, Name: fc.Name, Args: parseArgs(string(fc.Args)), Signature: p.Signature})
case p.FunctionResponse != nil:
fr := p.FunctionResponse
id := fr.ID
@@ -292,8 +293,8 @@ func parseGemini(body []byte) (*Request, error) {
}
case p.Thought:
msg.Parts = append(msg.Parts, Part{Kind: Thinking, Text: p.Text, Signature: p.Signature})
case p.Text != "":
msg.Parts = append(msg.Parts, Part{Kind: Text, Text: p.Text})
case p.Text != "" || p.Signature != "":
msg.Parts = append(msg.Parts, Part{Kind: Text, Text: p.Text, Signature: p.Signature})
}
}
if len(msg.Parts) > 0 {
@@ -543,8 +544,12 @@ func geminiParts(parts []Part) []map[string]any {
for _, p := range parts {
switch p.Kind {
case Text:
if p.Text != "" {
out = append(out, map[string]any{"text": p.Text})
if p.Text != "" || p.Signature != "" {
part := map[string]any{"text": p.Text}
if p.Signature != "" {
part["thoughtSignature"] = p.Signature
}
out = append(out, part)
}
case Thinking:
if p.Text != "" {
@@ -555,11 +560,16 @@ func geminiParts(parts []Part) []map[string]any {
out = append(out, map[string]any{"inlineData": map[string]any{"mimeType": p.MediaType, "data": p.Data}})
}
case ToolCall:
id := p.ID
// a signature that rode in the id goes where Gemini has it
id, sig := unsignedID(p.ID)
if id == "" {
id = "call_" + newID()
}
out = append(out, map[string]any{"functionCall": map[string]any{"id": id, "name": p.Name, "args": argsOf(p)}})
part := map[string]any{"functionCall": map[string]any{"id": id, "name": p.Name, "args": argsOf(p)}}
if sig != "" {
part["thoughtSignature"] = sig
}
out = append(out, part)
}
}
return out
@@ -638,6 +648,10 @@ func (e *geminiEncoder) event(ev Event) {
case KThink:
e.flushTool()
e.chunk(geminiParts([]Part{{Kind: Thinking, Text: ev.Text}}), "", nil)
case KTextSig:
// Gemini's signature on its text, as Gemini streams it (#1445)
e.flushTool()
e.chunk([]map[string]any{{"text": "", "thoughtSignature": ev.Text}}, "", nil)
case KImage:
e.flushTool()
e.chunk(geminiParts([]Part{{Kind: Image, MediaType: ev.Name, Data: ev.Text}}), "", nil)
@@ -0,0 +1,175 @@
package gateway
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// Gemini's thought signatures as its thought-signatures guide gives them:
// on the first function call of a step (parallel calls after it go
// unsigned), and on the last text part of a reply with no call, which a
// stream may send as an empty text part of its own (#1445, XYenon).
const (
gSigCall = "CiQB0e2Kb7Zk8n1sX3qS4Q2iQh3VJb9K1Z0yR5mT6uW7xY8zA9BCsQEKrgEB0e2Kb2w=="
gSigText = "CiUB0e2Kb3Xn8pQ4rT6vW8yA0cE2gI4kM6oQ8sU0wY2aC4eG6iK8mO0q"
)
// A Gemini request's signatures go back to Gemini as the client gave
// them: on the signed call, not on the call after it, and on the text.
// The old build put skip_thought_signature_validator on every call and
// dropped the text's.
func TestGeminiSignaturesGoBackAsTheyCame(t *testing.T) {
body := `{"model":"gemini-3-pro-preview","contents":[
{"role":"user","parts":[{"text":"Check flight AA100 and the weather in Paris."}]},
{"role":"model","parts":[
{"functionCall":{"name":"check_flight","args":{"flight":"AA100"}},"thoughtSignature":"` + gSigCall + `"},
{"functionCall":{"name":"get_weather","args":{"location":"Paris"}}}]},
{"role":"user","parts":[
{"functionResponse":{"name":"check_flight","response":{"status":"on time"}}},
{"functionResponse":{"name":"get_weather","response":{"temp":"15C"}}}]},
{"role":"model","parts":[{"text":"AA100 is on time, and it's 15C in Paris.","thoughtSignature":"` + gSigText + `"}]},
{"role":"user","parts":[{"text":"Thanks!"}]}]}`
req, err := parse(provider.Gemini, []byte(body))
if err != nil {
t.Fatal(err)
}
type gContents struct {
Contents []struct {
Parts []map[string]any `json:"parts"`
} `json:"contents"`
}
gemini, err := build(provider.Gemini, req, "gemini-3-pro-preview", "generativelanguage.googleapis.com", false)
if err != nil {
t.Fatal(err)
}
var ca struct {
Request gContents `json:"request"`
}
if err := json.Unmarshal(buildCodeAssist(req, "gemini-3-pro-preview", "gemini"), &ca); err != nil {
t.Fatal(err)
}
var g gContents
if err := json.Unmarshal(gemini, &g); err != nil {
t.Fatal(err)
}
for name, c := range map[string]gContents{"Gemini API": g, "Code Assist": ca.Request} {
if len(c.Contents) != 5 {
t.Fatalf("%s: %d contents", name, len(c.Contents))
}
calls, text := c.Contents[1].Parts, c.Contents[3].Parts
if len(calls) != 2 || calls[0]["thoughtSignature"] != gSigCall || calls[1]["thoughtSignature"] != nil {
t.Fatalf("%s: calls %v", name, calls)
}
if len(text) != 1 || text[0]["thoughtSignature"] != gSigText {
t.Fatalf("%s: text %v", name, text)
}
}
}
// geminiCallStream is a Code Assist stream answering with a signed call
// and an unsigned one after it.
var geminiCallStream = []string{
`{"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"check_flight","args":{"flight":"AA100"}},"thoughtSignature":"` + gSigCall + `"}]}}],"modelVersion":"gemini-3-pro-preview","responseId":"r1"}}`,
`{"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"location":"Paris"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":20,"totalTokenCount":30},"modelVersion":"gemini-3-pro-preview","responseId":"r1"}}`,
}
// geminiTextStream is one answering in text, its signature in an empty
// text part of its own at the end.
var geminiTextStream = []string{
`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"AA100 is on time."}]}}],"modelVersion":"gemini-3-pro-preview","responseId":"r2"}}`,
`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"","thoughtSignature":"` + gSigText + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15},"modelVersion":"gemini-3-pro-preview","responseId":"r2"}}`,
}
// geminiReplyParts is every part a Gemini client was given, streamed or
// whole.
func geminiReplyParts(msgs ...map[string]any) []map[string]any {
var out []map[string]any
for _, m := range msgs {
cands, _ := m["candidates"].([]any)
for _, c := range cands {
content, _ := c.(map[string]any)["content"].(map[string]any)
parts, _ := content["parts"].([]any)
for _, p := range parts {
out = append(out, p.(map[string]any))
}
}
}
return out
}
// A Gemini client is given the signatures where Gemini gives them: on the
// signed call, with the call's own id, and on the text. The old decoder
// dropped both.
func TestGeminiReplyKeepsSignatures(t *testing.T) {
for _, whole := range []bool{false, true} {
var calls, texts []map[string]any
var parts []map[string]any
if whole {
parts = geminiReplyParts(translateWhole(t, provider.CodeAssist, provider.Gemini, geminiCallStream...))
parts = append(parts, geminiReplyParts(translateWhole(t, provider.CodeAssist, provider.Gemini, geminiTextStream...))...)
} else {
parts = geminiReplyParts(translateStream(t, provider.CodeAssist, provider.Gemini, geminiCallStream...)...)
parts = append(parts, geminiReplyParts(translateStream(t, provider.CodeAssist, provider.Gemini, geminiTextStream...)...)...)
}
for _, p := range parts {
if p["functionCall"] != nil {
calls = append(calls, p)
} else if p["text"] != nil {
texts = append(texts, p)
}
}
if len(calls) != 2 || calls[0]["thoughtSignature"] != gSigCall || calls[1]["thoughtSignature"] != nil {
t.Fatalf("whole=%v: calls %v", whole, calls)
}
if id, _ := calls[0]["functionCall"].(map[string]any)["id"].(string); strings.Contains(id, sigMark) || strings.Contains(id, sigMarkRaw) {
t.Fatalf("whole=%v: the signature left in the call's id %q", whole, id)
}
signed := false
for _, p := range texts {
if p["thoughtSignature"] == gSigText {
signed = true
}
}
if !signed {
t.Fatalf("whole=%v: text's signature lost: %v", whole, texts)
}
}
}
// Another API's client on a Google account is given the call's signature
// in its id, as on chat (#687), and it goes back to Google on the next
// turn; the text's goes to no one else.
func TestGeminiCallSignatureRidesInTheID(t *testing.T) {
out := translateWhole(t, provider.CodeAssist, provider.Chat, geminiCallStream...)
msg := out["choices"].([]any)[0].(map[string]any)["message"].(map[string]any)
calls := msg["tool_calls"].([]any)
id := calls[0].(map[string]any)["id"].(string)
if _, sig := unsignedID(id); sig != gSigCall {
t.Fatalf("call id %q carries %q", id, sig)
}
next := `{"model":"gemini-3-pro-preview","messages":[{"role":"user","content":"go"},
{"role":"assistant","content":null,"tool_calls":[{"id":"` + id + `","type":"function","function":{"name":"check_flight","arguments":"{\"flight\":\"AA100\"}"}}]},
{"role":"tool","tool_call_id":"` + id + `","content":"on time"}]}`
req, err := parse(provider.Chat, []byte(next))
if err != nil {
t.Fatal(err)
}
sent := buildCodeAssist(req, "gemini-3-pro-preview", "gemini")
if !bytes.Contains(sent, []byte(`"thoughtSignature":"`+gSigCall+`"`)) || bytes.Contains(sent, []byte(sigMark)) {
t.Fatalf("sent %s", sent)
}
for _, from := range []provider.Protocol{provider.Anthropic, provider.Responses, provider.Chat} {
b, _ := json.Marshal(translateWhole(t, provider.CodeAssist, from, geminiTextStream...))
s, _ := json.Marshal(translateStream(t, provider.CodeAssist, from, geminiTextStream...))
if bytes.Contains(b, []byte(gSigText)) || bytes.Contains(s, []byte(gSigText)) {
t.Fatalf("%s client handed Gemini's text signature:\n%s\n%s", from, b, s)
}
if !bytes.Contains(b, []byte("AA100 is on time.")) {
t.Fatalf("%s client lost the text: %s", from, b)
}
}
}