claude code tools error
This commit is contained in:
@@ -202,6 +202,51 @@ func TestChatToMessagesResp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResponsesToMessagesRespToolCall 非流式 Responses 响应 → Messages 响应(经 chat 中转)。
|
||||||
|
func TestResponsesToMessagesRespToolCall(t *testing.T) {
|
||||||
|
in := `{"id":"resp_abc","object":"response","model":"glm-4","status":"completed",
|
||||||
|
"output":[
|
||||||
|
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我来查询"}]},
|
||||||
|
{"type":"function_call","id":"fc_1","call_id":"call_9","name":"get_weather","arguments":"{\"city\":\"北京\"}"}
|
||||||
|
],
|
||||||
|
"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}`
|
||||||
|
out, err := ConvertResponse([]byte(in), ProtoResponses, ProtoMessages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(out, &m)
|
||||||
|
if m["stop_reason"] != "tool_use" {
|
||||||
|
t.Fatalf("stop_reason = %v", m["stop_reason"])
|
||||||
|
}
|
||||||
|
if m["model"] != "glm-4" {
|
||||||
|
t.Fatalf("model = %v", m["model"])
|
||||||
|
}
|
||||||
|
content := m["content"].([]any)
|
||||||
|
var text, tool string
|
||||||
|
for _, b := range content {
|
||||||
|
bm := b.(map[string]any)
|
||||||
|
switch bm["type"] {
|
||||||
|
case "text":
|
||||||
|
text, _ = bm["text"].(string)
|
||||||
|
case "tool_use":
|
||||||
|
tool = "y"
|
||||||
|
if bm["id"] != "call_9" || bm["name"] != "get_weather" {
|
||||||
|
t.Fatalf("tool block = %v", bm)
|
||||||
|
}
|
||||||
|
if in, _ := bm["input"].(map[string]any); in["city"] != "北京" {
|
||||||
|
t.Fatalf("tool input = %v", bm["input"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text != "我来查询" {
|
||||||
|
t.Fatalf("text = %q", text)
|
||||||
|
}
|
||||||
|
if tool != "y" {
|
||||||
|
t.Fatal("expected tool_use block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 流式转换
|
// 流式转换
|
||||||
|
|
||||||
@@ -549,3 +594,431 @@ func TestMessagesToResponsesReqImage(t *testing.T) {
|
|||||||
t.Fatalf("input_image = %v", img)
|
t.Fatalf("input_image = %v", img)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 流式工具调用转换(chat tool_calls / messages tool_use / responses function_call 互转不丢)
|
||||||
|
|
||||||
|
// sseEvent 一条解析出的 SSE 事件(dataLine 无 event 行时 typ 为空串)。
|
||||||
|
type sseEvent struct {
|
||||||
|
typ string
|
||||||
|
data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSSE 把转换器输出拆成事件列表。
|
||||||
|
func parseSSE(t *testing.T, s string) []sseEvent {
|
||||||
|
t.Helper()
|
||||||
|
var evs []sseEvent
|
||||||
|
cur := ""
|
||||||
|
for _, ln := range strings.Split(s, "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ln, "event: "):
|
||||||
|
cur = strings.TrimSpace(strings.TrimPrefix(ln, "event: "))
|
||||||
|
case strings.HasPrefix(ln, "data: "):
|
||||||
|
d := strings.TrimPrefix(ln, "data: ")
|
||||||
|
if d == "[DONE]" {
|
||||||
|
evs = append(evs, sseEvent{typ: "[DONE]"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(d), &m); err != nil {
|
||||||
|
t.Fatalf("parse sse data: %v\n%s", err, d)
|
||||||
|
}
|
||||||
|
evs = append(evs, sseEvent{typ: cur, data: m})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return evs
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSSE 返回第一个 typ 匹配且(index>=0 时)index 字段相符的事件。
|
||||||
|
func findSSE(evs []sseEvent, typ string, index int) *sseEvent {
|
||||||
|
for i := range evs {
|
||||||
|
if evs[i].typ != typ {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if index >= 0 {
|
||||||
|
if idx, ok := evs[i].data["index"].(float64); !ok || int(idx) != index {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &evs[i]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputJSONDeltas 汇总所有 input_json_delta:index → 拼接后的 partial_json。
|
||||||
|
func inputJSONDeltas(evs []sseEvent) map[int]string {
|
||||||
|
out := map[int]string{}
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.typ != "content_block_delta" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, _ := e.data["delta"].(map[string]any)
|
||||||
|
if d == nil || d["type"] != "input_json_delta" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, _ := e.data["index"].(float64)
|
||||||
|
p, _ := d["partial_json"].(string)
|
||||||
|
out[int(idx)] += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamChatToMessagesToolCall(t *testing.T) {
|
||||||
|
tf := newChatToMessages().line
|
||||||
|
out := feedLines(t, tf, []string{
|
||||||
|
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"用户"}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"content":"我来帮您查询"}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_dbcef6a399ad47bb973fa83a","index":0,"type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":167,"completion_tokens":48}}` + "\n\n",
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
})
|
||||||
|
evs := parseSSE(t, out)
|
||||||
|
|
||||||
|
// message_start
|
||||||
|
if ms := findSSE(evs, "message_start", -1); ms == nil {
|
||||||
|
t.Fatal("missing message_start")
|
||||||
|
}
|
||||||
|
// 文本块 index 0
|
||||||
|
if cs := findSSE(evs, "content_block_start", 0); cs == nil {
|
||||||
|
t.Fatal("missing text content_block_start")
|
||||||
|
} else if cb := cs.data["content_block"].(map[string]any); cb["type"] != "text" {
|
||||||
|
t.Fatalf("block 0 type = %v", cb["type"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"text":"我来帮您查询"`) {
|
||||||
|
t.Fatalf("missing text delta: %s", out)
|
||||||
|
}
|
||||||
|
// 工具块 index 1
|
||||||
|
cs := findSSE(evs, "content_block_start", 1)
|
||||||
|
if cs == nil {
|
||||||
|
t.Fatal("missing tool content_block_start")
|
||||||
|
}
|
||||||
|
cb := cs.data["content_block"].(map[string]any)
|
||||||
|
if cb["type"] != "tool_use" || cb["id"] != "call_dbcef6a399ad47bb973fa83a" || cb["name"] != "get_weather" {
|
||||||
|
t.Fatalf("tool block = %v", cb)
|
||||||
|
}
|
||||||
|
if input, ok := cb["input"].(map[string]any); !ok || len(input) != 0 {
|
||||||
|
t.Fatalf("tool input should be empty object, got %v", cb["input"])
|
||||||
|
}
|
||||||
|
// input_json_delta 整段 args
|
||||||
|
if d := inputJSONDeltas(evs)[1]; d != `{"city":"北京"}` {
|
||||||
|
t.Fatalf("tool args = %q", d)
|
||||||
|
}
|
||||||
|
// 两个块都有 stop
|
||||||
|
if findSSE(evs, "content_block_stop", 0) == nil || findSSE(evs, "content_block_stop", 1) == nil {
|
||||||
|
t.Fatalf("missing per-block stops: %s", out)
|
||||||
|
}
|
||||||
|
// stop_reason tool_use
|
||||||
|
if md := findSSE(evs, "message_delta", -1); md == nil {
|
||||||
|
t.Fatal("missing message_delta")
|
||||||
|
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
|
||||||
|
t.Fatalf("stop_reason = %v", d["stop_reason"])
|
||||||
|
}
|
||||||
|
if findSSE(evs, "message_stop", -1) == nil {
|
||||||
|
t.Fatal("missing message_stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamChatToMessagesToolOnly(t *testing.T) {
|
||||||
|
tf := newChatToMessages().line
|
||||||
|
out := feedLines(t, tf, []string{
|
||||||
|
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"查询"}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","index":0,"type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}]}` + "\n\n",
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
})
|
||||||
|
evs := parseSSE(t, out)
|
||||||
|
|
||||||
|
// 首块即 tool_use index 0
|
||||||
|
cs := findSSE(evs, "content_block_start", 0)
|
||||||
|
if cs == nil {
|
||||||
|
t.Fatal("missing first block")
|
||||||
|
}
|
||||||
|
if cb := cs.data["content_block"].(map[string]any); cb["type"] != "tool_use" {
|
||||||
|
t.Fatalf("first block type = %v", cb["type"])
|
||||||
|
}
|
||||||
|
// 不应出现空文本块
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.typ == "content_block_start" {
|
||||||
|
if cb := e.data["content_block"].(map[string]any); cb["type"] == "text" {
|
||||||
|
t.Fatalf("unexpected text block: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 单个 stop
|
||||||
|
if findSSE(evs, "content_block_stop", 0) == nil {
|
||||||
|
t.Fatal("missing stop 0")
|
||||||
|
}
|
||||||
|
if findSSE(evs, "content_block_stop", 1) != nil {
|
||||||
|
t.Fatalf("unexpected stop 1: %s", out)
|
||||||
|
}
|
||||||
|
if md := findSSE(evs, "message_delta", -1); md == nil {
|
||||||
|
t.Fatal("missing message_delta")
|
||||||
|
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
|
||||||
|
t.Fatalf("stop_reason = %v", d["stop_reason"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamChatToMessagesParallelTools(t *testing.T) {
|
||||||
|
tf := newChatToMessages().line
|
||||||
|
out := feedLines(t, tf, []string{
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_0","index":0,"type":"function","function":{"name":"search","arguments":""}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","index":1,"type":"function","function":{"name":"read","arguments":""}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":"}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"path\":"}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"weather\"}"}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"a.txt\"}"}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"finish_reason":"tool_calls","delta":{}}]}` + "\n\n",
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
})
|
||||||
|
evs := parseSSE(t, out)
|
||||||
|
|
||||||
|
// 块 0 = search,块 1 = read(按首现顺序分配)
|
||||||
|
if cb := findSSE(evs, "content_block_start", 0); cb == nil {
|
||||||
|
t.Fatal("missing block 0")
|
||||||
|
} else if n := cb.data["content_block"].(map[string]any)["name"]; n != "search" {
|
||||||
|
t.Fatalf("block 0 name = %v", n)
|
||||||
|
}
|
||||||
|
if cb := findSSE(evs, "content_block_start", 1); cb == nil {
|
||||||
|
t.Fatal("missing block 1")
|
||||||
|
} else if n := cb.data["content_block"].(map[string]any)["name"]; n != "read" {
|
||||||
|
t.Fatalf("block 1 name = %v", n)
|
||||||
|
}
|
||||||
|
// 参数分段正确路由
|
||||||
|
deltas := inputJSONDeltas(evs)
|
||||||
|
if deltas[0] != `{"query":"weather"}` {
|
||||||
|
t.Fatalf("block 0 args = %q", deltas[0])
|
||||||
|
}
|
||||||
|
if deltas[1] != `{"path":"a.txt"}` {
|
||||||
|
t.Fatalf("block 1 args = %q", deltas[1])
|
||||||
|
}
|
||||||
|
if findSSE(evs, "content_block_stop", 0) == nil || findSSE(evs, "content_block_stop", 1) == nil {
|
||||||
|
t.Fatalf("missing stops: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamChatToMessagesTextAfterTool(t *testing.T) {
|
||||||
|
tf := newChatToMessages().line
|
||||||
|
out := feedLines(t, tf, []string{
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_1","index":0,"type":"function","function":{"name":"get_weather","arguments":""}}]}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"content":"天气查询中"}}]}` + "\n\n",
|
||||||
|
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"finish_reason":"stop","delta":{}}]}` + "\n\n",
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
})
|
||||||
|
evs := parseSSE(t, out)
|
||||||
|
|
||||||
|
// 工具块 index 0、文本块 index 1
|
||||||
|
if cb := findSSE(evs, "content_block_start", 0); cb == nil {
|
||||||
|
t.Fatal("missing tool block")
|
||||||
|
} else if cb.data["content_block"].(map[string]any)["type"] != "tool_use" {
|
||||||
|
t.Fatalf("block 0 type = %v", cb.data["content_block"].(map[string]any)["type"])
|
||||||
|
}
|
||||||
|
if cb := findSSE(evs, "content_block_start", 1); cb == nil {
|
||||||
|
t.Fatal("missing text block")
|
||||||
|
} else if cb.data["content_block"].(map[string]any)["type"] != "text" {
|
||||||
|
t.Fatalf("block 1 type = %v", cb.data["content_block"].(map[string]any)["type"])
|
||||||
|
}
|
||||||
|
// stop 顺序 0,1
|
||||||
|
var stops []int
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.typ == "content_block_stop" {
|
||||||
|
idx, _ := e.data["index"].(float64)
|
||||||
|
stops = append(stops, int(idx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stops) != 2 || stops[0] != 0 || stops[1] != 1 {
|
||||||
|
t.Fatalf("stop order = %v", stops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMessagesToChatToolUse(t *testing.T) {
|
||||||
|
tf := newMessagesToChat().line
|
||||||
|
out := feedLines(t, tf, []string{
|
||||||
|
"event: message_start\n",
|
||||||
|
`data: {"type":"message_start","message":{"id":"msg_1","model":"glm-4.7-flash"}}` + "\n\n",
|
||||||
|
"event: content_block_delta\n",
|
||||||
|
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}` + "\n\n",
|
||||||
|
"event: content_block_start\n",
|
||||||
|
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_x","name":"get_weather","input":{}}}` + "\n\n",
|
||||||
|
"event: content_block_delta\n",
|
||||||
|
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\""}}` + "\n\n",
|
||||||
|
"event: content_block_delta\n",
|
||||||
|
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"city\":\"北京\"}"}}` + "\n\n",
|
||||||
|
"event: content_block_stop\n",
|
||||||
|
`data: {"type":"content_block_stop","index":1}` + "\n\n",
|
||||||
|
"event: message_delta\n",
|
||||||
|
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}` + "\n\n",
|
||||||
|
"event: message_stop\n",
|
||||||
|
`data: {"type":"message_stop"}` + "\n\n",
|
||||||
|
})
|
||||||
|
evs := parseSSE(t, out)
|
||||||
|
|
||||||
|
// 文本增量
|
||||||
|
if !strings.Contains(out, `"content":"你好"`) {
|
||||||
|
t.Fatalf("missing text chunk: %s", out)
|
||||||
|
}
|
||||||
|
// tool_use → chat tool_calls:messages 块 index 1 映射为 chat 工具索引 0
|
||||||
|
var toolStart, argsSeen, finishSeen bool
|
||||||
|
for _, e := range evs {
|
||||||
|
choices, _ := e.data["choices"].([]any)
|
||||||
|
if len(choices) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c0, _ := choices[0].(map[string]any)
|
||||||
|
delta, _ := c0["delta"].(map[string]any)
|
||||||
|
if tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 {
|
||||||
|
tc, _ := tcs[0].(map[string]any)
|
||||||
|
idx, _ := tc["index"].(float64)
|
||||||
|
if int(idx) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn, _ := tc["function"].(map[string]any)
|
||||||
|
if tc["id"] == "call_x" && fn["name"] == "get_weather" {
|
||||||
|
toolStart = true
|
||||||
|
}
|
||||||
|
if s, _ := fn["arguments"].(string); s != "" {
|
||||||
|
argsSeen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fr, _ := c0["finish_reason"].(string); fr == "tool_calls" {
|
||||||
|
finishSeen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !toolStart {
|
||||||
|
t.Fatalf("missing tool_calls start: %s", out)
|
||||||
|
}
|
||||||
|
if !argsSeen {
|
||||||
|
t.Fatalf("missing tool_calls arguments chunks: %s", out)
|
||||||
|
}
|
||||||
|
if !finishSeen {
|
||||||
|
t.Fatalf("missing finish_reason tool_calls: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "data: [DONE]") {
|
||||||
|
t.Fatalf("missing [DONE]: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponsesToMessagesFunctionCall(t *testing.T) {
|
||||||
|
tf := newResponsesToMessages().line
|
||||||
|
out := feedLines(t, tf, []string{
|
||||||
|
"event: response.created\n",
|
||||||
|
`data: {"type":"response.created","response":{"id":"resp_1","model":"glm-4.7-flash"}}` + "\n\n",
|
||||||
|
"event: response.output_item.added\n",
|
||||||
|
`data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_call_1","call_id":"call_1","name":"get_weather","arguments":"","status":"in_progress"},"output_index":0}` + "\n\n",
|
||||||
|
"event: response.function_call_arguments.delta\n",
|
||||||
|
`data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_call_1"}` + "\n\n",
|
||||||
|
"event: response.function_call_arguments.delta\n",
|
||||||
|
`data: {"type":"response.function_call_arguments.delta","delta":"city\":\"北京\"}","item_id":"fc_call_1"}` + "\n\n",
|
||||||
|
"event: response.output_item.done\n",
|
||||||
|
`data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_call_1","arguments":"{\"city\":\"北京\"}","status":"completed"}}` + "\n\n",
|
||||||
|
"event: response.completed\n",
|
||||||
|
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n",
|
||||||
|
})
|
||||||
|
evs := parseSSE(t, out)
|
||||||
|
|
||||||
|
if findSSE(evs, "message_start", -1) == nil {
|
||||||
|
t.Fatal("missing message_start")
|
||||||
|
}
|
||||||
|
// 首块即 tool_use index 0
|
||||||
|
cs := findSSE(evs, "content_block_start", 0)
|
||||||
|
if cs == nil {
|
||||||
|
t.Fatal("missing tool block")
|
||||||
|
}
|
||||||
|
cb := cs.data["content_block"].(map[string]any)
|
||||||
|
if cb["type"] != "tool_use" || cb["id"] != "call_1" || cb["name"] != "get_weather" {
|
||||||
|
t.Fatalf("tool block = %v", cb)
|
||||||
|
}
|
||||||
|
if d := inputJSONDeltas(evs)[0]; d != `{"city":"北京"}` {
|
||||||
|
t.Fatalf("tool args = %q", d)
|
||||||
|
}
|
||||||
|
if findSSE(evs, "content_block_stop", 0) == nil {
|
||||||
|
t.Fatal("missing stop")
|
||||||
|
}
|
||||||
|
if md := findSSE(evs, "message_delta", -1); md == nil {
|
||||||
|
t.Fatal("missing message_delta")
|
||||||
|
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
|
||||||
|
t.Fatalf("stop_reason = %v", d["stop_reason"])
|
||||||
|
}
|
||||||
|
if findSSE(evs, "message_stop", -1) == nil {
|
||||||
|
t.Fatal("missing message_stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponsesToMessagesTextThenFunction(t *testing.T) {
|
||||||
|
tf := newResponsesToMessages().line
|
||||||
|
out := feedLines(t, tf, []string{
|
||||||
|
"event: response.created\n",
|
||||||
|
`data: {"type":"response.created","response":{"id":"resp_1","model":"glm-4.7-flash"}}` + "\n\n",
|
||||||
|
"event: response.output_text.delta\n",
|
||||||
|
`data: {"type":"response.output_text.delta","delta":"我来查询"}` + "\n\n",
|
||||||
|
"event: response.output_item.added\n",
|
||||||
|
`data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_call_1","call_id":"call_1","name":"get_weather","arguments":"","status":"in_progress"},"output_index":1}` + "\n\n",
|
||||||
|
"event: response.function_call_arguments.delta\n",
|
||||||
|
`data: {"type":"response.function_call_arguments.delta","delta":"{\"city\":\"北京\"}","item_id":"fc_call_1"}` + "\n\n",
|
||||||
|
"event: response.completed\n",
|
||||||
|
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n",
|
||||||
|
})
|
||||||
|
evs := parseSSE(t, out)
|
||||||
|
|
||||||
|
// 文本块 index 0、工具块 index 1
|
||||||
|
if cb := findSSE(evs, "content_block_start", 0); cb == nil {
|
||||||
|
t.Fatal("missing text block")
|
||||||
|
} else if cb.data["content_block"].(map[string]any)["type"] != "text" {
|
||||||
|
t.Fatalf("block 0 type = %v", cb.data["content_block"].(map[string]any)["type"])
|
||||||
|
}
|
||||||
|
if cb := findSSE(evs, "content_block_start", 1); cb == nil {
|
||||||
|
t.Fatal("missing tool block")
|
||||||
|
} else if cb.data["content_block"].(map[string]any)["type"] != "tool_use" {
|
||||||
|
t.Fatalf("block 1 type = %v", cb.data["content_block"].(map[string]any)["type"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"text":"我来查询"`) {
|
||||||
|
t.Fatalf("missing text delta: %s", out)
|
||||||
|
}
|
||||||
|
// stop 顺序 0,1
|
||||||
|
var stops []int
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.typ == "content_block_stop" {
|
||||||
|
idx, _ := e.data["index"].(float64)
|
||||||
|
stops = append(stops, int(idx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stops) != 2 || stops[0] != 0 || stops[1] != 1 {
|
||||||
|
t.Fatalf("stop order = %v", stops)
|
||||||
|
}
|
||||||
|
if md := findSSE(evs, "message_delta", -1); md == nil {
|
||||||
|
t.Fatal("missing message_delta")
|
||||||
|
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
|
||||||
|
t.Fatalf("stop_reason = %v", d["stop_reason"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesToChatReqToolOrder(t *testing.T) {
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"messages": []any{map[string]any{"role": "assistant", "content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "我来"},
|
||||||
|
map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{"city": "北京"}},
|
||||||
|
map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "晴"},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := messagesToChatReq([]byte(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("messagesToChatReq: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(out, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
msgs := m["messages"].([]any)
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("messages len = %d: %s", len(msgs), out)
|
||||||
|
}
|
||||||
|
if r := msgs[0].(map[string]any)["role"]; r != "assistant" {
|
||||||
|
t.Fatalf("msg[0] role = %v", r)
|
||||||
|
}
|
||||||
|
if r := msgs[1].(map[string]any)["role"]; r != "tool" {
|
||||||
|
t.Fatalf("msg[1] role = %v", r)
|
||||||
|
}
|
||||||
|
if _, ok := msgs[0].(map[string]any)["tool_calls"]; !ok {
|
||||||
|
t.Fatalf("msg[0] missing tool_calls: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
var blocks []map[string]any
|
var blocks []map[string]any
|
||||||
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
|
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
|
||||||
var out []any
|
var out []any
|
||||||
|
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
|
||||||
var textParts []string
|
var textParts []string
|
||||||
var contentBlocks []any // text / image_url 块,保留原始顺序
|
var contentBlocks []any // text / image_url 块,保留原始顺序
|
||||||
var toolCalls []any
|
var toolCalls []any
|
||||||
@@ -259,7 +260,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
case "tool_result":
|
case "tool_result":
|
||||||
callID, _ := b["tool_use_id"].(string)
|
callID, _ := b["tool_use_id"].(string)
|
||||||
res := strField(b["content"])
|
res := strField(b["content"])
|
||||||
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasImage := false
|
hasImage := false
|
||||||
@@ -282,6 +283,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
}
|
}
|
||||||
out = append(out, msg)
|
out = append(out, msg)
|
||||||
}
|
}
|
||||||
|
out = append(out, toolMsgs...)
|
||||||
if len(out) > 0 {
|
if len(out) > 0 {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,8 +305,11 @@ func responsesToChatResp(body []byte) ([]byte, error) {
|
|||||||
msg["tool_calls"] = toolCalls
|
msg["tool_calls"] = toolCalls
|
||||||
}
|
}
|
||||||
finish := "stop"
|
finish := "stop"
|
||||||
if string(rawJSON(m, "status")) == `"incomplete"` {
|
switch {
|
||||||
finish = "length"
|
case string(rawJSON(m, "status")) == `"incomplete"`:
|
||||||
|
finish = "length" // 截断优先,客户端可据此区分
|
||||||
|
case len(toolCalls) > 0:
|
||||||
|
finish = "tool_calls" // 有工具调用时映射 tool_calls,否则中转后 stop_reason 成 end_turn
|
||||||
}
|
}
|
||||||
var prompt, completion int64
|
var prompt, completion int64
|
||||||
if u := rawJSON(m, "usage"); u != nil {
|
if u := rawJSON(m, "usage"); u != nil {
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ func eventLine(name string, obj any) []byte {
|
|||||||
type messagesToChat struct {
|
type messagesToChat struct {
|
||||||
sseState
|
sseState
|
||||||
id, model string
|
id, model string
|
||||||
|
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
|
||||||
|
nextTool int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMessagesToChat() *messagesToChat { return &messagesToChat{} }
|
func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
|
||||||
|
|
||||||
func (t *messagesToChat) line(line []byte) []byte {
|
func (t *messagesToChat) line(line []byte) []byte {
|
||||||
isData, data, done := t.parseLine(line)
|
isData, data, done := t.parseLine(line)
|
||||||
@@ -72,8 +74,43 @@ func (t *messagesToChat) line(line []byte) []byte {
|
|||||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
||||||
})
|
})
|
||||||
|
case "content_block_start":
|
||||||
|
cb, _ := m["content_block"].(map[string]any)
|
||||||
|
if cb == nil || cb["type"] != "tool_use" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blockIdx, _ := m["index"].(float64)
|
||||||
|
tool := t.nextTool
|
||||||
|
t.nextTool++
|
||||||
|
t.toolIdx[int(blockIdx)] = tool
|
||||||
|
toolID, _ := cb["id"].(string)
|
||||||
|
name, _ := cb["name"].(string)
|
||||||
|
return dataLine(map[string]any{
|
||||||
|
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||||
|
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
|
||||||
|
"tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}},
|
||||||
|
}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
case "content_block_delta":
|
case "content_block_delta":
|
||||||
delta, _ := m["delta"].(map[string]any)
|
delta, _ := m["delta"].(map[string]any)
|
||||||
|
deltaType, _ := delta["type"].(string)
|
||||||
|
if deltaType == "input_json_delta" {
|
||||||
|
blockIdx, _ := m["index"].(float64)
|
||||||
|
tool, ok := t.toolIdx[int(blockIdx)]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
partial, _ := delta["partial_json"].(string)
|
||||||
|
if partial == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dataLine(map[string]any{
|
||||||
|
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||||
|
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
|
||||||
|
"tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}},
|
||||||
|
}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
|
}
|
||||||
text, _ := delta["text"].(string)
|
text, _ := delta["text"].(string)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -118,14 +155,19 @@ func joinLines(lines [][]byte) []byte {
|
|||||||
|
|
||||||
type chatToMessages struct {
|
type chatToMessages struct {
|
||||||
sseState
|
sseState
|
||||||
started bool
|
started bool // message_start 已发出
|
||||||
blockStarted bool
|
nextIndex int // 下一个 content block index(顺序分配)
|
||||||
model string
|
textIndex int // 文本块 index;-1 = 未开始
|
||||||
stopReason string
|
toolIdx map[int]int // chat delta.tool_calls[].index → messages block index
|
||||||
usage any
|
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||||
|
model string
|
||||||
|
stopReason string
|
||||||
|
usage any
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChatToMessages() *chatToMessages { return &chatToMessages{} }
|
func newChatToMessages() *chatToMessages {
|
||||||
|
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *chatToMessages) line(line []byte) []byte {
|
func (t *chatToMessages) line(line []byte) []byte {
|
||||||
isData, data, done := t.parseLine(line)
|
isData, data, done := t.parseLine(line)
|
||||||
@@ -133,18 +175,18 @@ func (t *chatToMessages) line(line []byte) []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
// 汇聚最终 message_delta + content_block_stop + message_stop
|
// 汇聚最终:先对每个已开始未停止的块发 content_block_stop,再 message_delta + message_stop
|
||||||
|
var out [][]byte
|
||||||
|
for _, idx := range t.openBlocks {
|
||||||
|
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
|
||||||
|
}
|
||||||
md := map[string]any{"type": "message_delta", "delta": map[string]any{
|
md := map[string]any{"type": "message_delta", "delta": map[string]any{
|
||||||
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
|
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
|
||||||
}}
|
}}
|
||||||
if t.usage != nil {
|
if t.usage != nil {
|
||||||
md["usage"] = t.usage
|
md["usage"] = t.usage
|
||||||
}
|
}
|
||||||
var out [][]byte
|
|
||||||
out = append(out, eventLine("message_delta", md))
|
out = append(out, eventLine("message_delta", md))
|
||||||
if t.blockStarted {
|
|
||||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
|
||||||
}
|
|
||||||
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
||||||
return joinLines(out)
|
return joinLines(out)
|
||||||
}
|
}
|
||||||
@@ -167,47 +209,97 @@ func (t *chatToMessages) line(line []byte) []byte {
|
|||||||
id, _ := m["id"].(string)
|
id, _ := m["id"].(string)
|
||||||
|
|
||||||
var out [][]byte
|
var out [][]byte
|
||||||
// 首个包含内容或角色的块前,先发 message_start + content_block_start
|
// message_start 只在实际有内容(文本或工具)时发出,避免 GLM 等模型的
|
||||||
if !t.started {
|
// reasoning_content 块(带 role 无 content)提前开出一个空文本块。
|
||||||
role, _ := delta["role"].(string)
|
ensureStarted := func() {
|
||||||
content, _ := delta["content"].(string)
|
if t.started {
|
||||||
if role == "assistant" || content != "" {
|
return
|
||||||
t.started = true
|
|
||||||
out = append(out, eventLine("message_start", map[string]any{
|
|
||||||
"type": "message_start",
|
|
||||||
"message": map[string]any{
|
|
||||||
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
|
|
||||||
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
out = append(out, eventLine("content_block_start", map[string]any{
|
|
||||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
|
||||||
}))
|
|
||||||
t.blockStarted = true
|
|
||||||
}
|
}
|
||||||
}
|
t.started = true
|
||||||
if content, _ := delta["content"].(string); content != "" {
|
out = append(out, eventLine("message_start", map[string]any{
|
||||||
if !t.started {
|
"type": "message_start",
|
||||||
t.started = true
|
"message": map[string]any{
|
||||||
t.blockStarted = true
|
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
|
||||||
out = append(out, eventLine("message_start", map[string]any{
|
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
||||||
"type": "message_start",
|
},
|
||||||
"message": map[string]any{"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", "model": t.model, "content": []any{}},
|
|
||||||
}))
|
|
||||||
out = append(out, eventLine("content_block_start", map[string]any{
|
|
||||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
|
||||||
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": content},
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文本:delta.content(string;兼容 {type:text,text} 数组)
|
||||||
|
if content := deltaText(delta); content != "" {
|
||||||
|
if t.textIndex < 0 {
|
||||||
|
t.textIndex = t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, t.textIndex)
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式)
|
||||||
|
if tcs, ok := delta["tool_calls"].([]any); ok {
|
||||||
|
for _, tc := range tcs {
|
||||||
|
call, ok := tc.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, _ := call["index"].(float64)
|
||||||
|
tcIdx := int(idx)
|
||||||
|
fn, _ := call["function"].(map[string]any)
|
||||||
|
name, _ := fn["name"].(string)
|
||||||
|
args, _ := fn["arguments"].(string)
|
||||||
|
blockIdx, seen := t.toolIdx[tcIdx]
|
||||||
|
if !seen {
|
||||||
|
blockIdx = t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
t.toolIdx[tcIdx] = blockIdx
|
||||||
|
toolID, _ := call["id"].(string)
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
|
||||||
|
"type": "tool_use", "id": toolID, "name": name, "input": map[string]any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, blockIdx)
|
||||||
|
}
|
||||||
|
if args != "" {
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if u, ok := m["usage"]; ok {
|
if u, ok := m["usage"]; ok {
|
||||||
t.usage = u
|
t.usage = u
|
||||||
}
|
}
|
||||||
return joinLines(out)
|
return joinLines(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
|
||||||
|
func deltaText(delta map[string]any) string {
|
||||||
|
if s, ok := delta["content"].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if arr, ok := delta["content"].([]any); ok {
|
||||||
|
var parts []string
|
||||||
|
for _, b := range arr {
|
||||||
|
if bm, ok := b.(map[string]any); ok {
|
||||||
|
if t, _ := bm["text"].(string); t != "" {
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func stopReasonOrEnd(s string) string {
|
func stopReasonOrEnd(s string) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return "end_turn"
|
return "end_turn"
|
||||||
@@ -220,12 +312,19 @@ func stopReasonOrEnd(s string) string {
|
|||||||
|
|
||||||
type responsesToMessages struct {
|
type responsesToMessages struct {
|
||||||
sseState
|
sseState
|
||||||
started bool
|
started bool
|
||||||
model string
|
model string
|
||||||
usage any
|
usage any
|
||||||
|
nextIndex int // 下一个 content block index(顺序分配)
|
||||||
|
textIndex int // 文本块 index;-1 = 未开始
|
||||||
|
toolIdx map[string]int // function_call item_id → messages block index
|
||||||
|
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||||
|
anyTool bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newResponsesToMessages() *responsesToMessages { return &responsesToMessages{} }
|
func newResponsesToMessages() *responsesToMessages {
|
||||||
|
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *responsesToMessages) line(line []byte) []byte {
|
func (t *responsesToMessages) line(line []byte) []byte {
|
||||||
isData, data, done := t.parseLine(line)
|
isData, data, done := t.parseLine(line)
|
||||||
@@ -243,38 +342,93 @@ func (t *responsesToMessages) line(line []byte) []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var out [][]byte
|
var out [][]byte
|
||||||
|
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
|
||||||
|
// 避免纯函数调用响应提前开出一个空文本块。
|
||||||
|
ensureStarted := func() {
|
||||||
|
if t.started {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.started = true
|
||||||
|
rid := ""
|
||||||
|
if resp, ok := m["response"].(map[string]any); ok {
|
||||||
|
rid, _ = resp["id"].(string)
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("message_start", map[string]any{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": map[string]any{
|
||||||
|
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
|
||||||
|
"model": t.model, "content": []any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
switch evt {
|
switch evt {
|
||||||
case "response.created":
|
case "response.created":
|
||||||
if !t.started {
|
ensureStarted()
|
||||||
t.started = true
|
|
||||||
id, _ := m["response"].(map[string]any)
|
|
||||||
rid := ""
|
|
||||||
if id != nil {
|
|
||||||
rid, _ = id["id"].(string)
|
|
||||||
}
|
|
||||||
out = append(out, eventLine("message_start", map[string]any{
|
|
||||||
"type": "message_start",
|
|
||||||
"message": map[string]any{
|
|
||||||
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
|
|
||||||
"model": t.model, "content": []any{},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
out = append(out, eventLine("content_block_start", map[string]any{
|
|
||||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
case "response.output_text.delta":
|
case "response.output_text.delta":
|
||||||
delta, _ := m["delta"].(string)
|
delta, _ := m["delta"].(string)
|
||||||
if delta != "" {
|
if delta == "" {
|
||||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
return nil
|
||||||
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
case "response.completed":
|
if t.textIndex < 0 {
|
||||||
out = append(out, eventLine("message_delta", map[string]any{
|
t.textIndex = t.nextIndex
|
||||||
"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
t.nextIndex++
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, t.textIndex)
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta},
|
||||||
}))
|
}))
|
||||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
case "response.output_item.added":
|
||||||
|
item, _ := m["item"].(map[string]any)
|
||||||
|
if item == nil || item["type"] != "function_call" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blockIdx := t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
t.anyTool = true
|
||||||
|
itemID, _ := item["id"].(string)
|
||||||
|
t.toolIdx[itemID] = blockIdx
|
||||||
|
toolUseID, _ := item["call_id"].(string)
|
||||||
|
if toolUseID == "" {
|
||||||
|
toolUseID = itemID
|
||||||
|
}
|
||||||
|
name, _ := item["name"].(string)
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
|
||||||
|
"type": "tool_use", "id": toolUseID, "name": name, "input": map[string]any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, blockIdx)
|
||||||
|
case "response.function_call_arguments.delta":
|
||||||
|
itemID, _ := m["item_id"].(string)
|
||||||
|
blockIdx, ok := t.toolIdx[itemID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delta, _ := m["delta"].(string)
|
||||||
|
if delta == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": delta},
|
||||||
|
}))
|
||||||
|
case "response.completed":
|
||||||
|
for _, idx := range t.openBlocks {
|
||||||
|
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
|
||||||
|
}
|
||||||
|
stop := "end_turn"
|
||||||
|
if t.anyTool {
|
||||||
|
stop = "tool_use"
|
||||||
|
}
|
||||||
|
md := map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}}
|
||||||
|
if t.usage != nil {
|
||||||
|
md["usage"] = t.usage
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("message_delta", md))
|
||||||
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
||||||
}
|
}
|
||||||
return joinLines(out)
|
return joinLines(out)
|
||||||
|
|||||||
Reference in New Issue
Block a user