AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
CI / test (push) Successful in 31s
Release / release (push) Successful in 54s

This commit is contained in:
2026-07-12 17:48:28 +08:00
parent 7706f59549
commit 489cb49cb3
34 changed files with 2602 additions and 2901 deletions
+173
View File
@@ -0,0 +1,173 @@
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)
}
}