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:
1 parent
13356b9da7
commit
d7186e51b9
4 files changed
+234
-12
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user