From 38a2555c7b5772d7f783ab1bf237d65d54f6be27 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 26 Apr 2026 23:27:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Anthropic=20=E6=B5=81=E5=BC=8F=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E5=99=A8=E8=A1=A5=E5=85=A8=20message=5Fstart/message?= =?UTF-8?q?=5Fdelta=20=E5=BF=85=E5=A1=AB=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨协议流式转换时,Anthropic 客户端 Zod 校验因 SSE 事件缺少必填字段报错。 由 Anthropic encoder 层(而非 OpenAI decoder 层)负责补全协议默认值,保持权责分离。 - encodeMessageStart 补全 type/content/stop_reason/stop_sequence,usage nil 时输出零值 - encodeMessageDelta usage nil 时输出零值 - 更新相关测试覆盖新增行为 --- .../conversion/anthropic/stream_encoder.go | 22 ++++++-- .../anthropic/stream_encoder_test.go | 55 ++++++++++++++++++- .../specs/protocol-adapter-anthropic/spec.md | 20 +++++++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/backend/internal/conversion/anthropic/stream_encoder.go b/backend/internal/conversion/anthropic/stream_encoder.go index 002dfe4..de81874 100644 --- a/backend/internal/conversion/anthropic/stream_encoder.go +++ b/backend/internal/conversion/anthropic/stream_encoder.go @@ -50,16 +50,24 @@ func (e *StreamEncoder) encodeMessageStart(event canonical.CanonicalStreamEvent) } if event.Message != nil { msg := map[string]any{ - "id": event.Message.ID, - "model": event.Message.Model, - "role": "assistant", + "id": event.Message.ID, + "type": "message", + "role": "assistant", + "content": []any{}, + "model": event.Message.Model, + "stop_reason": nil, + "stop_sequence": nil, } if event.Message.Usage != nil { - usage := map[string]any{ + msg["usage"] = map[string]any{ "input_tokens": event.Message.Usage.InputTokens, "output_tokens": event.Message.Usage.OutputTokens, } - msg["usage"] = usage + } else { + msg["usage"] = map[string]any{ + "input_tokens": 0, + "output_tokens": 0, + } } payload["message"] = msg } @@ -147,6 +155,10 @@ func (e *StreamEncoder) encodeMessageDelta(event canonical.CanonicalStreamEvent) payload["usage"] = map[string]any{ "output_tokens": event.Usage.OutputTokens, } + } else { + payload["usage"] = map[string]any{ + "output_tokens": 0, + } } return e.marshalEvent("message_delta", payload) } diff --git a/backend/internal/conversion/anthropic/stream_encoder_test.go b/backend/internal/conversion/anthropic/stream_encoder_test.go index e76c5c4..50621e8 100644 --- a/backend/internal/conversion/anthropic/stream_encoder_test.go +++ b/backend/internal/conversion/anthropic/stream_encoder_test.go @@ -21,8 +21,55 @@ func TestStreamEncoder_MessageStart(t *testing.T) { s := string(chunks[0]) assert.True(t, strings.HasPrefix(s, "event: message_start\n")) assert.Contains(t, s, "data: ") - assert.Contains(t, s, "msg_1") - assert.Contains(t, s, "claude-3") + + var payload map[string]any + lines := strings.Split(s, "\n") + for _, l := range lines { + if strings.HasPrefix(l, "data: ") { + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &payload)) + break + } + } + + msg, ok := payload["message"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "msg_1", msg["id"]) + assert.Equal(t, "message", msg["type"]) + assert.Equal(t, "assistant", msg["role"]) + assert.Equal(t, []any{}, msg["content"]) + assert.Equal(t, "claude-3", msg["model"]) + assert.Nil(t, msg["stop_reason"]) + assert.Nil(t, msg["stop_sequence"]) + + usage, okU := msg["usage"].(map[string]any) + require.True(t, okU) + assert.Equal(t, float64(0), usage["input_tokens"]) + assert.Equal(t, float64(0), usage["output_tokens"]) +} + +func TestStreamEncoder_MessageStart_WithUsage(t *testing.T) { + e := NewStreamEncoder() + event := canonical.NewMessageStartEventWithUsage("msg_2", "gpt-4", &canonical.CanonicalUsage{InputTokens: 100, OutputTokens: 50}) + + chunks := e.EncodeEvent(event) + require.Len(t, chunks, 1) + + s := string(chunks[0]) + var payload map[string]any + lines := strings.Split(s, "\n") + for _, l := range lines { + if strings.HasPrefix(l, "data: ") { + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &payload)) + break + } + } + + msg, ok := payload["message"].(map[string]any) + require.True(t, ok) + usage, okU := msg["usage"].(map[string]any) + require.True(t, okU) + assert.Equal(t, float64(100), usage["input_tokens"]) + assert.Equal(t, float64(50), usage["output_tokens"]) } func TestStreamEncoder_ContentBlockDelta(t *testing.T) { @@ -179,6 +226,10 @@ func TestStreamEncoder_MessageDelta_WithStopReason(t *testing.T) { delta, okd := payload["delta"].(map[string]any) require.True(t, okd) assert.Equal(t, "end_turn", delta["stop_reason"]) + + usage, oku := payload["usage"].(map[string]any) + require.True(t, oku, "message_delta SHALL always include usage") + assert.Equal(t, float64(0), usage["output_tokens"]) } func TestStreamEncoder_MessageDelta_WithUsage(t *testing.T) { diff --git a/openspec/specs/protocol-adapter-anthropic/spec.md b/openspec/specs/protocol-adapter-anthropic/spec.md index c125cc6..eb8e27a 100644 --- a/openspec/specs/protocol-adapter-anthropic/spec.md +++ b/openspec/specs/protocol-adapter-anthropic/spec.md @@ -239,6 +239,26 @@ Decoder 几乎 1:1 映射,维护最小状态机: - **WHEN** delta.type == "thinking_delta" - **THEN** SHALL 编码为 Anthropic thinking_delta +#### Scenario: message_start 事件编码完整 message 字段 + +- **WHEN** 编码 MessageStartEvent +- **THEN** SHALL 输出 `message` 对象包含以下字段: + - `id`: 来自 event.Message.ID + - `type`: 固定值 `"message"` + - `role`: 固定值 `"assistant"` + - `content`: 固定值 `[]`(空数组) + - `model`: 来自 event.Message.Model + - `stop_reason`: 固定值 `null` + - `stop_sequence`: 固定值 `null` + - `usage`: 来自 event.Message.Usage;若 Usage 为 nil,SHALL 输出 `{"input_tokens": 0, "output_tokens": 0}` + +#### Scenario: message_delta 事件编码包含 usage 字段 + +- **WHEN** 编码 MessageDeltaEvent +- **THEN** SHALL 输出 `usage` 字段 +- **WHEN** event.Usage 为 nil +- **THEN** SHALL 输出 `{"output_tokens": 0}` + ### Requirement: Anthropic 错误编码 系统 SHALL 实现 Anthropic 协议的错误编码。