diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index 23601c0457..61e32b615d 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -122,6 +122,20 @@ type ClaudeContentItem struct { Input any `json:"input,omitempty"` } +// MarshalJSON 保证 thinking 块始终带 thinking/signature 字段(即使为空), +// 与 Anthropic 官方一致;Grok Build 等严格客户端缺少 signature 会解析失败。 +func (c ClaudeContentItem) MarshalJSON() ([]byte, error) { + type claudeContentItem ClaudeContentItem + if c.Type != "thinking" { + return json.Marshal(claudeContentItem(c)) + } + return json.Marshal(struct { + Thinking string `json:"thinking"` + Signature string `json:"signature"` + claudeContentItem + }{Thinking: c.Thinking, Signature: c.Signature, claudeContentItem: claudeContentItem(c)}) +} + // ClaudeUsage Claude 用量统计 type ClaudeUsage struct { InputTokens int `json:"input_tokens"` diff --git a/backend/internal/pkg/antigravity/claude_types_test.go b/backend/internal/pkg/antigravity/claude_types_test.go index 4b49fdb9b1..1aa5bca312 100644 --- a/backend/internal/pkg/antigravity/claude_types_test.go +++ b/backend/internal/pkg/antigravity/claude_types_test.go @@ -1,6 +1,9 @@ package antigravity -import "testing" +import ( + "encoding/json" + "testing" +) func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) { t.Parallel() @@ -44,3 +47,23 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) { } } } + +func TestClaudeContentItem_ThinkingAlwaysHasSignature(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(ClaudeContentItem{Type: "thinking", Thinking: "plan"}) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), `{"thinking":"plan","signature":"","type":"thinking"}`; got != want { + t.Fatalf("thinking block = %s, want %s", got, want) + } + + data, err = json.Marshal(ClaudeContentItem{Type: "text", Text: "hi"}) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), `{"type":"text","text":"hi"}`; got != want { + t.Fatalf("text block = %s, want %s", got, want) + } +} diff --git a/backend/internal/pkg/antigravity/stream_transformer.go b/backend/internal/pkg/antigravity/stream_transformer.go index 13471ddddc..faecf233ec 100644 --- a/backend/internal/pkg/antigravity/stream_transformer.go +++ b/backend/internal/pkg/antigravity/stream_transformer.go @@ -296,8 +296,9 @@ func (p *StreamingProcessor) processThinking(text, signature string) []byte { // 开始或继续 thinking 块 if p.blockType != BlockTypeThinking { _, _ = result.Write(p.startBlock(BlockTypeThinking, map[string]any{ - "type": "thinking", - "thinking": "", + "type": "thinking", + "thinking": "", + "signature": "", })) } @@ -475,8 +476,9 @@ func (p *StreamingProcessor) emitEmptyThinkingWithSignature(signature string) [] var result bytes.Buffer _, _ = result.Write(p.startBlock(BlockTypeThinking, map[string]any{ - "type": "thinking", - "thinking": "", + "type": "thinking", + "thinking": "", + "signature": "", })) _, _ = result.Write(p.emitDelta("thinking_delta", map[string]any{ "thinking": "", diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 63d355eb78..5902128b97 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -844,7 +844,7 @@ func TestStreamingReasoning(t *testing.T) { sse, err := ResponsesAnthropicEventToSSE(events[0]) require.NoError(t, err) - assert.Contains(t, sse, `"content_block":{"thinking":"","type":"thinking"}`) + assert.Contains(t, sse, `"content_block":{"thinking":"","signature":"","type":"thinking"}`) // reasoning text delta events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ diff --git a/backend/internal/pkg/apicompat/thinking_signature_wire_test.go b/backend/internal/pkg/apicompat/thinking_signature_wire_test.go new file mode 100644 index 0000000000..33924fd498 --- /dev/null +++ b/backend/internal/pkg/apicompat/thinking_signature_wire_test.go @@ -0,0 +1,24 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// Thinking blocks must always carry the `signature` key (Anthropic wire shape); +// strict clients such as Grok Build fail with "missing field `signature`". +func TestAnthropicContentBlock_ThinkingAlwaysHasSignature(t *testing.T) { + data, err := json.Marshal(AnthropicContentBlock{Type: "thinking", Thinking: "plan"}) + require.NoError(t, err) + require.JSONEq(t, `{"type":"thinking","thinking":"plan","signature":""}`, string(data)) + + data, err = json.Marshal(AnthropicContentBlock{Type: "thinking", Signature: "sig"}) + require.NoError(t, err) + require.JSONEq(t, `{"type":"thinking","thinking":"","signature":"sig"}`, string(data)) + + data, err = json.Marshal(AnthropicContentBlock{Type: "text", Text: "hi"}) + require.NoError(t, err) + require.JSONEq(t, `{"type":"text","text":"hi"}`, string(data)) +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index c0a833f107..b5864f2f2f 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -95,10 +95,14 @@ func (b AnthropicContentBlock) MarshalJSON() ([]byte, error) { anthropicContentBlock }{Text: b.Text, anthropicContentBlock: anthropicContentBlock(b)}) case "thinking": + // Anthropic always sends `signature` on thinking blocks (empty on + // content_block_start); strict clients such as Grok Build reject the + // block with "missing field `signature`" when the key is absent. return json.Marshal(struct { - Thinking string `json:"thinking"` + Thinking string `json:"thinking"` + Signature string `json:"signature"` anthropicContentBlock - }{Thinking: b.Thinking, anthropicContentBlock: anthropicContentBlock(b)}) + }{Thinking: b.Thinking, Signature: b.Signature, anthropicContentBlock: anthropicContentBlock(b)}) default: return json.Marshal(base) }