feat: 补齐三个方向的流式工具调用;Anthropic 上游走 x-api-key;禁用模型真正下架
- 流式转换补齐 chat→responses / responses→chat / messages→responses 的工具调用(此前只转文本,SDK 客户端拿不到 tool_calls);completed 事件携带完整 output 数组 - Anthropic 协议上游同时发 x-api-key(官方 API 不接受 API key 走 Bearer)并透传 anthropic-beta - 请求侧校验模型存在且启用:禁用模型 404 model_not_found,不再只是从 /v1/models 消失、实际照发照扣费;空模型名 400 - 模型在请求中途被删时按 0 记账留痕,不再按硬编码示例价扣用户的钱 - finishUsage 新增一行式请求日志(trace/user/model/channel/tokens/cost/status/latency),记账是异步的,日志是唯一即时排障依据
This commit is contained in:
1 parent
984a31cbc5
commit
d6db0f8162
6 files changed
+485
-31
No files matched your search
@@ -605,3 +605,76 @@ func TestAllChannelsFailedAttributesLastChannel(t *testing.T) {
|
||||
t.Fatalf("失败不应产生扣费:%+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic 原生上游必须收到 x-api-key(官方 API 不接受 API key 走 Bearer)。
|
||||
func TestAnthropicUpstreamUsesXAPIKey(t *testing.T) {
|
||||
var gotXAPIKey, gotAuth, gotVersion string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotXAPIKey = r.Header.Get("x-api-key")
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotVersion = r.Header.Get("anthropic-version")
|
||||
_, _ = io.WriteString(w, `{"id":"msg_1","type":"message","role":"assistant","model":"m1","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h := newHarness(t, srv.URL, 3000, true, nil)
|
||||
// 把渠道改造成 Anthropic 原生(messages 协议)
|
||||
if err := h.db.Model(&store.Channel{}).Where("id = ?", 1).Updates(map[string]any{
|
||||
"provider": store.ChannelProviderAnthropic,
|
||||
"formats": []string{store.FormatMessages},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Anthropic 客户端走 x-api-key 头 + /v1/messages
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"m1","messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("x-api-key", h.key)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
req.Header.Set("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
c.Request = req
|
||||
h.gw.Auth(c)
|
||||
if !c.IsAborted() {
|
||||
h.gw.Handle(c)
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("messages 请求应成功,实际 %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotXAPIKey != "upstream-key" {
|
||||
t.Fatalf("上游应收到 x-api-key=upstream-key,实际 %q", gotXAPIKey)
|
||||
}
|
||||
if gotAuth != "Bearer upstream-key" {
|
||||
t.Fatalf("兼容网关仍需 Bearer 头,实际 %q", gotAuth)
|
||||
}
|
||||
if gotVersion == "" {
|
||||
t.Fatal("上游应收到 anthropic-version")
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用/不存在的模型必须 404:不能只是从 /v1/models 列表消失、请求照样计费。
|
||||
func TestDisabledOrUnknownModelIsRejected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("禁用模型不应触达上游")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h := newHarness(t, srv.URL, 3000, true, nil)
|
||||
if err := h.db.Model(&store.Model{}).Where("id = ?", 1).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("禁用模型应 404,实际 %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "model_not_found") {
|
||||
t.Fatalf("错误码应为 model_not_found: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 空模型名直接 400
|
||||
w = h.run(t, nil, `{"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("空模型应 400,实际 %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user