Merge pull request #84 from ekoeppen/gemini-thought-sig
Add thought signatures for Gemini tool calls
5 files · +434 -58
M packages/core/core_test.go +58 -0
| @@ -46,6 +46,64 @@ func TestSessionRoundTrip(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) { | |
| 50 | + | dir := t.TempDir() | |
| 51 | + | sess, err := NewSession(dir, "/tmp/project", "google", "gemini-3-flash", "test") | |
| 52 | + | if err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | msg := provider.Message{ | |
| 56 | + | Role: provider.RoleAssistant, | |
| 57 | + | Content: []provider.Content{ | |
| 58 | + | provider.ReasoningBlock{Summary: "thinking"}, | |
| 59 | + | provider.TextBlock{Text: "answer", ThoughtSignature: "text-sig"}, | |
| 60 | + | provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"}, | |
| 61 | + | provider.ToolCallBlock{ | |
| 62 | + | ID: "call-1", | |
| 63 | + | Name: "read", | |
| 64 | + | Arguments: []byte(`{"path":"a"}`), | |
| 65 | + | ThoughtSignature: "tool-sig", | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | } | |
| 69 | + | if err := sess.AppendMessage(msg); err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | if err := sess.AppendMessage(provider.Message{ | |
| 73 | + | Role: provider.RoleTool, | |
| 74 | + | Content: []provider.Content{provider.ToolResultBlock{ | |
| 75 | + | CallID: "call-1", | |
| 76 | + | Content: []provider.Content{provider.TextBlock{Text: "result"}}, | |
| 77 | + | }}, | |
| 78 | + | }); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | if err := sess.Close(); err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | reopened, msgs, err := OpenSession(sess.Path) | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | t.Cleanup(func() { _ = reopened.Close() }) | |
| 90 | + | if len(msgs) != 2 || len(msgs[0].Content) != 4 { | |
| 91 | + | t.Fatalf("round trip content = %+v", msgs) | |
| 92 | + | } | |
| 93 | + | if rb, ok := msgs[0].Content[0].(provider.ReasoningBlock); !ok || rb.Summary != "thinking" { | |
| 94 | + | t.Fatalf("reasoning block = %#v", msgs[0].Content[0]) | |
| 95 | + | } | |
| 96 | + | if tb, ok := msgs[0].Content[1].(provider.TextBlock); !ok || tb.ThoughtSignature != "text-sig" { | |
| 97 | + | t.Fatalf("text block = %#v", msgs[0].Content[1]) | |
| 98 | + | } | |
| 99 | + | if ib, ok := msgs[0].Content[2].(provider.ImageBlock); !ok || ib.ThoughtSignature != "image-sig" { | |
| 100 | + | t.Fatalf("image block = %#v", msgs[0].Content[2]) | |
| 101 | + | } | |
| 102 | + | if tc, ok := msgs[0].Content[3].(provider.ToolCallBlock); !ok || tc.ThoughtSignature != "tool-sig" { | |
| 103 | + | t.Fatalf("tool call block = %#v", msgs[0].Content[3]) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 49 | 107 | // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's | |
| 50 | 108 | // session-persistence fallback works: NewSessionAtPath creates the | |
| 51 | 109 | // file (and parent dirs) at the exact path the caller chose, | |
| @@ -46,6 +46,64 @@ func TestSessionRoundTrip(t *testing.T) { | |||||
| 46 | } | 46 | } | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) { | |||
| 50 | + | dir := t.TempDir() | |||
| 51 | + | sess, err := NewSession(dir, "/tmp/project", "google", "gemini-3-flash", "test") | |||
| 52 | + | if err != nil { | |||
| 53 | + | t.Fatal(err) | |||
| 54 | + | } | |||
| 55 | + | msg := provider.Message{ | |||
| 56 | + | Role: provider.RoleAssistant, | |||
| 57 | + | Content: []provider.Content{ | |||
| 58 | + | provider.ReasoningBlock{Summary: "thinking"}, | |||
| 59 | + | provider.TextBlock{Text: "answer", ThoughtSignature: "text-sig"}, | |||
| 60 | + | provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"}, | |||
| 61 | + | provider.ToolCallBlock{ | |||
| 62 | + | ID: "call-1", | |||
| 63 | + | Name: "read", | |||
| 64 | + | Arguments: []byte(`{"path":"a"}`), | |||
| 65 | + | ThoughtSignature: "tool-sig", | |||
| 66 | + | }, | |||
| 67 | + | }, | |||
| 68 | + | } | |||
| 69 | + | if err := sess.AppendMessage(msg); err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | if err := sess.AppendMessage(provider.Message{ | |||
| 73 | + | Role: provider.RoleTool, | |||
| 74 | + | Content: []provider.Content{provider.ToolResultBlock{ | |||
| 75 | + | CallID: "call-1", | |||
| 76 | + | Content: []provider.Content{provider.TextBlock{Text: "result"}}, | |||
| 77 | + | }}, | |||
| 78 | + | }); err != nil { | |||
| 79 | + | t.Fatal(err) | |||
| 80 | + | } | |||
| 81 | + | if err := sess.Close(); err != nil { | |||
| 82 | + | t.Fatal(err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | reopened, msgs, err := OpenSession(sess.Path) | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | t.Cleanup(func() { _ = reopened.Close() }) | |||
| 90 | + | if len(msgs) != 2 || len(msgs[0].Content) != 4 { | |||
| 91 | + | t.Fatalf("round trip content = %+v", msgs) | |||
| 92 | + | } | |||
| 93 | + | if rb, ok := msgs[0].Content[0].(provider.ReasoningBlock); !ok || rb.Summary != "thinking" { | |||
| 94 | + | t.Fatalf("reasoning block = %#v", msgs[0].Content[0]) | |||
| 95 | + | } | |||
| 96 | + | if tb, ok := msgs[0].Content[1].(provider.TextBlock); !ok || tb.ThoughtSignature != "text-sig" { | |||
| 97 | + | t.Fatalf("text block = %#v", msgs[0].Content[1]) | |||
| 98 | + | } | |||
| 99 | + | if ib, ok := msgs[0].Content[2].(provider.ImageBlock); !ok || ib.ThoughtSignature != "image-sig" { | |||
| 100 | + | t.Fatalf("image block = %#v", msgs[0].Content[2]) | |||
| 101 | + | } | |||
| 102 | + | if tc, ok := msgs[0].Content[3].(provider.ToolCallBlock); !ok || tc.ThoughtSignature != "tool-sig" { | |||
| 103 | + | t.Fatalf("tool call block = %#v", msgs[0].Content[3]) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 49 | // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's | 107 | // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's | ||
| 50 | // session-persistence fallback works: NewSessionAtPath creates the | 108 | // session-persistence fallback works: NewSessionAtPath creates the | ||
| 51 | // file (and parent dirs) at the exact path the caller chose, | 109 | // file (and parent dirs) at the exact path the caller chose, | ||
M packages/core/session.go +30 -16
| @@ -820,15 +820,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 820 | 820 | msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta} | |
| 821 | 821 | for _, raw := range row.Content { | |
| 822 | 822 | var head struct { | |
| 823 | - | Text string `json:"text"` | |
| 824 | - | MimeType string `json:"mime_type"` | |
| 825 | - | Data []byte `json:"data"` | |
| 826 | - | ID string `json:"id"` | |
| 827 | - | Name string `json:"name"` | |
| 828 | - | CallID string `json:"call_id"` | |
| 829 | - | ReasoningID string `json:"reasoning_id"` | |
| 830 | - | Summary string `json:"summary"` | |
| 831 | - | Encrypted string `json:"encrypted_content"` | |
| 823 | + | Text string `json:"text"` | |
| 824 | + | MimeType string `json:"mime_type"` | |
| 825 | + | Data []byte `json:"data"` | |
| 826 | + | ID string `json:"id"` | |
| 827 | + | Name string `json:"name"` | |
| 828 | + | CallID string `json:"call_id"` | |
| 829 | + | ReasoningID string `json:"reasoning_id"` | |
| 830 | + | Summary string `json:"summary"` | |
| 831 | + | Encrypted string `json:"encrypted_content"` | |
| 832 | + | ThoughtSignature string `json:"thought_signature"` | |
| 832 | 833 | // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError | |
| 833 | 834 | } | |
| 834 | 835 | if err := json.Unmarshal(raw, &head); err != nil { | |
| @@ -836,7 +837,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 836 | 837 | } | |
| 837 | 838 | // Discriminate by presence of fields. | |
| 838 | 839 | switch { | |
| 839 | - | case head.ReasoningID != "" || head.Encrypted != "": | |
| 840 | + | case head.ReasoningID != "" || head.Summary != "" || head.Encrypted != "": | |
| 840 | 841 | msg.Content = append(msg.Content, provider.ReasoningBlock{ | |
| 841 | 842 | ID: head.ReasoningID, | |
| 842 | 843 | Summary: head.Summary, | |
| @@ -844,12 +845,18 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 844 | 845 | }) | |
| 845 | 846 | case head.Name != "" && head.ID != "": | |
| 846 | 847 | var tc struct { | |
| 847 | - | ID string `json:"id"` | |
| 848 | - | Name string `json:"name"` | |
| 849 | - | Arguments json.RawMessage `json:"arguments"` | |
| 848 | + | ID string `json:"id"` | |
| 849 | + | Name string `json:"name"` | |
| 850 | + | Arguments json.RawMessage `json:"arguments"` | |
| 851 | + | ThoughtSignature string `json:"thought_signature"` | |
| 850 | 852 | } | |
| 851 | 853 | _ = json.Unmarshal(raw, &tc) | |
| 852 | - | msg.Content = append(msg.Content, provider.ToolCallBlock{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}) | |
| 854 | + | msg.Content = append(msg.Content, provider.ToolCallBlock{ | |
| 855 | + | ID: tc.ID, | |
| 856 | + | Name: tc.Name, | |
| 857 | + | Arguments: tc.Arguments, | |
| 858 | + | ThoughtSignature: tc.ThoughtSignature, | |
| 859 | + | }) | |
| 853 | 860 | case head.CallID != "": | |
| 854 | 861 | var tr struct { | |
| 855 | 862 | CallID string `json:"call_id"` | |
| @@ -873,9 +880,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 873 | 880 | } | |
| 874 | 881 | msg.Content = append(msg.Content, block) | |
| 875 | 882 | case head.MimeType != "": | |
| 876 | - | msg.Content = append(msg.Content, provider.ImageBlock{MimeType: head.MimeType, Data: head.Data}) | |
| 883 | + | msg.Content = append(msg.Content, provider.ImageBlock{ | |
| 884 | + | MimeType: head.MimeType, | |
| 885 | + | Data: head.Data, | |
| 886 | + | ThoughtSignature: head.ThoughtSignature, | |
| 887 | + | }) | |
| 877 | 888 | default: | |
| 878 | - | msg.Content = append(msg.Content, provider.TextBlock{Text: head.Text}) | |
| 889 | + | msg.Content = append(msg.Content, provider.TextBlock{ | |
| 890 | + | Text: head.Text, | |
| 891 | + | ThoughtSignature: head.ThoughtSignature, | |
| 892 | + | }) | |
| 879 | 893 | } | |
| 880 | 894 | } | |
| 881 | 895 | return msg, nil | |
| @@ -820,15 +820,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||||
| 820 | msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta} | 820 | msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta} | ||
| 821 | for _, raw := range row.Content { | 821 | for _, raw := range row.Content { | ||
| 822 | var head struct { | 822 | var head struct { | ||
| 823 | - | Text string `json:"text"` | 823 | + | Text string `json:"text"` |
| 824 | - | MimeType string `json:"mime_type"` | 824 | + | MimeType string `json:"mime_type"` |
| 825 | - | Data []byte `json:"data"` | 825 | + | Data []byte `json:"data"` |
| 826 | - | ID string `json:"id"` | 826 | + | ID string `json:"id"` |
| 827 | - | Name string `json:"name"` | 827 | + | Name string `json:"name"` |
| 828 | - | CallID string `json:"call_id"` | 828 | + | CallID string `json:"call_id"` |
| 829 | - | ReasoningID string `json:"reasoning_id"` | 829 | + | ReasoningID string `json:"reasoning_id"` |
| 830 | - | Summary string `json:"summary"` | 830 | + | Summary string `json:"summary"` |
| 831 | - | Encrypted string `json:"encrypted_content"` | 831 | + | Encrypted string `json:"encrypted_content"` |
| 832 | + | ThoughtSignature string `json:"thought_signature"` | |||
| 832 | // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError | 833 | // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError | ||
| 833 | } | 834 | } | ||
| 834 | if err := json.Unmarshal(raw, &head); err != nil { | 835 | if err := json.Unmarshal(raw, &head); err != nil { | ||
| @@ -836,7 +837,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||||
| 836 | } | 837 | } | ||
| 837 | // Discriminate by presence of fields. | 838 | // Discriminate by presence of fields. | ||
| 838 | switch { | 839 | switch { | ||
| 839 | - | case head.ReasoningID != "" || head.Encrypted != "": | 840 | + | case head.ReasoningID != "" || head.Summary != "" || head.Encrypted != "": |
| 840 | msg.Content = append(msg.Content, provider.ReasoningBlock{ | 841 | msg.Content = append(msg.Content, provider.ReasoningBlock{ | ||
| 841 | ID: head.ReasoningID, | 842 | ID: head.ReasoningID, | ||
| 842 | Summary: head.Summary, | 843 | Summary: head.Summary, | ||
| @@ -844,12 +845,18 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||||
| 844 | }) | 845 | }) | ||
| 845 | case head.Name != "" && head.ID != "": | 846 | case head.Name != "" && head.ID != "": | ||
| 846 | var tc struct { | 847 | var tc struct { | ||
| 847 | - | ID string `json:"id"` | 848 | + | ID string `json:"id"` |
| 848 | - | Name string `json:"name"` | 849 | + | Name string `json:"name"` |
| 849 | - | Arguments json.RawMessage `json:"arguments"` | 850 | + | Arguments json.RawMessage `json:"arguments"` |
| 851 | + | ThoughtSignature string `json:"thought_signature"` | |||
| 850 | } | 852 | } | ||
| 851 | _ = json.Unmarshal(raw, &tc) | 853 | _ = json.Unmarshal(raw, &tc) | ||
| 852 | - | msg.Content = append(msg.Content, provider.ToolCallBlock{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}) | 854 | + | msg.Content = append(msg.Content, provider.ToolCallBlock{ |
| 855 | + | ID: tc.ID, | |||
| 856 | + | Name: tc.Name, | |||
| 857 | + | Arguments: tc.Arguments, | |||
| 858 | + | ThoughtSignature: tc.ThoughtSignature, | |||
| 859 | + | }) | |||
| 853 | case head.CallID != "": | 860 | case head.CallID != "": | ||
| 854 | var tr struct { | 861 | var tr struct { | ||
| 855 | CallID string `json:"call_id"` | 862 | CallID string `json:"call_id"` | ||
| @@ -873,9 +880,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||||
| 873 | } | 880 | } | ||
| 874 | msg.Content = append(msg.Content, block) | 881 | msg.Content = append(msg.Content, block) | ||
| 875 | case head.MimeType != "": | 882 | case head.MimeType != "": | ||
| 876 | - | msg.Content = append(msg.Content, provider.ImageBlock{MimeType: head.MimeType, Data: head.Data}) | 883 | + | msg.Content = append(msg.Content, provider.ImageBlock{ |
| 884 | + | MimeType: head.MimeType, | |||
| 885 | + | Data: head.Data, | |||
| 886 | + | ThoughtSignature: head.ThoughtSignature, | |||
| 887 | + | }) | |||
| 877 | default: | 888 | default: | ||
| 878 | - | msg.Content = append(msg.Content, provider.TextBlock{Text: head.Text}) | 889 | + | msg.Content = append(msg.Content, provider.TextBlock{ |
| 890 | + | Text: head.Text, | |||
| 891 | + | ThoughtSignature: head.ThoughtSignature, | |||
| 892 | + | }) | |||
| 879 | } | 893 | } | ||
| 880 | } | 894 | } | ||
| 881 | return msg, nil | 895 | return msg, nil | ||
M packages/provider/gemini.go +82 -36
| @@ -80,9 +80,10 @@ type gemPart struct { | |||
| 80 | 80 | InlineData *gemInlineData `json:"inlineData,omitempty"` | |
| 81 | 81 | FunctionCall *gemFunctionCall `json:"functionCall,omitempty"` | |
| 82 | 82 | FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"` | |
| 83 | - | // Thought: true marks a thought-summary part. Outgoing parts | |
| 84 | - | // from zot never set this; incoming chunks might. | |
| 85 | - | Thought bool `json:"thought,omitempty"` | |
| 83 | + | // Thought marks a thought-summary part. ThoughtSignature is opaque | |
| 84 | + | // provider state that must be replayed on the same part. | |
| 85 | + | Thought bool `json:"thought,omitempty"` | |
| 86 | + | ThoughtSignature string `json:"thoughtSignature,omitempty"` | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | type gemContent struct { | |
| @@ -288,11 +289,27 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart | |||
| 288 | 289 | var parts []gemPart | |
| 289 | 290 | for _, b := range blocks { | |
| 290 | 291 | switch v := b.(type) { | |
| 292 | + | case ReasoningBlock: | |
| 293 | + | if v.Encrypted != "" || v.Summary != "" { | |
| 294 | + | parts = append(parts, gemPart{ | |
| 295 | + | Text: v.Summary, | |
| 296 | + | Thought: true, | |
| 297 | + | ThoughtSignature: v.Encrypted, | |
| 298 | + | }) | |
| 299 | + | } | |
| 291 | 300 | case TextBlock: | |
| 292 | 301 | if strings.TrimSpace(v.Text) == "" { | |
| 293 | 302 | continue | |
| 294 | 303 | } | |
| 295 | - | parts = append(parts, gemPart{Text: v.Text}) | |
| 304 | + | parts = append(parts, gemPart{Text: v.Text, ThoughtSignature: v.ThoughtSignature}) | |
| 305 | + | case ImageBlock: | |
| 306 | + | parts = append(parts, gemPart{ | |
| 307 | + | InlineData: &gemInlineData{ | |
| 308 | + | MimeType: v.MimeType, | |
| 309 | + | Data: base64.StdEncoding.EncodeToString(v.Data), | |
| 310 | + | }, | |
| 311 | + | ThoughtSignature: v.ThoughtSignature, | |
| 312 | + | }) | |
| 296 | 313 | case ToolCallBlock: | |
| 297 | 314 | if !functionsEnabled { | |
| 298 | 315 | continue | |
| @@ -306,6 +323,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart | |||
| 306 | 323 | Name: v.Name, | |
| 307 | 324 | Args: args, | |
| 308 | 325 | }, | |
| 326 | + | ThoughtSignature: v.ThoughtSignature, | |
| 309 | 327 | }) | |
| 310 | 328 | } | |
| 311 | 329 | } | |
| @@ -508,41 +526,54 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 508 | 526 | // candidate carries a list of parts (text or functionCall), | |
| 509 | 527 | // possibly accumulating across chunks. | |
| 510 | 528 | type blockEntry struct { | |
| 511 | - | kind string // "text" | "image" | "tool_use" | |
| 512 | - | textBuf strings.Builder | |
| 513 | - | image *ImageBlock | |
| 514 | - | imagePath string | |
| 515 | - | toolID string | |
| 516 | - | toolName string | |
| 517 | - | toolArgs strings.Builder | |
| 529 | + | kind string // "text" | "image" | "tool_use" | "reasoning" | |
| 530 | + | textBuf strings.Builder | |
| 531 | + | thoughtSignature string | |
| 532 | + | image *ImageBlock | |
| 533 | + | imagePath string | |
| 534 | + | toolID string | |
| 535 | + | toolName string | |
| 536 | + | toolArgs strings.Builder | |
| 518 | 537 | } | |
| 519 | 538 | var ( | |
| 520 | - | blocks []*blockEntry | |
| 521 | - | currentText *blockEntry | |
| 522 | - | usage Usage | |
| 523 | - | stop StopReason = StopEnd | |
| 524 | - | finalErr error | |
| 525 | - | toolCounter int | |
| 539 | + | blocks []*blockEntry | |
| 540 | + | currentText *blockEntry | |
| 541 | + | currentReasoning *blockEntry | |
| 542 | + | usage Usage | |
| 543 | + | stop StopReason = StopEnd | |
| 544 | + | finalErr error | |
| 545 | + | toolCounter int | |
| 526 | 546 | ) | |
| 527 | 547 | ||
| 528 | - | appendText := func(delta string) { | |
| 529 | - | if currentText == nil { | |
| 530 | - | currentText = &blockEntry{kind: "text"} | |
| 531 | - | blocks = append(blocks, currentText) | |
| 548 | + | appendPartText := func(kind, delta, signature string) { | |
| 549 | + | current := ¤tText | |
| 550 | + | if kind == "reasoning" { | |
| 551 | + | current = ¤tReasoning | |
| 552 | + | } | |
| 553 | + | if *current == nil { | |
| 554 | + | *current = &blockEntry{kind: kind} | |
| 555 | + | blocks = append(blocks, *current) | |
| 556 | + | } | |
| 557 | + | (*current).textBuf.WriteString(delta) | |
| 558 | + | if signature != "" { | |
| 559 | + | (*current).thoughtSignature = signature | |
| 560 | + | // A signature terminates this wire part. A later delta of the | |
| 561 | + | // same kind belongs to a new part and must not be merged into it. | |
| 562 | + | *current = nil | |
| 532 | 563 | } | |
| 533 | - | currentText.textBuf.WriteString(delta) | |
| 534 | 564 | } | |
| 535 | 565 | ||
| 536 | - | appendImage := func(mimeType, dataB64 string) { | |
| 566 | + | appendImage := func(mimeType, dataB64, signature string) { | |
| 537 | 567 | data, err := base64.StdEncoding.DecodeString(dataB64) | |
| 538 | 568 | if err != nil || len(data) == 0 { | |
| 539 | 569 | return | |
| 540 | 570 | } | |
| 541 | - | img := ImageBlock{MimeType: mimeType, Data: data} | |
| 571 | + | img := ImageBlock{MimeType: mimeType, Data: data, ThoughtSignature: signature} | |
| 542 | 572 | path, _ := saveGeminiImageToWorkingDir(mimeType, data) | |
| 543 | 573 | blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path}) | |
| 544 | - | // Image blocks break the current text run. | |
| 574 | + | // Non-text parts break both streamed text runs. | |
| 545 | 575 | currentText = nil | |
| 576 | + | currentReasoning = nil | |
| 546 | 577 | } | |
| 547 | 578 | ||
| 548 | 579 | startTool := func(name string, providedID string, args json.RawMessage) *blockEntry { | |
| @@ -560,8 +591,9 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 560 | 591 | t.toolArgs.Write(args) | |
| 561 | 592 | } | |
| 562 | 593 | blocks = append(blocks, t) | |
| 563 | - | // New tool block breaks the current text run. | |
| 594 | + | // New tool blocks break both streamed text runs. | |
| 564 | 595 | currentText = nil | |
| 596 | + | currentReasoning = nil | |
| 565 | 597 | return t | |
| 566 | 598 | } | |
| 567 | 599 | ||
| @@ -571,7 +603,17 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 571 | 603 | switch b.kind { | |
| 572 | 604 | case "text": | |
| 573 | 605 | if b.textBuf.Len() > 0 { | |
| 574 | - | content = append(content, TextBlock{Text: b.textBuf.String()}) | |
| 606 | + | content = append(content, TextBlock{ | |
| 607 | + | Text: b.textBuf.String(), | |
| 608 | + | ThoughtSignature: b.thoughtSignature, | |
| 609 | + | }) | |
| 610 | + | } | |
| 611 | + | case "reasoning": | |
| 612 | + | if b.textBuf.Len() > 0 || b.thoughtSignature != "" { | |
| 613 | + | content = append(content, ReasoningBlock{ | |
| 614 | + | Summary: b.textBuf.String(), | |
| 615 | + | Encrypted: b.thoughtSignature, | |
| 616 | + | }) | |
| 575 | 617 | } | |
| 576 | 618 | case "image": | |
| 577 | 619 | if b.image != nil && len(b.image.Data) > 0 { | |
| @@ -586,7 +628,10 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 586 | 628 | args = "{}" | |
| 587 | 629 | } | |
| 588 | 630 | content = append(content, ToolCallBlock{ | |
| 589 | - | ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), | |
| 631 | + | ID: b.toolID, | |
| 632 | + | Name: b.toolName, | |
| 633 | + | Arguments: json.RawMessage(args), | |
| 634 | + | ThoughtSignature: b.thoughtSignature, | |
| 590 | 635 | }) | |
| 591 | 636 | } | |
| 592 | 637 | } | |
| @@ -622,6 +667,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 622 | 667 | Text string `json:"text"` | |
| 623 | 668 | InlineData *gemInlineData `json:"inlineData"` | |
| 624 | 669 | Thought bool `json:"thought"` | |
| 670 | + | ThoughtSignature string `json:"thoughtSignature"` | |
| 625 | 671 | FunctionCall *gemFunctionCall `json:"functionCall"` | |
| 626 | 672 | FunctionCallID string `json:"id"` | |
| 627 | 673 | FunctionCallName string `json:"name"` | |
| @@ -654,7 +700,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 654 | 700 | for _, cand := range chunk.Candidates { | |
| 655 | 701 | for _, part := range cand.Content.Parts { | |
| 656 | 702 | if part.InlineData != nil { | |
| 657 | - | appendImage(part.InlineData.MimeType, part.InlineData.Data) | |
| 703 | + | appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature) | |
| 658 | 704 | continue | |
| 659 | 705 | } | |
| 660 | 706 | if part.FunctionCall != nil { | |
| @@ -665,22 +711,22 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 665 | 711 | args = json.RawMessage("{}") | |
| 666 | 712 | } | |
| 667 | 713 | t := startTool(part.FunctionCall.Name, part.FunctionCallID, args) | |
| 714 | + | t.thoughtSignature = part.ThoughtSignature | |
| 668 | 715 | out <- EventToolStart{ID: t.toolID, Name: t.toolName} | |
| 669 | 716 | out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()} | |
| 670 | 717 | out <- EventToolEnd{ID: t.toolID} | |
| 671 | 718 | continue | |
| 672 | 719 | } | |
| 673 | - | if part.Text == "" { | |
| 720 | + | if part.Thought { | |
| 721 | + | appendPartText("reasoning", part.Text, part.ThoughtSignature) | |
| 722 | + | currentText = nil | |
| 674 | 723 | continue | |
| 675 | 724 | } | |
| 676 | - | if part.Thought { | |
| 677 | - | // Thinking summaries arrive as text parts | |
| 678 | - | // with thought=true. Not surfaced to the | |
| 679 | - | // user in v1; could be exposed via a | |
| 680 | - | // future ReasoningBlock if useful. | |
| 725 | + | if part.Text == "" { | |
| 681 | 726 | continue | |
| 682 | 727 | } | |
| 683 | - | appendText(part.Text) | |
| 728 | + | appendPartText("text", part.Text, part.ThoughtSignature) | |
| 729 | + | currentReasoning = nil | |
| 684 | 730 | out <- EventTextDelta{Delta: part.Text} | |
| 685 | 731 | } | |
| 686 | 732 | switch cand.FinishReason { | |
| @@ -80,9 +80,10 @@ type gemPart struct { | |||||
| 80 | InlineData *gemInlineData `json:"inlineData,omitempty"` | 80 | InlineData *gemInlineData `json:"inlineData,omitempty"` | ||
| 81 | FunctionCall *gemFunctionCall `json:"functionCall,omitempty"` | 81 | FunctionCall *gemFunctionCall `json:"functionCall,omitempty"` | ||
| 82 | FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"` | 82 | FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"` | ||
| 83 | - | // Thought: true marks a thought-summary part. Outgoing parts | 83 | + | // Thought marks a thought-summary part. ThoughtSignature is opaque |
| 84 | - | // from zot never set this; incoming chunks might. | 84 | + | // provider state that must be replayed on the same part. |
| 85 | - | Thought bool `json:"thought,omitempty"` | 85 | + | Thought bool `json:"thought,omitempty"` |
| 86 | + | ThoughtSignature string `json:"thoughtSignature,omitempty"` | |||
| 86 | } | 87 | } | ||
| 87 | 88 | ||||
| 88 | type gemContent struct { | 89 | type gemContent struct { | ||
| @@ -288,11 +289,27 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart | |||||
| 288 | var parts []gemPart | 289 | var parts []gemPart | ||
| 289 | for _, b := range blocks { | 290 | for _, b := range blocks { | ||
| 290 | switch v := b.(type) { | 291 | switch v := b.(type) { | ||
| 292 | + | case ReasoningBlock: | |||
| 293 | + | if v.Encrypted != "" || v.Summary != "" { | |||
| 294 | + | parts = append(parts, gemPart{ | |||
| 295 | + | Text: v.Summary, | |||
| 296 | + | Thought: true, | |||
| 297 | + | ThoughtSignature: v.Encrypted, | |||
| 298 | + | }) | |||
| 299 | + | } | |||
| 291 | case TextBlock: | 300 | case TextBlock: | ||
| 292 | if strings.TrimSpace(v.Text) == "" { | 301 | if strings.TrimSpace(v.Text) == "" { | ||
| 293 | continue | 302 | continue | ||
| 294 | } | 303 | } | ||
| 295 | - | parts = append(parts, gemPart{Text: v.Text}) | 304 | + | parts = append(parts, gemPart{Text: v.Text, ThoughtSignature: v.ThoughtSignature}) |
| 305 | + | case ImageBlock: | |||
| 306 | + | parts = append(parts, gemPart{ | |||
| 307 | + | InlineData: &gemInlineData{ | |||
| 308 | + | MimeType: v.MimeType, | |||
| 309 | + | Data: base64.StdEncoding.EncodeToString(v.Data), | |||
| 310 | + | }, | |||
| 311 | + | ThoughtSignature: v.ThoughtSignature, | |||
| 312 | + | }) | |||
| 296 | case ToolCallBlock: | 313 | case ToolCallBlock: | ||
| 297 | if !functionsEnabled { | 314 | if !functionsEnabled { | ||
| 298 | continue | 315 | continue | ||
| @@ -306,6 +323,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart | |||||
| 306 | Name: v.Name, | 323 | Name: v.Name, | ||
| 307 | Args: args, | 324 | Args: args, | ||
| 308 | }, | 325 | }, | ||
| 326 | + | ThoughtSignature: v.ThoughtSignature, | |||
| 309 | }) | 327 | }) | ||
| 310 | } | 328 | } | ||
| 311 | } | 329 | } | ||
| @@ -508,41 +526,54 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 508 | // candidate carries a list of parts (text or functionCall), | 526 | // candidate carries a list of parts (text or functionCall), | ||
| 509 | // possibly accumulating across chunks. | 527 | // possibly accumulating across chunks. | ||
| 510 | type blockEntry struct { | 528 | type blockEntry struct { | ||
| 511 | - | kind string // "text" | "image" | "tool_use" | 529 | + | kind string // "text" | "image" | "tool_use" | "reasoning" |
| 512 | - | textBuf strings.Builder | 530 | + | textBuf strings.Builder |
| 513 | - | image *ImageBlock | 531 | + | thoughtSignature string |
| 514 | - | imagePath string | 532 | + | image *ImageBlock |
| 515 | - | toolID string | 533 | + | imagePath string |
| 516 | - | toolName string | 534 | + | toolID string |
| 517 | - | toolArgs strings.Builder | 535 | + | toolName string |
| 536 | + | toolArgs strings.Builder | |||
| 518 | } | 537 | } | ||
| 519 | var ( | 538 | var ( | ||
| 520 | - | blocks []*blockEntry | 539 | + | blocks []*blockEntry |
| 521 | - | currentText *blockEntry | 540 | + | currentText *blockEntry |
| 522 | - | usage Usage | 541 | + | currentReasoning *blockEntry |
| 523 | - | stop StopReason = StopEnd | 542 | + | usage Usage |
| 524 | - | finalErr error | 543 | + | stop StopReason = StopEnd |
| 525 | - | toolCounter int | 544 | + | finalErr error |
| 545 | + | toolCounter int | |||
| 526 | ) | 546 | ) | ||
| 527 | 547 | ||||
| 528 | - | appendText := func(delta string) { | 548 | + | appendPartText := func(kind, delta, signature string) { |
| 529 | - | if currentText == nil { | 549 | + | current := ¤tText |
| 530 | - | currentText = &blockEntry{kind: "text"} | 550 | + | if kind == "reasoning" { |
| 531 | - | blocks = append(blocks, currentText) | 551 | + | current = ¤tReasoning |
| 552 | + | } | |||
| 553 | + | if *current == nil { | |||
| 554 | + | *current = &blockEntry{kind: kind} | |||
| 555 | + | blocks = append(blocks, *current) | |||
| 556 | + | } | |||
| 557 | + | (*current).textBuf.WriteString(delta) | |||
| 558 | + | if signature != "" { | |||
| 559 | + | (*current).thoughtSignature = signature | |||
| 560 | + | // A signature terminates this wire part. A later delta of the | |||
| 561 | + | // same kind belongs to a new part and must not be merged into it. | |||
| 562 | + | *current = nil | |||
| 532 | } | 563 | } | ||
| 533 | - | currentText.textBuf.WriteString(delta) | |||
| 534 | } | 564 | } | ||
| 535 | 565 | ||||
| 536 | - | appendImage := func(mimeType, dataB64 string) { | 566 | + | appendImage := func(mimeType, dataB64, signature string) { |
| 537 | data, err := base64.StdEncoding.DecodeString(dataB64) | 567 | data, err := base64.StdEncoding.DecodeString(dataB64) | ||
| 538 | if err != nil || len(data) == 0 { | 568 | if err != nil || len(data) == 0 { | ||
| 539 | return | 569 | return | ||
| 540 | } | 570 | } | ||
| 541 | - | img := ImageBlock{MimeType: mimeType, Data: data} | 571 | + | img := ImageBlock{MimeType: mimeType, Data: data, ThoughtSignature: signature} |
| 542 | path, _ := saveGeminiImageToWorkingDir(mimeType, data) | 572 | path, _ := saveGeminiImageToWorkingDir(mimeType, data) | ||
| 543 | blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path}) | 573 | blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path}) | ||
| 544 | - | // Image blocks break the current text run. | 574 | + | // Non-text parts break both streamed text runs. |
| 545 | currentText = nil | 575 | currentText = nil | ||
| 576 | + | currentReasoning = nil | |||
| 546 | } | 577 | } | ||
| 547 | 578 | ||||
| 548 | startTool := func(name string, providedID string, args json.RawMessage) *blockEntry { | 579 | startTool := func(name string, providedID string, args json.RawMessage) *blockEntry { | ||
| @@ -560,8 +591,9 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 560 | t.toolArgs.Write(args) | 591 | t.toolArgs.Write(args) | ||
| 561 | } | 592 | } | ||
| 562 | blocks = append(blocks, t) | 593 | blocks = append(blocks, t) | ||
| 563 | - | // New tool block breaks the current text run. | 594 | + | // New tool blocks break both streamed text runs. |
| 564 | currentText = nil | 595 | currentText = nil | ||
| 596 | + | currentReasoning = nil | |||
| 565 | return t | 597 | return t | ||
| 566 | } | 598 | } | ||
| 567 | 599 | ||||
| @@ -571,7 +603,17 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 571 | switch b.kind { | 603 | switch b.kind { | ||
| 572 | case "text": | 604 | case "text": | ||
| 573 | if b.textBuf.Len() > 0 { | 605 | if b.textBuf.Len() > 0 { | ||
| 574 | - | content = append(content, TextBlock{Text: b.textBuf.String()}) | 606 | + | content = append(content, TextBlock{ |
| 607 | + | Text: b.textBuf.String(), | |||
| 608 | + | ThoughtSignature: b.thoughtSignature, | |||
| 609 | + | }) | |||
| 610 | + | } | |||
| 611 | + | case "reasoning": | |||
| 612 | + | if b.textBuf.Len() > 0 || b.thoughtSignature != "" { | |||
| 613 | + | content = append(content, ReasoningBlock{ | |||
| 614 | + | Summary: b.textBuf.String(), | |||
| 615 | + | Encrypted: b.thoughtSignature, | |||
| 616 | + | }) | |||
| 575 | } | 617 | } | ||
| 576 | case "image": | 618 | case "image": | ||
| 577 | if b.image != nil && len(b.image.Data) > 0 { | 619 | if b.image != nil && len(b.image.Data) > 0 { | ||
| @@ -586,7 +628,10 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 586 | args = "{}" | 628 | args = "{}" | ||
| 587 | } | 629 | } | ||
| 588 | content = append(content, ToolCallBlock{ | 630 | content = append(content, ToolCallBlock{ | ||
| 589 | - | ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), | 631 | + | ID: b.toolID, |
| 632 | + | Name: b.toolName, | |||
| 633 | + | Arguments: json.RawMessage(args), | |||
| 634 | + | ThoughtSignature: b.thoughtSignature, | |||
| 590 | }) | 635 | }) | ||
| 591 | } | 636 | } | ||
| 592 | } | 637 | } | ||
| @@ -622,6 +667,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 622 | Text string `json:"text"` | 667 | Text string `json:"text"` | ||
| 623 | InlineData *gemInlineData `json:"inlineData"` | 668 | InlineData *gemInlineData `json:"inlineData"` | ||
| 624 | Thought bool `json:"thought"` | 669 | Thought bool `json:"thought"` | ||
| 670 | + | ThoughtSignature string `json:"thoughtSignature"` | |||
| 625 | FunctionCall *gemFunctionCall `json:"functionCall"` | 671 | FunctionCall *gemFunctionCall `json:"functionCall"` | ||
| 626 | FunctionCallID string `json:"id"` | 672 | FunctionCallID string `json:"id"` | ||
| 627 | FunctionCallName string `json:"name"` | 673 | FunctionCallName string `json:"name"` | ||
| @@ -654,7 +700,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 654 | for _, cand := range chunk.Candidates { | 700 | for _, cand := range chunk.Candidates { | ||
| 655 | for _, part := range cand.Content.Parts { | 701 | for _, part := range cand.Content.Parts { | ||
| 656 | if part.InlineData != nil { | 702 | if part.InlineData != nil { | ||
| 657 | - | appendImage(part.InlineData.MimeType, part.InlineData.Data) | 703 | + | appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature) |
| 658 | continue | 704 | continue | ||
| 659 | } | 705 | } | ||
| 660 | if part.FunctionCall != nil { | 706 | if part.FunctionCall != nil { | ||
| @@ -665,22 +711,22 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 665 | args = json.RawMessage("{}") | 711 | args = json.RawMessage("{}") | ||
| 666 | } | 712 | } | ||
| 667 | t := startTool(part.FunctionCall.Name, part.FunctionCallID, args) | 713 | t := startTool(part.FunctionCall.Name, part.FunctionCallID, args) | ||
| 714 | + | t.thoughtSignature = part.ThoughtSignature | |||
| 668 | out <- EventToolStart{ID: t.toolID, Name: t.toolName} | 715 | out <- EventToolStart{ID: t.toolID, Name: t.toolName} | ||
| 669 | out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()} | 716 | out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()} | ||
| 670 | out <- EventToolEnd{ID: t.toolID} | 717 | out <- EventToolEnd{ID: t.toolID} | ||
| 671 | continue | 718 | continue | ||
| 672 | } | 719 | } | ||
| 673 | - | if part.Text == "" { | 720 | + | if part.Thought { |
| 721 | + | appendPartText("reasoning", part.Text, part.ThoughtSignature) | |||
| 722 | + | currentText = nil | |||
| 674 | continue | 723 | continue | ||
| 675 | } | 724 | } | ||
| 676 | - | if part.Thought { | 725 | + | if part.Text == "" { |
| 677 | - | // Thinking summaries arrive as text parts | |||
| 678 | - | // with thought=true. Not surfaced to the | |||
| 679 | - | // user in v1; could be exposed via a | |||
| 680 | - | // future ReasoningBlock if useful. | |||
| 681 | continue | 726 | continue | ||
| 682 | } | 727 | } | ||
| 683 | - | appendText(part.Text) | 728 | + | appendPartText("text", part.Text, part.ThoughtSignature) |
| 729 | + | currentReasoning = nil | |||
| 684 | out <- EventTextDelta{Delta: part.Text} | 730 | out <- EventTextDelta{Delta: part.Text} | ||
| 685 | } | 731 | } | ||
| 686 | switch cand.FinishReason { | 732 | switch cand.FinishReason { | ||
M packages/provider/gemini_test.go +255 -0
| @@ -329,6 +329,75 @@ func TestGeminiThinkingConfig(t *testing.T) { | |||
| 329 | 329 | } | |
| 330 | 330 | } | |
| 331 | 331 | ||
| 332 | + | func TestGeminiThoughtSignaturesStreamAndReplay(t *testing.T) { | |
| 333 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 334 | + | w.Header().Set("content-type", "text/event-stream") | |
| 335 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"think","thought":true}]}}]}`+"\n\n") | |
| 336 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ing","thought":true,"thoughtSignature":"reasoning-sig"}]}}]}`+"\n\n") | |
| 337 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ans"}]}}]}`+"\n\n") | |
| 338 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"wer","thoughtSignature":"text-sig"}]}}]}`+"\n\n") | |
| 339 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig"}]},"finishReason":"STOP"}]}`+"\n\n") | |
| 340 | + | })) | |
| 341 | + | defer srv.Close() | |
| 342 | + | ||
| 343 | + | client := NewGemini("k", srv.URL).(*geminiClient) | |
| 344 | + | events, err := client.Stream(context.Background(), Request{Model: "gemini-3-flash"}) | |
| 345 | + | if err != nil { | |
| 346 | + | t.Fatal(err) | |
| 347 | + | } | |
| 348 | + | var done EventDone | |
| 349 | + | for event := range events { | |
| 350 | + | if event, ok := event.(EventDone); ok { | |
| 351 | + | done = event | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | if len(done.Message.Content) != 3 { | |
| 355 | + | t.Fatalf("content = %#v", done.Message.Content) | |
| 356 | + | } | |
| 357 | + | reasoning, ok := done.Message.Content[0].(ReasoningBlock) | |
| 358 | + | if !ok || reasoning.Summary != "thinking" || reasoning.Encrypted != "reasoning-sig" { | |
| 359 | + | t.Fatalf("reasoning = %#v", done.Message.Content[0]) | |
| 360 | + | } | |
| 361 | + | text, ok := done.Message.Content[1].(TextBlock) | |
| 362 | + | if !ok || text.Text != "answer" || text.ThoughtSignature != "text-sig" { | |
| 363 | + | t.Fatalf("text = %#v", done.Message.Content[1]) | |
| 364 | + | } | |
| 365 | + | tool, ok := done.Message.Content[2].(ToolCallBlock) | |
| 366 | + | if !ok || tool.ThoughtSignature != "tool-sig" { | |
| 367 | + | t.Fatalf("tool = %#v", done.Message.Content[2]) | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | wire, _, err := client.buildRequest(Request{ | |
| 371 | + | Model: "gemini-3-flash", | |
| 372 | + | Messages: []Message{done.Message}, | |
| 373 | + | }) | |
| 374 | + | if err != nil { | |
| 375 | + | t.Fatal(err) | |
| 376 | + | } | |
| 377 | + | parts := wire.Contents[0].Parts | |
| 378 | + | if len(parts) != 3 || parts[0].ThoughtSignature != "reasoning-sig" || | |
| 379 | + | parts[1].ThoughtSignature != "text-sig" || parts[2].ThoughtSignature != "tool-sig" { | |
| 380 | + | t.Fatalf("replayed parts = %#v", parts) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func TestGeminiAssistantImageReplaysThoughtSignature(t *testing.T) { | |
| 385 | + | client := NewGemini("k", "https://example.invalid").(*geminiClient) | |
| 386 | + | wire, _, err := client.buildRequest(Request{ | |
| 387 | + | Model: "gemini-3-flash", | |
| 388 | + | Messages: []Message{{Role: RoleAssistant, Content: []Content{ | |
| 389 | + | ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"}, | |
| 390 | + | }}}, | |
| 391 | + | }) | |
| 392 | + | if err != nil { | |
| 393 | + | t.Fatal(err) | |
| 394 | + | } | |
| 395 | + | part := wire.Contents[0].Parts[0] | |
| 396 | + | if part.InlineData == nil || part.ThoughtSignature != "image-sig" { | |
| 397 | + | t.Fatalf("image part = %#v", part) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 332 | 401 | // TestDiscoverGoogle exercises the discovery helper against a fake | |
| 333 | 402 | // /v1beta/models endpoint, confirming pagination plus filtering of | |
| 334 | 403 | // non-chat ids (embedding, aqa). | |
| @@ -375,3 +444,189 @@ func TestDiscoverGoogle(t *testing.T) { | |||
| 375 | 444 | t.Errorf("second model wrong: %+v", got[1]) | |
| 376 | 445 | } | |
| 377 | 446 | } | |
| 447 | + | ||
| 448 | + | // TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing | |
| 449 | + | // fallback behavior for empty/invalid input, to make sure the new logic | |
| 450 | + | // didn't regress it. | |
| 451 | + | func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) { | |
| 452 | + | for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} { | |
| 453 | + | out := sanitizeGeminiToolSchema(in) | |
| 454 | + | var got map[string]any | |
| 455 | + | if err := json.Unmarshal(out, &got); err != nil { | |
| 456 | + | t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out) | |
| 457 | + | } | |
| 458 | + | if got["type"] != "object" { | |
| 459 | + | t.Fatalf("want type=object fallback, got %+v", got) | |
| 460 | + | } | |
| 461 | + | if _, ok := got["properties"].(map[string]any); !ok { | |
| 462 | + | t.Fatalf("want properties object fallback, got %+v", got) | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // TestGeminiStreamReasoning confirms that when the Gemini SSE stream | |
| 468 | + | // contains thought blocks with a signature, they are correctly mapped to | |
| 469 | + | // ReasoningBlock structures inside the final assistant message. | |
| 470 | + | func TestGeminiStreamReasoning(t *testing.T) { | |
| 471 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 472 | + | w.Header().Set("content-type", "text/event-stream") | |
| 473 | + | w.WriteHeader(http.StatusOK) | |
| 474 | + | fl, _ := w.(http.Flusher) | |
| 475 | + | write := func(s string) { | |
| 476 | + | _, _ = w.Write([]byte(s)) | |
| 477 | + | if fl != nil { | |
| 478 | + | fl.Flush() | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | // Write a thought part with signature, then text, then usage containing thoughtsTokenCount. | |
| 482 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n") | |
| 483 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n") | |
| 484 | + | })) | |
| 485 | + | defer srv.Close() | |
| 486 | + | ||
| 487 | + | c := NewGemini("k", srv.URL) | |
| 488 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |
| 489 | + | if err != nil { | |
| 490 | + | t.Fatal(err) | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | var gotText string | |
| 494 | + | var done EventDone | |
| 495 | + | var usage Usage | |
| 496 | + | for ev := range evs { | |
| 497 | + | switch e := ev.(type) { | |
| 498 | + | case EventTextDelta: | |
| 499 | + | gotText += e.Delta | |
| 500 | + | case EventUsage: | |
| 501 | + | usage = e.Usage | |
| 502 | + | case EventDone: | |
| 503 | + | done = e | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | if gotText != "Answer text" { | |
| 508 | + | t.Fatalf("text=%q", gotText) | |
| 509 | + | } | |
| 510 | + | if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3) | |
| 511 | + | t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens) | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // Verify that the final message contains both the ReasoningBlock and the TextBlock | |
| 515 | + | msg := done.Message | |
| 516 | + | if len(msg.Content) != 2 { | |
| 517 | + | t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content) | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | rb, ok := msg.Content[0].(ReasoningBlock) | |
| 521 | + | if !ok { | |
| 522 | + | t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0]) | |
| 523 | + | } | |
| 524 | + | if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" { | |
| 525 | + | t.Fatalf("invalid ReasoningBlock: %+v", rb) | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | tb, ok := msg.Content[1].(TextBlock) | |
| 529 | + | if !ok { | |
| 530 | + | t.Fatalf("second block should be TextBlock, got %T", msg.Content[1]) | |
| 531 | + | } | |
| 532 | + | if tb.Text != "Answer text" { | |
| 533 | + | t.Fatalf("invalid TextBlock: %+v", tb) | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | // TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a | |
| 538 | + | // historical assistant message containing a ReasoningBlock, buildRequest | |
| 539 | + | // serializes it into the verbatim Gemini "thought: true" wire representation | |
| 540 | + | // with its corresponding thoughtSignature. | |
| 541 | + | func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) { | |
| 542 | + | c := NewGemini("k", "https://example.invalid").(*geminiClient) | |
| 543 | + | wire, _, err := c.buildRequest(Request{ | |
| 544 | + | Model: "gemini-2.5-pro", | |
| 545 | + | Messages: []Message{ | |
| 546 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}}, | |
| 547 | + | {Role: RoleAssistant, Content: []Content{ | |
| 548 | + | ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"}, | |
| 549 | + | TextBlock{Text: "It scatters blue light more than other colors."}, | |
| 550 | + | }}, | |
| 551 | + | }, | |
| 552 | + | }) | |
| 553 | + | if err != nil { | |
| 554 | + | t.Fatal(err) | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | if len(wire.Contents) != 2 { | |
| 558 | + | t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents)) | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | assistant := wire.Contents[1] | |
| 562 | + | if assistant.Role != "model" { | |
| 563 | + | t.Fatalf("assistant role should be 'model', got %q", assistant.Role) | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | if len(assistant.Parts) != 2 { | |
| 567 | + | t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts) | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | // Check the serialized ReasoningBlock | |
| 571 | + | p0 := assistant.Parts[0] | |
| 572 | + | if !p0.Thought { | |
| 573 | + | t.Errorf("part 0: expected Thought=true, got %+v", p0) | |
| 574 | + | } | |
| 575 | + | if p0.Text != "Evaluating Rayleigh scattering" { | |
| 576 | + | t.Errorf("part 0: expected text to match summary, got %q", p0.Text) | |
| 577 | + | } | |
| 578 | + | if p0.ThoughtSignature != "sky-sig-999" { | |
| 579 | + | t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature) | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // Check the serialized TextBlock | |
| 583 | + | p1 := assistant.Parts[1] | |
| 584 | + | if p1.Thought { | |
| 585 | + | t.Errorf("part 1: expected Thought=false, got %+v", p1) | |
| 586 | + | } | |
| 587 | + | if p1.Text != "It scatters blue light more than other colors." { | |
| 588 | + | t.Errorf("part 1: expected text to match, got %q", p1.Text) | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call | |
| 593 | + | // featuring a thoughtSignature in the SSE stream successfully extracts and | |
| 594 | + | // attaches the thought signature onto the final ToolCallBlock. | |
| 595 | + | func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) { | |
| 596 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 597 | + | w.Header().Set("content-type", "text/event-stream") | |
| 598 | + | w.WriteHeader(http.StatusOK) | |
| 599 | + | _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n")) | |
| 600 | + | })) | |
| 601 | + | defer srv.Close() | |
| 602 | + | ||
| 603 | + | c := NewGemini("k", srv.URL) | |
| 604 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |
| 605 | + | if err != nil { | |
| 606 | + | t.Fatal(err) | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | var done EventDone | |
| 610 | + | for ev := range evs { | |
| 611 | + | if e, ok := ev.(EventDone); ok { | |
| 612 | + | done = e | |
| 613 | + | } | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | if len(done.Message.Content) != 1 { | |
| 617 | + | t.Fatalf("want 1 content block, got %d", len(done.Message.Content)) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | tc, ok := done.Message.Content[0].(ToolCallBlock) | |
| 621 | + | if !ok { | |
| 622 | + | t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0]) | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | if tc.Name != "read" { | |
| 626 | + | t.Fatalf("tool name=%q, want 'read'", tc.Name) | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | if tc.ThoughtSignature != "tool-sig-456" { | |
| 630 | + | t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature) | |
| 631 | + | } | |
| 632 | + | } | |
| @@ -329,6 +329,75 @@ func TestGeminiThinkingConfig(t *testing.T) { | |||||
| 329 | } | 329 | } | ||
| 330 | } | 330 | } | ||
| 331 | 331 | ||||
| 332 | + | func TestGeminiThoughtSignaturesStreamAndReplay(t *testing.T) { | |||
| 333 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 334 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 335 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"think","thought":true}]}}]}`+"\n\n") | |||
| 336 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ing","thought":true,"thoughtSignature":"reasoning-sig"}]}}]}`+"\n\n") | |||
| 337 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ans"}]}}]}`+"\n\n") | |||
| 338 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"wer","thoughtSignature":"text-sig"}]}}]}`+"\n\n") | |||
| 339 | + | _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig"}]},"finishReason":"STOP"}]}`+"\n\n") | |||
| 340 | + | })) | |||
| 341 | + | defer srv.Close() | |||
| 342 | + | ||||
| 343 | + | client := NewGemini("k", srv.URL).(*geminiClient) | |||
| 344 | + | events, err := client.Stream(context.Background(), Request{Model: "gemini-3-flash"}) | |||
| 345 | + | if err != nil { | |||
| 346 | + | t.Fatal(err) | |||
| 347 | + | } | |||
| 348 | + | var done EventDone | |||
| 349 | + | for event := range events { | |||
| 350 | + | if event, ok := event.(EventDone); ok { | |||
| 351 | + | done = event | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | if len(done.Message.Content) != 3 { | |||
| 355 | + | t.Fatalf("content = %#v", done.Message.Content) | |||
| 356 | + | } | |||
| 357 | + | reasoning, ok := done.Message.Content[0].(ReasoningBlock) | |||
| 358 | + | if !ok || reasoning.Summary != "thinking" || reasoning.Encrypted != "reasoning-sig" { | |||
| 359 | + | t.Fatalf("reasoning = %#v", done.Message.Content[0]) | |||
| 360 | + | } | |||
| 361 | + | text, ok := done.Message.Content[1].(TextBlock) | |||
| 362 | + | if !ok || text.Text != "answer" || text.ThoughtSignature != "text-sig" { | |||
| 363 | + | t.Fatalf("text = %#v", done.Message.Content[1]) | |||
| 364 | + | } | |||
| 365 | + | tool, ok := done.Message.Content[2].(ToolCallBlock) | |||
| 366 | + | if !ok || tool.ThoughtSignature != "tool-sig" { | |||
| 367 | + | t.Fatalf("tool = %#v", done.Message.Content[2]) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | wire, _, err := client.buildRequest(Request{ | |||
| 371 | + | Model: "gemini-3-flash", | |||
| 372 | + | Messages: []Message{done.Message}, | |||
| 373 | + | }) | |||
| 374 | + | if err != nil { | |||
| 375 | + | t.Fatal(err) | |||
| 376 | + | } | |||
| 377 | + | parts := wire.Contents[0].Parts | |||
| 378 | + | if len(parts) != 3 || parts[0].ThoughtSignature != "reasoning-sig" || | |||
| 379 | + | parts[1].ThoughtSignature != "text-sig" || parts[2].ThoughtSignature != "tool-sig" { | |||
| 380 | + | t.Fatalf("replayed parts = %#v", parts) | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | func TestGeminiAssistantImageReplaysThoughtSignature(t *testing.T) { | |||
| 385 | + | client := NewGemini("k", "https://example.invalid").(*geminiClient) | |||
| 386 | + | wire, _, err := client.buildRequest(Request{ | |||
| 387 | + | Model: "gemini-3-flash", | |||
| 388 | + | Messages: []Message{{Role: RoleAssistant, Content: []Content{ | |||
| 389 | + | ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"}, | |||
| 390 | + | }}}, | |||
| 391 | + | }) | |||
| 392 | + | if err != nil { | |||
| 393 | + | t.Fatal(err) | |||
| 394 | + | } | |||
| 395 | + | part := wire.Contents[0].Parts[0] | |||
| 396 | + | if part.InlineData == nil || part.ThoughtSignature != "image-sig" { | |||
| 397 | + | t.Fatalf("image part = %#v", part) | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | ||||
| 332 | // TestDiscoverGoogle exercises the discovery helper against a fake | 401 | // TestDiscoverGoogle exercises the discovery helper against a fake | ||
| 333 | // /v1beta/models endpoint, confirming pagination plus filtering of | 402 | // /v1beta/models endpoint, confirming pagination plus filtering of | ||
| 334 | // non-chat ids (embedding, aqa). | 403 | // non-chat ids (embedding, aqa). | ||
| @@ -375,3 +444,189 @@ func TestDiscoverGoogle(t *testing.T) { | |||||
| 375 | t.Errorf("second model wrong: %+v", got[1]) | 444 | t.Errorf("second model wrong: %+v", got[1]) | ||
| 376 | } | 445 | } | ||
| 377 | } | 446 | } | ||
| 447 | + | ||||
| 448 | + | // TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing | |||
| 449 | + | // fallback behavior for empty/invalid input, to make sure the new logic | |||
| 450 | + | // didn't regress it. | |||
| 451 | + | func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) { | |||
| 452 | + | for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} { | |||
| 453 | + | out := sanitizeGeminiToolSchema(in) | |||
| 454 | + | var got map[string]any | |||
| 455 | + | if err := json.Unmarshal(out, &got); err != nil { | |||
| 456 | + | t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out) | |||
| 457 | + | } | |||
| 458 | + | if got["type"] != "object" { | |||
| 459 | + | t.Fatalf("want type=object fallback, got %+v", got) | |||
| 460 | + | } | |||
| 461 | + | if _, ok := got["properties"].(map[string]any); !ok { | |||
| 462 | + | t.Fatalf("want properties object fallback, got %+v", got) | |||
| 463 | + | } | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // TestGeminiStreamReasoning confirms that when the Gemini SSE stream | |||
| 468 | + | // contains thought blocks with a signature, they are correctly mapped to | |||
| 469 | + | // ReasoningBlock structures inside the final assistant message. | |||
| 470 | + | func TestGeminiStreamReasoning(t *testing.T) { | |||
| 471 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 472 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 473 | + | w.WriteHeader(http.StatusOK) | |||
| 474 | + | fl, _ := w.(http.Flusher) | |||
| 475 | + | write := func(s string) { | |||
| 476 | + | _, _ = w.Write([]byte(s)) | |||
| 477 | + | if fl != nil { | |||
| 478 | + | fl.Flush() | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | // Write a thought part with signature, then text, then usage containing thoughtsTokenCount. | |||
| 482 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n") | |||
| 483 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n") | |||
| 484 | + | })) | |||
| 485 | + | defer srv.Close() | |||
| 486 | + | ||||
| 487 | + | c := NewGemini("k", srv.URL) | |||
| 488 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |||
| 489 | + | if err != nil { | |||
| 490 | + | t.Fatal(err) | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | var gotText string | |||
| 494 | + | var done EventDone | |||
| 495 | + | var usage Usage | |||
| 496 | + | for ev := range evs { | |||
| 497 | + | switch e := ev.(type) { | |||
| 498 | + | case EventTextDelta: | |||
| 499 | + | gotText += e.Delta | |||
| 500 | + | case EventUsage: | |||
| 501 | + | usage = e.Usage | |||
| 502 | + | case EventDone: | |||
| 503 | + | done = e | |||
| 504 | + | } | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | if gotText != "Answer text" { | |||
| 508 | + | t.Fatalf("text=%q", gotText) | |||
| 509 | + | } | |||
| 510 | + | if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3) | |||
| 511 | + | t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens) | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | // Verify that the final message contains both the ReasoningBlock and the TextBlock | |||
| 515 | + | msg := done.Message | |||
| 516 | + | if len(msg.Content) != 2 { | |||
| 517 | + | t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content) | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | rb, ok := msg.Content[0].(ReasoningBlock) | |||
| 521 | + | if !ok { | |||
| 522 | + | t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0]) | |||
| 523 | + | } | |||
| 524 | + | if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" { | |||
| 525 | + | t.Fatalf("invalid ReasoningBlock: %+v", rb) | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | tb, ok := msg.Content[1].(TextBlock) | |||
| 529 | + | if !ok { | |||
| 530 | + | t.Fatalf("second block should be TextBlock, got %T", msg.Content[1]) | |||
| 531 | + | } | |||
| 532 | + | if tb.Text != "Answer text" { | |||
| 533 | + | t.Fatalf("invalid TextBlock: %+v", tb) | |||
| 534 | + | } | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | // TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a | |||
| 538 | + | // historical assistant message containing a ReasoningBlock, buildRequest | |||
| 539 | + | // serializes it into the verbatim Gemini "thought: true" wire representation | |||
| 540 | + | // with its corresponding thoughtSignature. | |||
| 541 | + | func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) { | |||
| 542 | + | c := NewGemini("k", "https://example.invalid").(*geminiClient) | |||
| 543 | + | wire, _, err := c.buildRequest(Request{ | |||
| 544 | + | Model: "gemini-2.5-pro", | |||
| 545 | + | Messages: []Message{ | |||
| 546 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}}, | |||
| 547 | + | {Role: RoleAssistant, Content: []Content{ | |||
| 548 | + | ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"}, | |||
| 549 | + | TextBlock{Text: "It scatters blue light more than other colors."}, | |||
| 550 | + | }}, | |||
| 551 | + | }, | |||
| 552 | + | }) | |||
| 553 | + | if err != nil { | |||
| 554 | + | t.Fatal(err) | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | if len(wire.Contents) != 2 { | |||
| 558 | + | t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents)) | |||
| 559 | + | } | |||
| 560 | + | ||||
| 561 | + | assistant := wire.Contents[1] | |||
| 562 | + | if assistant.Role != "model" { | |||
| 563 | + | t.Fatalf("assistant role should be 'model', got %q", assistant.Role) | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | if len(assistant.Parts) != 2 { | |||
| 567 | + | t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts) | |||
| 568 | + | } | |||
| 569 | + | ||||
| 570 | + | // Check the serialized ReasoningBlock | |||
| 571 | + | p0 := assistant.Parts[0] | |||
| 572 | + | if !p0.Thought { | |||
| 573 | + | t.Errorf("part 0: expected Thought=true, got %+v", p0) | |||
| 574 | + | } | |||
| 575 | + | if p0.Text != "Evaluating Rayleigh scattering" { | |||
| 576 | + | t.Errorf("part 0: expected text to match summary, got %q", p0.Text) | |||
| 577 | + | } | |||
| 578 | + | if p0.ThoughtSignature != "sky-sig-999" { | |||
| 579 | + | t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature) | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | // Check the serialized TextBlock | |||
| 583 | + | p1 := assistant.Parts[1] | |||
| 584 | + | if p1.Thought { | |||
| 585 | + | t.Errorf("part 1: expected Thought=false, got %+v", p1) | |||
| 586 | + | } | |||
| 587 | + | if p1.Text != "It scatters blue light more than other colors." { | |||
| 588 | + | t.Errorf("part 1: expected text to match, got %q", p1.Text) | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call | |||
| 593 | + | // featuring a thoughtSignature in the SSE stream successfully extracts and | |||
| 594 | + | // attaches the thought signature onto the final ToolCallBlock. | |||
| 595 | + | func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) { | |||
| 596 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 597 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 598 | + | w.WriteHeader(http.StatusOK) | |||
| 599 | + | _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n")) | |||
| 600 | + | })) | |||
| 601 | + | defer srv.Close() | |||
| 602 | + | ||||
| 603 | + | c := NewGemini("k", srv.URL) | |||
| 604 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |||
| 605 | + | if err != nil { | |||
| 606 | + | t.Fatal(err) | |||
| 607 | + | } | |||
| 608 | + | ||||
| 609 | + | var done EventDone | |||
| 610 | + | for ev := range evs { | |||
| 611 | + | if e, ok := ev.(EventDone); ok { | |||
| 612 | + | done = e | |||
| 613 | + | } | |||
| 614 | + | } | |||
| 615 | + | ||||
| 616 | + | if len(done.Message.Content) != 1 { | |||
| 617 | + | t.Fatalf("want 1 content block, got %d", len(done.Message.Content)) | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | tc, ok := done.Message.Content[0].(ToolCallBlock) | |||
| 621 | + | if !ok { | |||
| 622 | + | t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0]) | |||
| 623 | + | } | |||
| 624 | + | ||||
| 625 | + | if tc.Name != "read" { | |||
| 626 | + | t.Fatalf("tool name=%q, want 'read'", tc.Name) | |||
| 627 | + | } | |||
| 628 | + | ||||
| 629 | + | if tc.ThoughtSignature != "tool-sig-456" { | |||
| 630 | + | t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature) | |||
| 631 | + | } | |||
| 632 | + | } | |||
M packages/provider/provider.go +9 -6
| @@ -28,24 +28,27 @@ type Content interface { | |||
| 28 | 28 | ||
| 29 | 29 | // TextBlock is plain text content. | |
| 30 | 30 | type TextBlock struct { | |
| 31 | - | Text string `json:"text"` | |
| 31 | + | Text string `json:"text"` | |
| 32 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | func (TextBlock) isContent() {} | |
| 35 | 36 | ||
| 36 | 37 | // ImageBlock is an inline image (PNG/JPEG/GIF/WebP). | |
| 37 | 38 | type ImageBlock struct { | |
| 38 | - | MimeType string `json:"mime_type"` | |
| 39 | - | Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire | |
| 39 | + | MimeType string `json:"mime_type"` | |
| 40 | + | Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire | |
| 41 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 44 | func (ImageBlock) isContent() {} | |
| 43 | 45 | ||
| 44 | 46 | // ToolCallBlock is an assistant-issued call to a tool. | |
| 45 | 47 | type ToolCallBlock struct { | |
| 46 | - | ID string `json:"id"` | |
| 47 | - | Name string `json:"name"` | |
| 48 | - | Arguments json.RawMessage `json:"arguments"` | |
| 48 | + | ID string `json:"id"` | |
| 49 | + | Name string `json:"name"` | |
| 50 | + | Arguments json.RawMessage `json:"arguments"` | |
| 51 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 51 | 54 | func (ToolCallBlock) isContent() {} | |
| @@ -28,24 +28,27 @@ type Content interface { | |||||
| 28 | 28 | ||||
| 29 | // TextBlock is plain text content. | 29 | // TextBlock is plain text content. | ||
| 30 | type TextBlock struct { | 30 | type TextBlock struct { | ||
| 31 | - | Text string `json:"text"` | 31 | + | Text string `json:"text"` |
| 32 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | func (TextBlock) isContent() {} | 35 | func (TextBlock) isContent() {} | ||
| 35 | 36 | ||||
| 36 | // ImageBlock is an inline image (PNG/JPEG/GIF/WebP). | 37 | // ImageBlock is an inline image (PNG/JPEG/GIF/WebP). | ||
| 37 | type ImageBlock struct { | 38 | type ImageBlock struct { | ||
| 38 | - | MimeType string `json:"mime_type"` | 39 | + | MimeType string `json:"mime_type"` |
| 39 | - | Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire | 40 | + | Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire |
| 41 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |||
| 40 | } | 42 | } | ||
| 41 | 43 | ||||
| 42 | func (ImageBlock) isContent() {} | 44 | func (ImageBlock) isContent() {} | ||
| 43 | 45 | ||||
| 44 | // ToolCallBlock is an assistant-issued call to a tool. | 46 | // ToolCallBlock is an assistant-issued call to a tool. | ||
| 45 | type ToolCallBlock struct { | 47 | type ToolCallBlock struct { | ||
| 46 | - | ID string `json:"id"` | 48 | + | ID string `json:"id"` |
| 47 | - | Name string `json:"name"` | 49 | + | Name string `json:"name"` |
| 48 | - | Arguments json.RawMessage `json:"arguments"` | 50 | + | Arguments json.RawMessage `json:"arguments"` |
| 51 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |||
| 49 | } | 52 | } | ||
| 50 | 53 | ||||
| 51 | func (ToolCallBlock) isContent() {} | 54 | func (ToolCallBlock) isContent() {} | ||