按性能分析逐项落地,实测对比(16 并发、mock 上游零延迟、SQLite): - 每请求 SQL 10 → 2:模型/渠道目录(模型行、绑定、渠道行)进程内缓存 10s, 运行期状态(冷却、健康)仍每次实时判定;管理端写操作经中间件、 健康状态变化经回调立即失效。新增 TestProxyRequestSQLBudget 钉住预算。 - 端到端 p50 1.3–1.6ms → 0.66–0.70ms,RPS 3.5–8.9k → 16–18k。 - 流式正文累积:字符串 += 改 strings.Builder(原为 O(n²)), 逐行 JSON 提取改字节快路径,并加廉价的子串预过滤、拿到输出 usage 后停累积。 500 块:949µs/7.9MB 分配 → 83µs/136KB。 - 请求体定点改写:模型映射从整包 unmarshal/marshal 改为顶层字段替换 (100KB:585µs → 29µs),stream_options 注入同理;requestText 改为 只在「上游没报用量」时按需提取,亲和键改用原始体前缀。 - 渠道满载背压:新增 Acquire 有界排队(OT_PROXY_CHANNEL_ACQUIRE_WAIT, 默认 150ms);排队仍满时返回 503 + Retry-After 而不是 502。 实测渠道上限 4、16 并发:无排队 81 次 503;排队 150ms 后 400/400 成功。 - 记账按批聚合:同批同用户一次扣款、流水批量插入、日表按 (user,model,日期) 聚合,替代每行 5 条语句;幂等仍靠 ref_id 唯一索引兜底。 - gorm 开启 PrepareStmt(SQL 解析此前占 CPU 15%);tiktoken 编码器按模型复用。 - 管理端用量列表不再返回原始请求/响应体(可能几十 MB), 改为 has_raw 标记 + GET /admin/usage/:id 按需拉取,前端弹窗打开时取。 验证:go vet、go test ./...、go test -race(proxy/channel/usage/tokenizer)、 vue-tsc、eslint、vite build 全绿;本地起 mock 上游实测列表/详情接口与 管理页弹窗按需加载。
398 lines
17 KiB
Go
398 lines
17 KiB
Go
package proxy
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/openteam/server/internal/pkg/crypto"
|
||
"github.com/openteam/server/internal/store"
|
||
)
|
||
|
||
// addChannel 给 harness 加一个绑定同一模型的渠道,返回渠道名。
|
||
// weight 传大数可以把加权随机的起点压到该渠道上(与既有测试同一手法)。
|
||
func addChannel(t *testing.T, h *harness, name, baseURL string, weight int) {
|
||
t.Helper()
|
||
encKey, err := crypto.NewEncryptor("test-master-key-1234567890").Encrypt("upstream-key")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var m store.Model
|
||
h.db.Where("name = ?", "m1").First(&m)
|
||
ch := store.Channel{Name: name, Provider: store.ChannelProviderOpenAI,
|
||
Formats: []string{store.FormatChat}, BaseURL: baseURL, APIKeyEnc: encKey,
|
||
Weight: weight, TimeoutMS: 0, MaxConcurrency: 4,
|
||
HealthStatus: store.ChannelHealthHealthy, Enabled: true}
|
||
if err := h.db.Create(&ch).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := h.db.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: "m1"}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
h.gw.InvalidateCatalog() // 管理端写操作会失效目录缓存,测试里直接写库要手动失效
|
||
}
|
||
|
||
// pushWeightTo 把指定名字渠道的权重改大,让加权随机几乎必然从它开始。
|
||
func pushWeightTo(t *testing.T, h *harness, name string, weight int) {
|
||
t.Helper()
|
||
if err := h.db.Model(&store.Channel{}).Where("name = ?", name).Update("weight", weight).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
h.gw.InvalidateCatalog()
|
||
}
|
||
|
||
// 流式容错:渠道1 在首个内容事件之前回错误事件(200+流式形式的错误),应换渠道2
|
||
// 重试——客户端只看到渠道2 的内容,全程 200,无感知切换。
|
||
func TestStreamFailoverBeforeFirstContent(t *testing.T) {
|
||
var ch1Hits, ch2Hits atomic.Int64
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ch1Hits.Add(1)
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `data: {"error":{"message":"overloaded","type":"server_error"}}`+"\n\n")
|
||
}))
|
||
defer bad.Close()
|
||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ch2Hits.Add(1)
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"来自健康渠道"}}]}`+"\n\n")
|
||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||
}))
|
||
defer good.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
addChannel(t, h, "c2", good.URL, 1)
|
||
pushWeightTo(t, h, "c1", 1000)
|
||
|
||
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
out := w.Body.String()
|
||
if ch1Hits.Load() != 1 || ch2Hits.Load() != 1 {
|
||
t.Fatalf("渠道命中次数不符:ch1=%d ch2=%d", ch1Hits.Load(), ch2Hits.Load())
|
||
}
|
||
if strings.Contains(out, "overloaded") {
|
||
t.Fatalf("渠道1 的错误不应到达客户端:%q", out)
|
||
}
|
||
if !strings.Contains(out, "来自健康渠道") {
|
||
t.Fatalf("应收到渠道2 的内容:%q", out)
|
||
}
|
||
}
|
||
|
||
// Anthropic 形状的错误事件({"type":"error","error":{...}})同样在放行前被识别换渠道。
|
||
func TestAnthropicStyleStreamErrorFailsOver(t *testing.T) {
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `event: error`+"\n"+`data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`+"\n\n")
|
||
}))
|
||
defer bad.Close()
|
||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n\n")
|
||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||
}))
|
||
defer good.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
addChannel(t, h, "c2", good.URL, 1)
|
||
pushWeightTo(t, h, "c1", 1000)
|
||
|
||
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"content":"ok"`) {
|
||
t.Fatalf("应无感知切换到渠道2:code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// 渠道回了 200+event-stream 却一个字没写就断流:放行前视为失败,换渠道重试。
|
||
func TestEmptyStreamBeforeContentFailsOver(t *testing.T) {
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
w.(http.Flusher).Flush()
|
||
// 直接结束:EOF 且无内容
|
||
}))
|
||
defer bad.Close()
|
||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"补位渠道"}}]}`+"\n\n")
|
||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||
}))
|
||
defer good.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
addChannel(t, h, "c2", good.URL, 1)
|
||
pushWeightTo(t, h, "c1", 1000)
|
||
|
||
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "补位渠道") {
|
||
t.Fatalf("空流应触发故障转移:code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// didNotStream:要流式,渠道却回了整包 JSON——视为失败换渠道,而不是把 JSON 塞给等 SSE 的客户端。
|
||
func TestDidNotStreamFailsOver(t *testing.T) {
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `{"id":"x","choices":[{"message":{"role":"assistant","content":"整包"}}]}`)
|
||
}))
|
||
defer bad.Close()
|
||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"真流式"}}]}`+"\n\n")
|
||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||
}))
|
||
defer good.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
addChannel(t, h, "c2", good.URL, 1)
|
||
pushWeightTo(t, h, "c1", 1000)
|
||
|
||
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
out := w.Body.String()
|
||
if !strings.HasPrefix(out, "data:") || !strings.Contains(out, "真流式") {
|
||
t.Fatalf("应从渠道2 收到 SSE 流:%q", out)
|
||
}
|
||
}
|
||
|
||
// 反向 didNotStream:要整包,渠道回了 event-stream,同样换渠道。
|
||
func TestNonStreamReceivingStreamFailsOver(t *testing.T) {
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||
}))
|
||
defer bad.Close()
|
||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = io.WriteString(w, `{"id":"ok","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
|
||
}))
|
||
defer good.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
addChannel(t, h, "c2", good.URL, 1)
|
||
pushWeightTo(t, h, "c1", 1000)
|
||
|
||
w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"content":"hi"`) {
|
||
t.Fatalf("应从渠道2 收到整包 JSON:code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// 首 token 计时:流式放行前迟迟不出内容,超时取消本次尝试、换下一个渠道。
|
||
func TestFirstTokenTimeoutFailsOver(t *testing.T) {
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
w.(http.Flusher).Flush()
|
||
select {
|
||
case <-r.Context().Done(): // 被首 token 计时器取消
|
||
case <-time.After(5 * time.Second):
|
||
}
|
||
}))
|
||
defer bad.Close()
|
||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"及时的内容"}}]}`+"\n\n")
|
||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||
}))
|
||
defer good.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
h.gw.firstTokenWait = 150 * time.Millisecond
|
||
addChannel(t, h, "c2", good.URL, 1)
|
||
pushWeightTo(t, h, "c1", 1000)
|
||
|
||
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "及时的内容") {
|
||
t.Fatalf("首 token 超时应换渠道:code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// 失败尝试扫到的 usage 不得计入最终账单:渠道1 的 message_start 式用量随失败被丢弃。
|
||
func TestUsageFromFailedTryNotBilled(t *testing.T) {
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
// role delta + usage 但没有内容:usage 会被扫进 trySink,随后错误事件导致整次尝试废弃
|
||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"role":"assistant"}}],"usage":{"prompt_tokens":500,"completion_tokens":0}}`+"\n\n")
|
||
_, _ = io.WriteString(w, `data: {"error":{"message":"boom"}}`+"\n\n")
|
||
}))
|
||
defer bad.Close()
|
||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"答案"}}]}`+"\n\n")
|
||
_, _ = io.WriteString(w, `data: {"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}`+"\n\n")
|
||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||
}))
|
||
defer good.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
addChannel(t, h, "c2", good.URL, 1)
|
||
pushWeightTo(t, h, "c1", 1000)
|
||
|
||
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
h.rec.Close() // usage 异步批量落库,先冲刷再查
|
||
var logs []store.UsageLog
|
||
h.db.Where("status = ?", store.UsageStatusSuccess).Find(&logs)
|
||
if len(logs) != 1 {
|
||
t.Fatalf("成功记账应只有一条,实际 %d", len(logs))
|
||
}
|
||
if logs[0].InputTokens != 7 || logs[0].OutputTokens != 3 {
|
||
t.Fatalf("应只计渠道2 的用量 (7/3),实际 in=%d out=%d", logs[0].InputTokens, logs[0].OutputTokens)
|
||
}
|
||
}
|
||
|
||
// 响应头已被心跳先行写出后,全部候选失败只能以 SSE 错误事件收尾(状态码已不可改)。
|
||
func TestStreamErrorEventWhenHeadAlreadySent(t *testing.T) {
|
||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
time.Sleep(200 * time.Millisecond) // 期间心跳先把 200 头写给客户端
|
||
_, _ = io.WriteString(w, `data: {"error":{"message":"boom"}}`+"\n\n")
|
||
}))
|
||
defer bad.Close()
|
||
|
||
h := newHarness(t, bad.URL, 0, true, nil)
|
||
h.gw.streamHeartbeat = 40 * time.Millisecond
|
||
|
||
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("头已写出,状态应为 200:code=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
out := w.Body.String()
|
||
if !strings.Contains(out, ": ping") {
|
||
t.Fatalf("扣住期间应有心跳:%q", out)
|
||
}
|
||
if !strings.Contains(out, `"upstream_error"`) {
|
||
t.Fatalf("应以 SSE 错误事件收尾:%q", out)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 判定函数单测
|
||
|
||
func TestSSEEventHasContent(t *testing.T) {
|
||
cases := []struct {
|
||
proto string
|
||
line string
|
||
want bool
|
||
}{
|
||
// chat:role-only 与空 delta 不算内容,content/reasoning/tool_calls 算
|
||
{"chat", `data: {"choices":[{"delta":{"role":"assistant"}}]}`, false},
|
||
{"chat", `data: {"choices":[{"delta":{}}]}`, false},
|
||
{"chat", `data: {"choices":[{"delta":{"content":"x"}}]}`, true},
|
||
{"chat", `data: {"choices":[{"delta":{"reasoning_content":"想"}}]}`, true},
|
||
{"chat", `data: {"choices":[{"delta":{"tool_calls":[{"id":"1"}]}}]}`, true},
|
||
{"chat", "data: [DONE]", false},
|
||
// messages:message_start / ping 不算,块开启与 delta 算
|
||
{"messages", `data: {"type":"message_start","message":{"usage":{"input_tokens":1}}}`, false},
|
||
{"messages", `data: {"type":"ping"}`, false},
|
||
{"messages", `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, true},
|
||
{"messages", `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t","name":"f"}}`, true},
|
||
{"messages", `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"x"}}`, true},
|
||
{"messages", `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"想"}}`, true},
|
||
// responses:.delta 与 output_item.added 算,created/completed 不算
|
||
{"responses", `data: {"type":"response.created","response":{}}`, false},
|
||
{"responses", `data: {"type":"response.output_text.delta","delta":"x"}`, true},
|
||
{"responses", `data: {"type":"response.output_item.added","item":{"type":"function_call"}}`, true},
|
||
}
|
||
for i, tc := range cases {
|
||
if got := sseEventHasContent([]byte(tc.line), tc.proto); got != tc.want {
|
||
t.Fatalf("case %d (%s): sseEventHasContent(%s) = %v, want %v", i, tc.proto, tc.line, got, tc.want)
|
||
}
|
||
}
|
||
// 未知协议:任何数据事件都算内容(退化放行,不吞正文)
|
||
if !sseEventHasContent([]byte(`data: {"weird":true}`), "other") {
|
||
t.Fatal("未知协议应放行")
|
||
}
|
||
}
|
||
|
||
func TestSSEUpstreamError(t *testing.T) {
|
||
cases := []struct {
|
||
line string
|
||
want bool
|
||
contain string
|
||
}{
|
||
{`data: {"error":{"message":"overloaded","type":"server_error"}}`, true, "overloaded"},
|
||
{`data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`, true, "Overloaded"},
|
||
{`data: {"type":"error","code":500}`, true, "upstream stream error"},
|
||
{`data: {"type":"response.failed","response":{"error":{"message":"bad tool"}}}`, true, "bad tool"},
|
||
{`data: {"choices":[{"delta":{"content":"hi"}}]}`, false, ""},
|
||
{`data: {"type":"content_block_start","content_block":{"type":"text"}}`, false, ""},
|
||
{`data: {"error":null}`, false, ""},
|
||
{`event: message_start`, false, ""},
|
||
}
|
||
for i, tc := range cases {
|
||
msg, ok := sseUpstreamError([]byte(tc.line))
|
||
if ok != tc.want {
|
||
t.Fatalf("case %d: sseUpstreamError(%s) ok=%v, want %v", i, tc.line, ok, tc.want)
|
||
}
|
||
if ok && !strings.Contains(msg, tc.contain) {
|
||
t.Fatalf("case %d: msg=%q, want contains %q", i, msg, tc.contain)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestClassifyUpstreamError(t *testing.T) {
|
||
cases := []struct {
|
||
status int
|
||
body string
|
||
kind string
|
||
retryable bool
|
||
}{
|
||
{http.StatusTooManyRequests, `{"error":{"message":"Rate limit reached"}}`, "rate_limited", true},
|
||
{http.StatusTooManyRequests, `{"error":{"message":"You exceeded your current quota, check your plan and billing details"}}`, "quota_exhausted", true},
|
||
{http.StatusTooManyRequests, `{"error":{"code":"1113","message":"余额不足"}}`, "quota_exhausted", true},
|
||
{http.StatusPaymentRequired, `{"error":{"message":"insufficient balance"}}`, "quota_exhausted", true},
|
||
{http.StatusForbidden, `{"error":{"message":"insufficient quota"}}`, "quota_exhausted", true},
|
||
{http.StatusForbidden, `{"error":{"message":"model not allowed"}}`, "server_error", false},
|
||
{http.StatusBadGateway, `{"error":{"message":"upstream gone"}}`, "server_error", true},
|
||
{http.StatusBadRequest, `{"error":{"message":"bad request"}}`, "server_error", false},
|
||
}
|
||
for i, tc := range cases {
|
||
kind, retryable := classifyUpstreamError(tc.status, []byte(tc.body))
|
||
if kind.String() != tc.kind || retryable != tc.retryable {
|
||
t.Fatalf("case %d: classify(%d) = (%s, %v), want (%s, %v)", i, tc.status, kind, retryable, tc.kind, tc.retryable)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestStreamErrorEventShapes(t *testing.T) {
|
||
chat := string(streamErrorEvent("chat", 502, "boom"))
|
||
if !strings.HasPrefix(chat, "data: ") || !strings.Contains(chat, `"code":"upstream_error"`) || !strings.Contains(chat, "data: [DONE]") {
|
||
t.Fatalf("chat 错误事件形状不符:%q", chat)
|
||
}
|
||
msg := string(streamErrorEvent("messages", 502, "boom"))
|
||
if !strings.HasPrefix(msg, "event: error\ndata: ") {
|
||
t.Fatalf("messages 错误事件形状不符:%q", msg)
|
||
}
|
||
var m map[string]any
|
||
if err := json.Unmarshal([]byte(strings.TrimPrefix(strings.SplitN(msg, "data: ", 2)[1], "\n\n")), &m); err != nil {
|
||
t.Fatalf("messages 错误事件不是合法 JSON: %v (%s)", err, msg)
|
||
}
|
||
resp := string(streamErrorEvent("responses", 502, "boom"))
|
||
if !strings.HasPrefix(resp, "data: ") || strings.Contains(resp, "[DONE]") {
|
||
t.Fatalf("responses 错误事件形状不符:%q", resp)
|
||
}
|
||
}
|