fix: Anthropic 流式编码器补全 message_start/message_delta 必填字段
跨协议流式转换时,Anthropic 客户端 Zod 校验因 SSE 事件缺少必填字段报错。 由 Anthropic encoder 层(而非 OpenAI decoder 层)负责补全协议默认值,保持权责分离。 - encodeMessageStart 补全 type/content/stop_reason/stop_sequence,usage nil 时输出零值 - encodeMessageDelta usage nil 时输出零值 - 更新相关测试覆盖新增行为
This commit is contained in:
@@ -50,16 +50,24 @@ func (e *StreamEncoder) encodeMessageStart(event canonical.CanonicalStreamEvent)
|
|||||||
}
|
}
|
||||||
if event.Message != nil {
|
if event.Message != nil {
|
||||||
msg := map[string]any{
|
msg := map[string]any{
|
||||||
"id": event.Message.ID,
|
"id": event.Message.ID,
|
||||||
"model": event.Message.Model,
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
|
"content": []any{},
|
||||||
|
"model": event.Message.Model,
|
||||||
|
"stop_reason": nil,
|
||||||
|
"stop_sequence": nil,
|
||||||
}
|
}
|
||||||
if event.Message.Usage != nil {
|
if event.Message.Usage != nil {
|
||||||
usage := map[string]any{
|
msg["usage"] = map[string]any{
|
||||||
"input_tokens": event.Message.Usage.InputTokens,
|
"input_tokens": event.Message.Usage.InputTokens,
|
||||||
"output_tokens": event.Message.Usage.OutputTokens,
|
"output_tokens": event.Message.Usage.OutputTokens,
|
||||||
}
|
}
|
||||||
msg["usage"] = usage
|
} else {
|
||||||
|
msg["usage"] = map[string]any{
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
payload["message"] = msg
|
payload["message"] = msg
|
||||||
}
|
}
|
||||||
@@ -147,6 +155,10 @@ func (e *StreamEncoder) encodeMessageDelta(event canonical.CanonicalStreamEvent)
|
|||||||
payload["usage"] = map[string]any{
|
payload["usage"] = map[string]any{
|
||||||
"output_tokens": event.Usage.OutputTokens,
|
"output_tokens": event.Usage.OutputTokens,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
payload["usage"] = map[string]any{
|
||||||
|
"output_tokens": 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return e.marshalEvent("message_delta", payload)
|
return e.marshalEvent("message_delta", payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,55 @@ func TestStreamEncoder_MessageStart(t *testing.T) {
|
|||||||
s := string(chunks[0])
|
s := string(chunks[0])
|
||||||
assert.True(t, strings.HasPrefix(s, "event: message_start\n"))
|
assert.True(t, strings.HasPrefix(s, "event: message_start\n"))
|
||||||
assert.Contains(t, s, "data: ")
|
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) {
|
func TestStreamEncoder_ContentBlockDelta(t *testing.T) {
|
||||||
@@ -179,6 +226,10 @@ func TestStreamEncoder_MessageDelta_WithStopReason(t *testing.T) {
|
|||||||
delta, okd := payload["delta"].(map[string]any)
|
delta, okd := payload["delta"].(map[string]any)
|
||||||
require.True(t, okd)
|
require.True(t, okd)
|
||||||
assert.Equal(t, "end_turn", delta["stop_reason"])
|
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) {
|
func TestStreamEncoder_MessageDelta_WithUsage(t *testing.T) {
|
||||||
|
|||||||
@@ -239,6 +239,26 @@ Decoder 几乎 1:1 映射,维护最小状态机:
|
|||||||
- **WHEN** delta.type == "thinking_delta"
|
- **WHEN** delta.type == "thinking_delta"
|
||||||
- **THEN** SHALL 编码为 Anthropic 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 错误编码
|
### Requirement: Anthropic 错误编码
|
||||||
|
|
||||||
系统 SHALL 实现 Anthropic 协议的错误编码。
|
系统 SHALL 实现 Anthropic 协议的错误编码。
|
||||||
|
|||||||
Reference in New Issue
Block a user