Add thought signatures for Gemini tool calls
Gemini requires thought signatures to be retained in function call parts.
- commit
- 056d214230cbaad277dd8d725d065ca794b216a6
- author
- Eckhart Koeppen · · 2026-07-06 09:20 UTC
- committer
- Eckhart Koeppen · · 2026-07-16 05:46 UTC
- parent
- c98b3d2
- signature
- unsigned
3 files · +232 -15
M packages/provider/gemini.go +40 -12
| @@ -82,7 +82,8 @@ type gemPart struct { | |||
| 82 | 82 | FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"` | |
| 83 | 83 | // Thought: true marks a thought-summary part. Outgoing parts | |
| 84 | 84 | // from zot never set this; incoming chunks might. | |
| 85 | - | Thought bool `json:"thought,omitempty"` | |
| 85 | + | Thought bool `json:"thought,omitempty"` | |
| 86 | + | ThoughtSignature string `json:"thoughtSignature,omitempty"` | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | type gemContent struct { | |
| @@ -288,6 +289,16 @@ 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 | + | // Vertex AI requires thought parts with their signature to be | |
| 294 | + | // replayed verbatim on follow-up turns. | |
| 295 | + | if v.Encrypted != "" || v.Summary != "" { | |
| 296 | + | parts = append(parts, gemPart{ | |
| 297 | + | Text: v.Summary, | |
| 298 | + | Thought: true, | |
| 299 | + | ThoughtSignature: v.Encrypted, | |
| 300 | + | }) | |
| 301 | + | } | |
| 291 | 302 | case TextBlock: | |
| 292 | 303 | if strings.TrimSpace(v.Text) == "" { | |
| 293 | 304 | continue | |
| @@ -306,6 +317,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart | |||
| 306 | 317 | Name: v.Name, | |
| 307 | 318 | Args: args, | |
| 308 | 319 | }, | |
| 320 | + | ThoughtSignature: v.ThoughtSignature, | |
| 309 | 321 | }) | |
| 310 | 322 | } | |
| 311 | 323 | } | |
| @@ -508,13 +520,16 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 508 | 520 | // candidate carries a list of parts (text or functionCall), | |
| 509 | 521 | // possibly accumulating across chunks. | |
| 510 | 522 | 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 | |
| 523 | + | kind string // "text" | "image" | "tool_use" | "reasoning" | |
| 524 | + | textBuf strings.Builder | |
| 525 | + | image *ImageBlock | |
| 526 | + | imagePath string | |
| 527 | + | toolID string | |
| 528 | + | toolName string | |
| 529 | + | toolArgs strings.Builder | |
| 530 | + | toolThoughtSig string | |
| 531 | + | reasoningSummary string | |
| 532 | + | reasoningSignature string | |
| 518 | 533 | } | |
| 519 | 534 | var ( | |
| 520 | 535 | blocks []*blockEntry | |
| @@ -580,6 +595,11 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 580 | 595 | content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)}) | |
| 581 | 596 | } | |
| 582 | 597 | } | |
| 598 | + | case "reasoning": | |
| 599 | + | content = append(content, ReasoningBlock{ | |
| 600 | + | Summary: b.reasoningSummary, | |
| 601 | + | Encrypted: b.reasoningSignature, | |
| 602 | + | }) | |
| 583 | 603 | case "tool_use": | |
| 584 | 604 | args := b.toolArgs.String() | |
| 585 | 605 | if args == "" || !json.Valid([]byte(args)) { | |
| @@ -587,6 +607,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 587 | 607 | } | |
| 588 | 608 | content = append(content, ToolCallBlock{ | |
| 589 | 609 | ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), | |
| 610 | + | ThoughtSignature: b.toolThoughtSig, | |
| 590 | 611 | }) | |
| 591 | 612 | } | |
| 592 | 613 | } | |
| @@ -622,6 +643,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 622 | 643 | Text string `json:"text"` | |
| 623 | 644 | InlineData *gemInlineData `json:"inlineData"` | |
| 624 | 645 | Thought bool `json:"thought"` | |
| 646 | + | ThoughtSignature string `json:"thoughtSignature"` | |
| 625 | 647 | FunctionCall *gemFunctionCall `json:"functionCall"` | |
| 626 | 648 | FunctionCallID string `json:"id"` | |
| 627 | 649 | FunctionCallName string `json:"name"` | |
| @@ -665,6 +687,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 665 | 687 | args = json.RawMessage("{}") | |
| 666 | 688 | } | |
| 667 | 689 | t := startTool(part.FunctionCall.Name, part.FunctionCallID, args) | |
| 690 | + | t.toolThoughtSig = part.ThoughtSignature | |
| 668 | 691 | out <- EventToolStart{ID: t.toolID, Name: t.toolName} | |
| 669 | 692 | out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()} | |
| 670 | 693 | out <- EventToolEnd{ID: t.toolID} | |
| @@ -674,10 +697,15 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 674 | 697 | continue | |
| 675 | 698 | } | |
| 676 | 699 | 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. | |
| 700 | + | // Store thought parts as ReasoningBlocks so | |
| 701 | + | // Vertex AI can replay the thought_signature | |
| 702 | + | // on follow-up turns (required by the API). | |
| 703 | + | blocks = append(blocks, &blockEntry{ | |
| 704 | + | kind: "reasoning", | |
| 705 | + | reasoningSummary: part.Text, | |
| 706 | + | reasoningSignature: part.ThoughtSignature, | |
| 707 | + | }) | |
| 708 | + | currentText = nil | |
| 681 | 709 | continue | |
| 682 | 710 | } | |
| 683 | 711 | appendText(part.Text) | |
| @@ -82,7 +82,8 @@ type gemPart struct { | |||||
| 82 | FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"` | 82 | FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"` | ||
| 83 | // Thought: true marks a thought-summary part. Outgoing parts | 83 | // Thought: true marks a thought-summary part. Outgoing parts | ||
| 84 | // from zot never set this; incoming chunks might. | 84 | // from zot never set this; incoming chunks might. | ||
| 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,6 +289,16 @@ 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 | + | // Vertex AI requires thought parts with their signature to be | |||
| 294 | + | // replayed verbatim on follow-up turns. | |||
| 295 | + | if v.Encrypted != "" || v.Summary != "" { | |||
| 296 | + | parts = append(parts, gemPart{ | |||
| 297 | + | Text: v.Summary, | |||
| 298 | + | Thought: true, | |||
| 299 | + | ThoughtSignature: v.Encrypted, | |||
| 300 | + | }) | |||
| 301 | + | } | |||
| 291 | case TextBlock: | 302 | case TextBlock: | ||
| 292 | if strings.TrimSpace(v.Text) == "" { | 303 | if strings.TrimSpace(v.Text) == "" { | ||
| 293 | continue | 304 | continue | ||
| @@ -306,6 +317,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart | |||||
| 306 | Name: v.Name, | 317 | Name: v.Name, | ||
| 307 | Args: args, | 318 | Args: args, | ||
| 308 | }, | 319 | }, | ||
| 320 | + | ThoughtSignature: v.ThoughtSignature, | |||
| 309 | }) | 321 | }) | ||
| 310 | } | 322 | } | ||
| 311 | } | 323 | } | ||
| @@ -508,13 +520,16 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 508 | // candidate carries a list of parts (text or functionCall), | 520 | // candidate carries a list of parts (text or functionCall), | ||
| 509 | // possibly accumulating across chunks. | 521 | // possibly accumulating across chunks. | ||
| 510 | type blockEntry struct { | 522 | type blockEntry struct { | ||
| 511 | - | kind string // "text" | "image" | "tool_use" | 523 | + | kind string // "text" | "image" | "tool_use" | "reasoning" |
| 512 | - | textBuf strings.Builder | 524 | + | textBuf strings.Builder |
| 513 | - | image *ImageBlock | 525 | + | image *ImageBlock |
| 514 | - | imagePath string | 526 | + | imagePath string |
| 515 | - | toolID string | 527 | + | toolID string |
| 516 | - | toolName string | 528 | + | toolName string |
| 517 | - | toolArgs strings.Builder | 529 | + | toolArgs strings.Builder |
| 530 | + | toolThoughtSig string | |||
| 531 | + | reasoningSummary string | |||
| 532 | + | reasoningSignature string | |||
| 518 | } | 533 | } | ||
| 519 | var ( | 534 | var ( | ||
| 520 | blocks []*blockEntry | 535 | blocks []*blockEntry | ||
| @@ -580,6 +595,11 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 580 | content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)}) | 595 | content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)}) | ||
| 581 | } | 596 | } | ||
| 582 | } | 597 | } | ||
| 598 | + | case "reasoning": | |||
| 599 | + | content = append(content, ReasoningBlock{ | |||
| 600 | + | Summary: b.reasoningSummary, | |||
| 601 | + | Encrypted: b.reasoningSignature, | |||
| 602 | + | }) | |||
| 583 | case "tool_use": | 603 | case "tool_use": | ||
| 584 | args := b.toolArgs.String() | 604 | args := b.toolArgs.String() | ||
| 585 | if args == "" || !json.Valid([]byte(args)) { | 605 | if args == "" || !json.Valid([]byte(args)) { | ||
| @@ -587,6 +607,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 587 | } | 607 | } | ||
| 588 | content = append(content, ToolCallBlock{ | 608 | content = append(content, ToolCallBlock{ | ||
| 589 | ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), | 609 | ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), | ||
| 610 | + | ThoughtSignature: b.toolThoughtSig, | |||
| 590 | }) | 611 | }) | ||
| 591 | } | 612 | } | ||
| 592 | } | 613 | } | ||
| @@ -622,6 +643,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 622 | Text string `json:"text"` | 643 | Text string `json:"text"` | ||
| 623 | InlineData *gemInlineData `json:"inlineData"` | 644 | InlineData *gemInlineData `json:"inlineData"` | ||
| 624 | Thought bool `json:"thought"` | 645 | Thought bool `json:"thought"` | ||
| 646 | + | ThoughtSignature string `json:"thoughtSignature"` | |||
| 625 | FunctionCall *gemFunctionCall `json:"functionCall"` | 647 | FunctionCall *gemFunctionCall `json:"functionCall"` | ||
| 626 | FunctionCallID string `json:"id"` | 648 | FunctionCallID string `json:"id"` | ||
| 627 | FunctionCallName string `json:"name"` | 649 | FunctionCallName string `json:"name"` | ||
| @@ -665,6 +687,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 665 | args = json.RawMessage("{}") | 687 | args = json.RawMessage("{}") | ||
| 666 | } | 688 | } | ||
| 667 | t := startTool(part.FunctionCall.Name, part.FunctionCallID, args) | 689 | t := startTool(part.FunctionCall.Name, part.FunctionCallID, args) | ||
| 690 | + | t.toolThoughtSig = part.ThoughtSignature | |||
| 668 | out <- EventToolStart{ID: t.toolID, Name: t.toolName} | 691 | out <- EventToolStart{ID: t.toolID, Name: t.toolName} | ||
| 669 | out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()} | 692 | out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()} | ||
| 670 | out <- EventToolEnd{ID: t.toolID} | 693 | out <- EventToolEnd{ID: t.toolID} | ||
| @@ -674,10 +697,15 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 674 | continue | 697 | continue | ||
| 675 | } | 698 | } | ||
| 676 | if part.Thought { | 699 | if part.Thought { | ||
| 677 | - | // Thinking summaries arrive as text parts | 700 | + | // Store thought parts as ReasoningBlocks so |
| 678 | - | // with thought=true. Not surfaced to the | 701 | + | // Vertex AI can replay the thought_signature |
| 679 | - | // user in v1; could be exposed via a | 702 | + | // on follow-up turns (required by the API). |
| 680 | - | // future ReasoningBlock if useful. | 703 | + | blocks = append(blocks, &blockEntry{ |
| 704 | + | kind: "reasoning", | |||
| 705 | + | reasoningSummary: part.Text, | |||
| 706 | + | reasoningSignature: part.ThoughtSignature, | |||
| 707 | + | }) | |||
| 708 | + | currentText = nil | |||
| 681 | continue | 709 | continue | ||
| 682 | } | 710 | } | ||
| 683 | appendText(part.Text) | 711 | appendText(part.Text) | ||
M packages/provider/gemini_test.go +186 -0
| @@ -375,3 +375,189 @@ func TestDiscoverGoogle(t *testing.T) { | |||
| 375 | 375 | t.Errorf("second model wrong: %+v", got[1]) | |
| 376 | 376 | } | |
| 377 | 377 | } | |
| 378 | + | ||
| 379 | + | // TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing | |
| 380 | + | // fallback behavior for empty/invalid input, to make sure the new logic | |
| 381 | + | // didn't regress it. | |
| 382 | + | func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) { | |
| 383 | + | for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} { | |
| 384 | + | out := sanitizeGeminiToolSchema(in) | |
| 385 | + | var got map[string]any | |
| 386 | + | if err := json.Unmarshal(out, &got); err != nil { | |
| 387 | + | t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out) | |
| 388 | + | } | |
| 389 | + | if got["type"] != "object" { | |
| 390 | + | t.Fatalf("want type=object fallback, got %+v", got) | |
| 391 | + | } | |
| 392 | + | if _, ok := got["properties"].(map[string]any); !ok { | |
| 393 | + | t.Fatalf("want properties object fallback, got %+v", got) | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // TestGeminiStreamReasoning confirms that when the Gemini SSE stream | |
| 399 | + | // contains thought blocks with a signature, they are correctly mapped to | |
| 400 | + | // ReasoningBlock structures inside the final assistant message. | |
| 401 | + | func TestGeminiStreamReasoning(t *testing.T) { | |
| 402 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 403 | + | w.Header().Set("content-type", "text/event-stream") | |
| 404 | + | w.WriteHeader(http.StatusOK) | |
| 405 | + | fl, _ := w.(http.Flusher) | |
| 406 | + | write := func(s string) { | |
| 407 | + | _, _ = w.Write([]byte(s)) | |
| 408 | + | if fl != nil { | |
| 409 | + | fl.Flush() | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | // Write a thought part with signature, then text, then usage containing thoughtsTokenCount. | |
| 413 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n") | |
| 414 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n") | |
| 415 | + | })) | |
| 416 | + | defer srv.Close() | |
| 417 | + | ||
| 418 | + | c := NewGemini("k", srv.URL) | |
| 419 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |
| 420 | + | if err != nil { | |
| 421 | + | t.Fatal(err) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | var gotText string | |
| 425 | + | var done EventDone | |
| 426 | + | var usage Usage | |
| 427 | + | for ev := range evs { | |
| 428 | + | switch e := ev.(type) { | |
| 429 | + | case EventTextDelta: | |
| 430 | + | gotText += e.Delta | |
| 431 | + | case EventUsage: | |
| 432 | + | usage = e.Usage | |
| 433 | + | case EventDone: | |
| 434 | + | done = e | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | if gotText != "Answer text" { | |
| 439 | + | t.Fatalf("text=%q", gotText) | |
| 440 | + | } | |
| 441 | + | if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3) | |
| 442 | + | t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens) | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // Verify that the final message contains both the ReasoningBlock and the TextBlock | |
| 446 | + | msg := done.Message | |
| 447 | + | if len(msg.Content) != 2 { | |
| 448 | + | t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | rb, ok := msg.Content[0].(ReasoningBlock) | |
| 452 | + | if !ok { | |
| 453 | + | t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0]) | |
| 454 | + | } | |
| 455 | + | if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" { | |
| 456 | + | t.Fatalf("invalid ReasoningBlock: %+v", rb) | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | tb, ok := msg.Content[1].(TextBlock) | |
| 460 | + | if !ok { | |
| 461 | + | t.Fatalf("second block should be TextBlock, got %T", msg.Content[1]) | |
| 462 | + | } | |
| 463 | + | if tb.Text != "Answer text" { | |
| 464 | + | t.Fatalf("invalid TextBlock: %+v", tb) | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a | |
| 469 | + | // historical assistant message containing a ReasoningBlock, buildRequest | |
| 470 | + | // serializes it into the verbatim Gemini "thought: true" wire representation | |
| 471 | + | // with its corresponding thoughtSignature. | |
| 472 | + | func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) { | |
| 473 | + | c := NewGemini("k", "https://example.invalid").(*geminiClient) | |
| 474 | + | wire, _, err := c.buildRequest(Request{ | |
| 475 | + | Model: "gemini-2.5-pro", | |
| 476 | + | Messages: []Message{ | |
| 477 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}}, | |
| 478 | + | {Role: RoleAssistant, Content: []Content{ | |
| 479 | + | ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"}, | |
| 480 | + | TextBlock{Text: "It scatters blue light more than other colors."}, | |
| 481 | + | }}, | |
| 482 | + | }, | |
| 483 | + | }) | |
| 484 | + | if err != nil { | |
| 485 | + | t.Fatal(err) | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | if len(wire.Contents) != 2 { | |
| 489 | + | t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents)) | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | assistant := wire.Contents[1] | |
| 493 | + | if assistant.Role != "model" { | |
| 494 | + | t.Fatalf("assistant role should be 'model', got %q", assistant.Role) | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | if len(assistant.Parts) != 2 { | |
| 498 | + | t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts) | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // Check the serialized ReasoningBlock | |
| 502 | + | p0 := assistant.Parts[0] | |
| 503 | + | if !p0.Thought { | |
| 504 | + | t.Errorf("part 0: expected Thought=true, got %+v", p0) | |
| 505 | + | } | |
| 506 | + | if p0.Text != "Evaluating Rayleigh scattering" { | |
| 507 | + | t.Errorf("part 0: expected text to match summary, got %q", p0.Text) | |
| 508 | + | } | |
| 509 | + | if p0.ThoughtSignature != "sky-sig-999" { | |
| 510 | + | t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature) | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // Check the serialized TextBlock | |
| 514 | + | p1 := assistant.Parts[1] | |
| 515 | + | if p1.Thought { | |
| 516 | + | t.Errorf("part 1: expected Thought=false, got %+v", p1) | |
| 517 | + | } | |
| 518 | + | if p1.Text != "It scatters blue light more than other colors." { | |
| 519 | + | t.Errorf("part 1: expected text to match, got %q", p1.Text) | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call | |
| 524 | + | // featuring a thoughtSignature in the SSE stream successfully extracts and | |
| 525 | + | // attaches the thought signature onto the final ToolCallBlock. | |
| 526 | + | func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) { | |
| 527 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 528 | + | w.Header().Set("content-type", "text/event-stream") | |
| 529 | + | w.WriteHeader(http.StatusOK) | |
| 530 | + | _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n")) | |
| 531 | + | })) | |
| 532 | + | defer srv.Close() | |
| 533 | + | ||
| 534 | + | c := NewGemini("k", srv.URL) | |
| 535 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |
| 536 | + | if err != nil { | |
| 537 | + | t.Fatal(err) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | var done EventDone | |
| 541 | + | for ev := range evs { | |
| 542 | + | if e, ok := ev.(EventDone); ok { | |
| 543 | + | done = e | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | if len(done.Message.Content) != 1 { | |
| 548 | + | t.Fatalf("want 1 content block, got %d", len(done.Message.Content)) | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | tc, ok := done.Message.Content[0].(ToolCallBlock) | |
| 552 | + | if !ok { | |
| 553 | + | t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0]) | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | if tc.Name != "read" { | |
| 557 | + | t.Fatalf("tool name=%q, want 'read'", tc.Name) | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | if tc.ThoughtSignature != "tool-sig-456" { | |
| 561 | + | t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature) | |
| 562 | + | } | |
| 563 | + | } | |
| @@ -375,3 +375,189 @@ func TestDiscoverGoogle(t *testing.T) { | |||||
| 375 | t.Errorf("second model wrong: %+v", got[1]) | 375 | t.Errorf("second model wrong: %+v", got[1]) | ||
| 376 | } | 376 | } | ||
| 377 | } | 377 | } | ||
| 378 | + | ||||
| 379 | + | // TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing | |||
| 380 | + | // fallback behavior for empty/invalid input, to make sure the new logic | |||
| 381 | + | // didn't regress it. | |||
| 382 | + | func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) { | |||
| 383 | + | for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} { | |||
| 384 | + | out := sanitizeGeminiToolSchema(in) | |||
| 385 | + | var got map[string]any | |||
| 386 | + | if err := json.Unmarshal(out, &got); err != nil { | |||
| 387 | + | t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out) | |||
| 388 | + | } | |||
| 389 | + | if got["type"] != "object" { | |||
| 390 | + | t.Fatalf("want type=object fallback, got %+v", got) | |||
| 391 | + | } | |||
| 392 | + | if _, ok := got["properties"].(map[string]any); !ok { | |||
| 393 | + | t.Fatalf("want properties object fallback, got %+v", got) | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // TestGeminiStreamReasoning confirms that when the Gemini SSE stream | |||
| 399 | + | // contains thought blocks with a signature, they are correctly mapped to | |||
| 400 | + | // ReasoningBlock structures inside the final assistant message. | |||
| 401 | + | func TestGeminiStreamReasoning(t *testing.T) { | |||
| 402 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 403 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 404 | + | w.WriteHeader(http.StatusOK) | |||
| 405 | + | fl, _ := w.(http.Flusher) | |||
| 406 | + | write := func(s string) { | |||
| 407 | + | _, _ = w.Write([]byte(s)) | |||
| 408 | + | if fl != nil { | |||
| 409 | + | fl.Flush() | |||
| 410 | + | } | |||
| 411 | + | } | |||
| 412 | + | // Write a thought part with signature, then text, then usage containing thoughtsTokenCount. | |||
| 413 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n") | |||
| 414 | + | write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n") | |||
| 415 | + | })) | |||
| 416 | + | defer srv.Close() | |||
| 417 | + | ||||
| 418 | + | c := NewGemini("k", srv.URL) | |||
| 419 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |||
| 420 | + | if err != nil { | |||
| 421 | + | t.Fatal(err) | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | var gotText string | |||
| 425 | + | var done EventDone | |||
| 426 | + | var usage Usage | |||
| 427 | + | for ev := range evs { | |||
| 428 | + | switch e := ev.(type) { | |||
| 429 | + | case EventTextDelta: | |||
| 430 | + | gotText += e.Delta | |||
| 431 | + | case EventUsage: | |||
| 432 | + | usage = e.Usage | |||
| 433 | + | case EventDone: | |||
| 434 | + | done = e | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | if gotText != "Answer text" { | |||
| 439 | + | t.Fatalf("text=%q", gotText) | |||
| 440 | + | } | |||
| 441 | + | if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3) | |||
| 442 | + | t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens) | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | // Verify that the final message contains both the ReasoningBlock and the TextBlock | |||
| 446 | + | msg := done.Message | |||
| 447 | + | if len(msg.Content) != 2 { | |||
| 448 | + | t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content) | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | rb, ok := msg.Content[0].(ReasoningBlock) | |||
| 452 | + | if !ok { | |||
| 453 | + | t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0]) | |||
| 454 | + | } | |||
| 455 | + | if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" { | |||
| 456 | + | t.Fatalf("invalid ReasoningBlock: %+v", rb) | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | tb, ok := msg.Content[1].(TextBlock) | |||
| 460 | + | if !ok { | |||
| 461 | + | t.Fatalf("second block should be TextBlock, got %T", msg.Content[1]) | |||
| 462 | + | } | |||
| 463 | + | if tb.Text != "Answer text" { | |||
| 464 | + | t.Fatalf("invalid TextBlock: %+v", tb) | |||
| 465 | + | } | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | // TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a | |||
| 469 | + | // historical assistant message containing a ReasoningBlock, buildRequest | |||
| 470 | + | // serializes it into the verbatim Gemini "thought: true" wire representation | |||
| 471 | + | // with its corresponding thoughtSignature. | |||
| 472 | + | func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) { | |||
| 473 | + | c := NewGemini("k", "https://example.invalid").(*geminiClient) | |||
| 474 | + | wire, _, err := c.buildRequest(Request{ | |||
| 475 | + | Model: "gemini-2.5-pro", | |||
| 476 | + | Messages: []Message{ | |||
| 477 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}}, | |||
| 478 | + | {Role: RoleAssistant, Content: []Content{ | |||
| 479 | + | ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"}, | |||
| 480 | + | TextBlock{Text: "It scatters blue light more than other colors."}, | |||
| 481 | + | }}, | |||
| 482 | + | }, | |||
| 483 | + | }) | |||
| 484 | + | if err != nil { | |||
| 485 | + | t.Fatal(err) | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | if len(wire.Contents) != 2 { | |||
| 489 | + | t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents)) | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | assistant := wire.Contents[1] | |||
| 493 | + | if assistant.Role != "model" { | |||
| 494 | + | t.Fatalf("assistant role should be 'model', got %q", assistant.Role) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | if len(assistant.Parts) != 2 { | |||
| 498 | + | t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts) | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | // Check the serialized ReasoningBlock | |||
| 502 | + | p0 := assistant.Parts[0] | |||
| 503 | + | if !p0.Thought { | |||
| 504 | + | t.Errorf("part 0: expected Thought=true, got %+v", p0) | |||
| 505 | + | } | |||
| 506 | + | if p0.Text != "Evaluating Rayleigh scattering" { | |||
| 507 | + | t.Errorf("part 0: expected text to match summary, got %q", p0.Text) | |||
| 508 | + | } | |||
| 509 | + | if p0.ThoughtSignature != "sky-sig-999" { | |||
| 510 | + | t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature) | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | // Check the serialized TextBlock | |||
| 514 | + | p1 := assistant.Parts[1] | |||
| 515 | + | if p1.Thought { | |||
| 516 | + | t.Errorf("part 1: expected Thought=false, got %+v", p1) | |||
| 517 | + | } | |||
| 518 | + | if p1.Text != "It scatters blue light more than other colors." { | |||
| 519 | + | t.Errorf("part 1: expected text to match, got %q", p1.Text) | |||
| 520 | + | } | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call | |||
| 524 | + | // featuring a thoughtSignature in the SSE stream successfully extracts and | |||
| 525 | + | // attaches the thought signature onto the final ToolCallBlock. | |||
| 526 | + | func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) { | |||
| 527 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 528 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 529 | + | w.WriteHeader(http.StatusOK) | |||
| 530 | + | _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n")) | |||
| 531 | + | })) | |||
| 532 | + | defer srv.Close() | |||
| 533 | + | ||||
| 534 | + | c := NewGemini("k", srv.URL) | |||
| 535 | + | evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"}) | |||
| 536 | + | if err != nil { | |||
| 537 | + | t.Fatal(err) | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | var done EventDone | |||
| 541 | + | for ev := range evs { | |||
| 542 | + | if e, ok := ev.(EventDone); ok { | |||
| 543 | + | done = e | |||
| 544 | + | } | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | if len(done.Message.Content) != 1 { | |||
| 548 | + | t.Fatalf("want 1 content block, got %d", len(done.Message.Content)) | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | tc, ok := done.Message.Content[0].(ToolCallBlock) | |||
| 552 | + | if !ok { | |||
| 553 | + | t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0]) | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | if tc.Name != "read" { | |||
| 557 | + | t.Fatalf("tool name=%q, want 'read'", tc.Name) | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | if tc.ThoughtSignature != "tool-sig-456" { | |||
| 561 | + | t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature) | |||
| 562 | + | } | |||
| 563 | + | } | |||
M packages/provider/provider.go +6 -3
| @@ -43,9 +43,12 @@ func (ImageBlock) isContent() {} | |||
| 43 | 43 | ||
| 44 | 44 | // ToolCallBlock is an assistant-issued call to a tool. | |
| 45 | 45 | type ToolCallBlock struct { | |
| 46 | - | ID string `json:"id"` | |
| 47 | - | Name string `json:"name"` | |
| 48 | - | Arguments json.RawMessage `json:"arguments"` | |
| 46 | + | ID string `json:"id"` | |
| 47 | + | Name string `json:"name"` | |
| 48 | + | Arguments json.RawMessage `json:"arguments"` | |
| 49 | + | // ThoughtSignature carries the Vertex AI thought_signature that must | |
| 50 | + | // be replayed on the functionCall part in follow-up turns. | |
| 51 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 51 | 54 | func (ToolCallBlock) isContent() {} | |
| @@ -43,9 +43,12 @@ func (ImageBlock) isContent() {} | |||||
| 43 | 43 | ||||
| 44 | // ToolCallBlock is an assistant-issued call to a tool. | 44 | // ToolCallBlock is an assistant-issued call to a tool. | ||
| 45 | type ToolCallBlock struct { | 45 | type ToolCallBlock struct { | ||
| 46 | - | ID string `json:"id"` | 46 | + | ID string `json:"id"` |
| 47 | - | Name string `json:"name"` | 47 | + | Name string `json:"name"` |
| 48 | - | Arguments json.RawMessage `json:"arguments"` | 48 | + | Arguments json.RawMessage `json:"arguments"` |
| 49 | + | // ThoughtSignature carries the Vertex AI thought_signature that must | |||
| 50 | + | // be replayed on the functionCall part in follow-up turns. | |||
| 51 | + | ThoughtSignature string `json:"thought_signature,omitempty"` | |||
| 49 | } | 52 | } | ||
| 50 | 53 | ||||
| 51 | func (ToolCallBlock) isContent() {} | 54 | func (ToolCallBlock) isContent() {} | ||