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 协议的错误编码。