174 lines
6.7 KiB
Go
174 lines
6.7 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"oci-portal/internal/aiwire"
|
|
)
|
|
|
|
func mustAnthReq(t *testing.T, raw string) aiwire.MessagesRequest {
|
|
t.Helper()
|
|
var req aiwire.MessagesRequest
|
|
if err := json.Unmarshal([]byte(raw), &req); err != nil {
|
|
t.Fatalf("解析请求: %v", err)
|
|
}
|
|
return req
|
|
}
|
|
|
|
// TestAnthropicToResponsesBody 断言 system/消息/工具/effort 的直通装配与 store 强制。
|
|
func TestAnthropicToResponsesBody(t *testing.T) {
|
|
req := mustAnthReq(t, `{
|
|
"model": "xai.grok-4.3", "max_tokens": 128, "system": "你是助手", "stream": true,
|
|
"temperature": 0.5,
|
|
"messages": [
|
|
{"role": "user", "content": "东京天气?"},
|
|
{"role": "assistant", "content": [
|
|
{"type": "text", "text": "查询中"},
|
|
{"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}}
|
|
]},
|
|
{"role": "user", "content": [
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"},
|
|
{"type": "text", "text": "继续"}
|
|
]}
|
|
],
|
|
"tools": [{"name": "get_weather", "description": "查天气", "input_schema": {"type": "object"}}],
|
|
"tool_choice": {"type": "any"},
|
|
"output_config": {"effort": "HIGH"}
|
|
}`)
|
|
payload, err := AnthropicToResponsesBody(req)
|
|
if err != nil {
|
|
t.Fatalf("AnthropicToResponsesBody: %v", err)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if body["model"] != "xai.grok-4.3" || body["max_output_tokens"] != float64(128) ||
|
|
body["instructions"] != "你是助手" || body["store"] != false || body["stream"] != true {
|
|
t.Fatalf("顶层字段装配错误: %v", body)
|
|
}
|
|
if body["tool_choice"] != "required" {
|
|
t.Fatalf("tool_choice = %v, want required", body["tool_choice"])
|
|
}
|
|
reasoning, _ := body["reasoning"].(map[string]any)
|
|
if reasoning["effort"] != "high" {
|
|
t.Fatalf("effort = %v, want high(小写透传)", reasoning)
|
|
}
|
|
input, _ := body["input"].([]any)
|
|
// user 消息、assistant 文本消息、function_call、function_call_output、末条 user 文本
|
|
if len(input) != 5 {
|
|
t.Fatalf("input 项数 = %d, want 5: %s", len(input), payload)
|
|
}
|
|
kinds := make([]string, 0, len(input))
|
|
for _, it := range input {
|
|
m := it.(map[string]any)
|
|
if ty, ok := m["type"].(string); ok {
|
|
kinds = append(kinds, ty)
|
|
} else {
|
|
kinds = append(kinds, "message/"+m["role"].(string))
|
|
}
|
|
}
|
|
want := "message/user,message/assistant,function_call,function_call_output,message/user"
|
|
if got := strings.Join(kinds, ","); got != want {
|
|
t.Fatalf("input 顺序 = %s, want %s", got, want)
|
|
}
|
|
if !strings.Contains(string(payload), `"output_text"`) {
|
|
t.Fatalf("assistant 历史文本应为 output_text 部件: %s", payload)
|
|
}
|
|
}
|
|
|
|
// TestAnthropicToResponsesBodyRejects 断言不支持的内容块拒绝与图片装配。
|
|
func TestAnthropicToResponsesBodyRejects(t *testing.T) {
|
|
bad := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[
|
|
{"role":"user","content":[{"type":"document","source":{}}]}]}`)
|
|
if _, err := AnthropicToResponsesBody(bad); err == nil {
|
|
t.Fatal("document 块应拒绝")
|
|
}
|
|
img := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[
|
|
{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"QUJD"}}]}]}`)
|
|
payload, err := AnthropicToResponsesBody(img)
|
|
if err != nil || !strings.Contains(string(payload), "data:image/png;base64,QUJD") {
|
|
t.Fatalf("图片应转 data URI: %v %s", err, payload)
|
|
}
|
|
}
|
|
|
|
// TestResponsesToAnthropic 断言输出块、stop_reason 与 usage 的回转。
|
|
func TestResponsesToAnthropic(t *testing.T) {
|
|
payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[
|
|
{"type":"reasoning","summary":[]},
|
|
{"type":"message","content":[{"type":"output_text","text":"你好"}]},
|
|
{"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\"city\":\"东京\"}"}],
|
|
"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}`)
|
|
out, err := ResponsesToAnthropic(payload, "msg_1")
|
|
if err != nil {
|
|
t.Fatalf("ResponsesToAnthropic: %v", err)
|
|
}
|
|
if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[0].Text != "你好" {
|
|
t.Fatalf("content 装配错误: %+v", out.Content)
|
|
}
|
|
if out.Content[1].Type != "tool_use" || out.Content[1].ID != "c1" || string(out.Content[1].Input) != `{"city":"东京"}` {
|
|
t.Fatalf("tool_use 装配错误: %+v", out.Content[1])
|
|
}
|
|
if out.StopReason != "tool_use" {
|
|
t.Fatalf("stop_reason = %s, want tool_use", out.StopReason)
|
|
}
|
|
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Usage.CacheReadInputTokens != 4 {
|
|
t.Fatalf("usage = %+v", out.Usage)
|
|
}
|
|
|
|
trunc := []byte(`{"model":"m","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},
|
|
"output":[{"type":"message","content":[{"type":"output_text","text":"半"}]}]}`)
|
|
out2, err := ResponsesToAnthropic(trunc, "msg_2")
|
|
if err != nil || out2.StopReason != "max_tokens" {
|
|
t.Fatalf("截断 stop_reason = %v, %v", out2, err)
|
|
}
|
|
}
|
|
|
|
func bridgeEventTypes(events []AnthEvent) string {
|
|
kinds := make([]string, 0, len(events))
|
|
for _, ev := range events {
|
|
kinds = append(kinds, ev.Event)
|
|
}
|
|
return strings.Join(kinds, ",")
|
|
}
|
|
|
|
// TestAnthRespBridge 断言流桥:文本增量、工具调用切块、reasoning 丢弃与收尾事件。
|
|
func TestAnthRespBridge(t *testing.T) {
|
|
st := NewAnthRespBridge("msg_1", "m1")
|
|
var events []AnthEvent
|
|
feed := func(lines ...string) {
|
|
for _, l := range lines {
|
|
events = append(events, st.Feed([]byte(l))...)
|
|
}
|
|
}
|
|
feed(`{"type":"response.created","response":{"model":"m1"}}`,
|
|
`{"type":"response.reasoning_text.delta","delta":"思考中"}`,
|
|
`{"type":"response.output_text.delta","delta":"你"}`,
|
|
`{"type":"response.output_text.delta","delta":"好"}`,
|
|
`{"type":"response.output_item.added","item":{"type":"function_call","call_id":"c1","name":"f"}}`,
|
|
`{"type":"response.function_call_arguments.delta","delta":"{\"a\":1}"}`,
|
|
`{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4}}}`)
|
|
events = append(events, st.Finish()...)
|
|
|
|
got := bridgeEventTypes(events)
|
|
want := "message_start,content_block_start,content_block_delta,content_block_delta," +
|
|
"content_block_stop,content_block_start,content_block_delta,content_block_stop," +
|
|
"message_delta,message_stop"
|
|
if got != want {
|
|
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
|
|
}
|
|
if st.Usage().InputTokens != 8 || st.Usage().OutputTokens != 4 {
|
|
t.Fatalf("usage = %+v", st.Usage())
|
|
}
|
|
}
|
|
|
|
// TestAnthRespBridgeEmpty 断言空流也产出完整事件骨架。
|
|
func TestAnthRespBridgeEmpty(t *testing.T) {
|
|
st := NewAnthRespBridge("msg_1", "m1")
|
|
if got := bridgeEventTypes(st.Finish()); got != "message_start,message_delta,message_stop" {
|
|
t.Errorf("空流事件序列 = %s", got)
|
|
}
|
|
}
|