发布 0.3.0:恢复 Chat 接口并增强云端事件通知
CI / test (push) Successful in 31s
Release / release (push) Successful in 48s

This commit is contained in:
2026-07-13 10:13:44 +08:00
parent 489cb49cb3
commit c7cc5616ed
31 changed files with 1682 additions and 1625 deletions
+301
View File
@@ -0,0 +1,301 @@
package service
import (
"encoding/json"
"strings"
"testing"
"oci-portal/internal/aiwire"
)
func mustChatReq(t *testing.T, raw string) aiwire.ChatRequest {
t.Helper()
var req aiwire.ChatRequest
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("解析请求: %v", err)
}
return req
}
func mustChatBody(t *testing.T, raw string) map[string]any {
t.Helper()
payload, err := ChatToResponsesBody(mustChatReq(t, raw))
if err != nil {
t.Fatalf("ChatToResponsesBody: %v", err)
}
var body map[string]any
if err := json.Unmarshal(payload, &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return body
}
// TestChatToResponsesBody 断言消息展开、instructions 聚合、工具与顶层字段装配。
func TestChatToResponsesBody(t *testing.T) {
body := mustChatBody(t, `{
"model": "xai.grok-4.3", "max_tokens": 64, "max_completion_tokens": 128,
"temperature": 0.5, "top_p": 0.9, "parallel_tool_calls": false, "stream": true,
"reasoning_effort": "HIGH",
"messages": [
{"role": "system", "content": "你是助手"},
{"role": "developer", "content": [{"type": "text", "text": "简洁作答"}]},
{"role": "user", "content": "东京天气?"},
{"role": "assistant", "content": "查询中", "tool_calls": [
{"id": "t1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"东京\"}"}}
]},
{"role": "tool", "tool_call_id": "t1", "content": "晴 25 度"},
{"role": "user", "content": "继续"}
],
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "查天气", "parameters": {"type": "object"}}}],
"tool_choice": "required"
}`)
if body["model"] != "xai.grok-4.3" || body["store"] != false || body["stream"] != true {
t.Fatalf("顶层字段装配错误: %v", body)
}
if body["max_output_tokens"] != float64(128) {
t.Fatalf("max_output_tokens = %v, want 128(max_completion_tokens 优先)", body["max_output_tokens"])
}
if body["instructions"] != "你是助手\n\n简洁作答" {
t.Fatalf("instructions = %v", body["instructions"])
}
if body["temperature"] != 0.5 || body["top_p"] != 0.9 || body["parallel_tool_calls"] != false {
t.Fatalf("采样字段装配错误: %v", body)
}
if body["tool_choice"] != "required" {
t.Fatalf("tool_choice = %v", body["tool_choice"])
}
if reasoning, _ := body["reasoning"].(map[string]any); reasoning["effort"] != "high" {
t.Fatalf("effort = %v, want high(小写透传)", body["reasoning"])
}
kinds := chatInputKinds(t, body)
want := "message/user,message/assistant,function_call,function_call_output,message/user"
if kinds != want {
t.Fatalf("input 顺序 = %s, want %s", kinds, want)
}
tools, _ := body["tools"].([]any)
tool, _ := tools[0].(map[string]any)
if tool["type"] != "function" || tool["name"] != "get_weather" {
t.Fatalf("工具应拍平为 Responses 形态: %v", tool)
}
}
func chatInputKinds(t *testing.T, body map[string]any) string {
t.Helper()
input, _ := body["input"].([]any)
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))
}
}
return strings.Join(kinds, ",")
}
// TestChatToResponsesBodyContent 断言文本部件按角色定型与图片装配/拒绝。
func TestChatToResponsesBodyContent(t *testing.T) {
body := mustChatBody(t, `{"model":"m","messages":[
{"role":"user","content":[
{"type":"text","text":"看图"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]},
{"role":"assistant","content":"这是猫"}]}`)
raw, _ := json.Marshal(body["input"])
if !strings.Contains(string(raw), `"input_image"`) ||
!strings.Contains(string(raw), "data:image/png;base64,QUJD") {
t.Fatalf("图片应转 input_image: %s", raw)
}
if !strings.Contains(string(raw), `"output_text"`) {
t.Fatalf("assistant 历史文本应为 output_text 部件: %s", raw)
}
for name, msg := range map[string]string{
"audio 块": `{"role":"user","content":[{"type":"input_audio","input_audio":{}}]}`,
"图片缺 url": `{"role":"user","content":[{"type":"image_url","image_url":{}}]}`,
} {
raw := `{"model":"m","messages":[` + msg + `]}`
if _, err := ChatToResponsesBody(mustChatReq(t, raw)); err == nil {
t.Errorf("%s 应拒绝", name)
}
}
badTool := mustChatReq(t, `{"model":"m","messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"web_search","function":{}}]}`)
if _, err := ChatToResponsesBody(badTool); err == nil {
t.Error("非 function 工具类型应拒绝")
}
}
// TestChatRespToolChoice 断言 tool_choice 各形态映射。
func TestChatRespToolChoice(t *testing.T) {
cases := []struct {
name, raw string
want any
}{
{"auto", `"auto"`, "auto"},
{"none", `"none"`, "none"},
{"required", `"required"`, "required"},
{"具名 function", `{"type":"function","function":{"name":"f1"}}`,
map[string]string{"type": "function", "name": "f1"}},
{"未知 string", `"whatever"`, nil},
{"缺 name", `{"type":"function","function":{}}`, nil},
{"空", ``, nil},
}
for _, tc := range cases {
got := chatRespToolChoice(json.RawMessage(tc.raw))
if gotMap, ok := got.(map[string]string); ok {
wantMap, _ := tc.want.(map[string]string)
if wantMap == nil || gotMap["name"] != wantMap["name"] {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
continue
}
if got != tc.want {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
// TestChatTextFormat 断言 response_format 拍平映射。
func TestChatTextFormat(t *testing.T) {
if got := chatTextFormat(nil); got != nil {
t.Errorf("nil 应不下发: %v", got)
}
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "text"}); got != nil {
t.Errorf("text 应不下发: %v", got)
}
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_object"}); got["type"] != "json_object" {
t.Errorf("json_object: %v", got)
}
got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_schema",
JSONSchema: json.RawMessage(`{"name":"out","schema":{"type":"object"},"strict":true}`)})
if got["type"] != "json_schema" || got["name"] != "out" || got["strict"] != true {
t.Errorf("json_schema 应提升嵌套字段: %v", got)
}
if _, ok := got["schema"]; !ok {
t.Errorf("schema 缺失: %v", got)
}
}
// TestResponsesToChat 断言文本聚合、tool_calls、finish_reason 与 usage 回转。
func TestResponsesToChat(t *testing.T) {
payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[
{"type":"reasoning","summary":[]},
{"type":"message","content":[{"type":"output_text","text":"你"},{"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 := ResponsesToChat(payload, "chatcmpl-1", 1700000000)
if err != nil {
t.Fatalf("ResponsesToChat: %v", err)
}
if out.Object != "chat.completion" || out.ID != "chatcmpl-1" || out.Created != 1700000000 {
t.Fatalf("响应骨架错误: %+v", out)
}
msg := out.Choices[0].Message
if msg.Role != "assistant" || msg.Content.JoinText() != "你好" {
t.Fatalf("content 装配错误: %+v", msg)
}
if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].ID != "c1" ||
msg.ToolCalls[0].Function.Arguments != `{"city":"东京"}` {
t.Fatalf("tool_calls 装配错误: %+v", msg.ToolCalls)
}
if out.Choices[0].FinishReason != "tool_calls" {
t.Fatalf("finish_reason = %s, want tool_calls", out.Choices[0].FinishReason)
}
if out.Usage.PromptTokens != 10 || out.Usage.CompletionTokens != 5 || out.Usage.CachedTokens() != 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 := ResponsesToChat(trunc, "chatcmpl-2", 1)
if err != nil || out2.Choices[0].FinishReason != "length" {
t.Fatalf("截断 finish_reason = %+v, %v", out2.Choices, err)
}
if out2.Usage != nil {
t.Fatalf("无 usage 时应为 nil: %+v", out2.Usage)
}
}
func chatChunkShapes(chunks []aiwire.ChatChunk) string {
kinds := make([]string, 0, len(chunks))
for _, ch := range chunks {
switch {
case len(ch.Choices) == 0:
kinds = append(kinds, "usage")
case ch.Choices[0].FinishReason != nil:
kinds = append(kinds, "finish:"+*ch.Choices[0].FinishReason)
case len(ch.Choices[0].Delta.ToolCalls) > 0:
kinds = append(kinds, "tool")
case ch.Choices[0].Delta.Role != "":
kinds = append(kinds, "role+text")
default:
kinds = append(kinds, "text")
}
}
return strings.Join(kinds, ",")
}
// TestChatRespBridge 断言流桥:首块 role、文本与工具增量、reasoning 丢弃、usage 块。
func TestChatRespBridge(t *testing.T) {
b := NewChatRespBridge("chatcmpl-1", "m1", 1700000000, true)
var chunks []aiwire.ChatChunk
feed := func(lines ...string) {
for _, l := range lines {
chunks = append(chunks, b.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\":"}`,
`{"type":"response.function_call_arguments.delta","delta":"1}"}`,
`{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4,"total_tokens":12}}}`)
chunks = append(chunks, b.Finish()...)
got := chatChunkShapes(chunks)
want := "role+text,text,tool,tool,tool,finish:tool_calls,usage"
if got != want {
t.Fatalf("chunk 序列:\n got %s\nwant %s", got, want)
}
if chunks[0].Choices[0].Delta.Role != "assistant" || chunks[0].Choices[0].Delta.Content != "你" {
t.Fatalf("首块应含 role 与文本: %+v", chunks[0].Choices[0].Delta)
}
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
if tool.Index != 0 || tool.ID != "c1" || tool.Function.Name != "f" {
t.Fatalf("工具首块 = %+v", tool)
}
args := chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments +
chunks[4].Choices[0].Delta.ToolCalls[0].Function.Arguments
if args != `{"a":1}` {
t.Fatalf("实参增量聚合 = %s", args)
}
last := chunks[len(chunks)-1]
if last.Usage == nil || last.Usage.PromptTokens != 8 || len(last.Choices) != 0 {
t.Fatalf("usage 块 = %+v", last)
}
if b.Usage().TotalTokens != 12 {
t.Fatalf("Usage() = %+v", b.Usage())
}
}
// TestChatRespBridgeVariants 断言 include_usage 关闭、截断与空流的收尾形态。
func TestChatRespBridgeVariants(t *testing.T) {
noUsage := NewChatRespBridge("c", "m", 1, false)
noUsage.Feed([]byte(`{"type":"response.output_text.delta","delta":"x"}`))
noUsage.Feed([]byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`))
if got := chatChunkShapes(noUsage.Finish()); got != "finish:length" {
t.Errorf("截断且不带 usage 块: %s", got)
}
empty := NewChatRespBridge("c", "m", 1, false)
fin := empty.Finish()
if got := chatChunkShapes(fin); got != "finish:stop" {
t.Errorf("空流收尾 = %s", got)
}
if fin[0].Choices[0].Delta.Role != "assistant" {
t.Errorf("空流终块应补 role: %+v", fin[0].Choices[0].Delta)
}
}