M4a:神识全量网关能力——OpenAI 兼容适配器(元炁共用)、候选路由+凭证/绑定级冷却+首字超时切道+粘性会话、用量计量与 /me/usage,故障注入测试四场景

This commit is contained in:
chenxin committed 2026-08-27 09:32:37 +08:00
1 parent df5caa69e7
commit 7279904043
8 files changed
+717 -35

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
package server
import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"hunjing/shenshi/internal/route"
"hunjing/shenshi/internal/transform"
)
// fakeOpenAI 构造一个 OpenAI 兼容 SSE 假上游:可控延迟/状态码/内容。
func fakeOpenAI(t *testing.T, delay time.Duration, status int, content string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if status != 0 {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":{"message":"boom"}}`))
return
}
if delay > 0 {
time.Sleep(delay)
}
w.Header().Set("content-type", "text/event-stream")
flusher := w.(http.Flusher)
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"" + content + "\"}}]}\n\n"))
flusher.Flush()
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":4}}\n\n"))
flusher.Flush()
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
}
func registryWith(t *testing.T, firstByte time.Duration, eps ...route.Endpoint) *route.Registry {
t.Helper()
r := route.New()
r.FirstByteTimeout = firstByte
// 清掉默认 stub,仅保留测试端点
r.Reset("chat")
for _, ep := range eps {
r.Register("chat", ep)
}
return r
}
func oaEndpoint(name, baseURL string, priority int) route.Endpoint {
return route.Endpoint{
Name: name, Type: "openai_compatible", Priority: priority,
Adaptor: &transform.OpenAICompatAdaptor{EndpointName: name, BaseURL: baseURL, UpstreamModel: "test-model"},
}
}
func foPostChat(ts *httptest.Server, body string) (*http.Response, string) {
resp, err := ts.Client().Post(ts.URL+"/v1/chat/completions", "application/json", strings.NewReader(body))
if err != nil {
return nil, ""
}
var sb strings.Builder
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
sb.WriteString(scanner.Text())
sb.WriteString("\n")
}
return resp, sb.String()
}
func foChatBody(run string) string {
return `{"ir_version":1,"slot":"chat","messages":[{"role":"user","content":"你好"}],"run_id":"` + run + `","stream":true}`
}
// 场景:首端点首字超时 → 切道到次端点成功(不向客户端暴露失败)。
func TestFailoverOnFirstByteTimeout(t *testing.T) {
slow := fakeOpenAI(t, 500*time.Millisecond, 0, "慢端点")
fast := fakeOpenAI(t, 0, 0, "快端点")
defer slow.Close()
defer fast.Close()
srv := New(Config{}, registryWith(t, 50*time.Millisecond, oaEndpoint("slow", slow.URL, 10), oaEndpoint("fast", fast.URL, 20)))
ts := httptest.NewServer(srv)
defer ts.Close()
resp, body := foPostChat(ts, foChatBody("run_fo"))
if resp == nil || resp.StatusCode != http.StatusOK {
t.Fatalf("状态码异常:%v", resp)
}
if !strings.Contains(body, "快端点") || strings.Contains(body, "慢端点") {
t.Fatalf("应切道到快端点:%s", body)
}
if !strings.Contains(body, `"finish_reason":"stop"`) || !strings.Contains(body, "data: [DONE]") {
t.Fatalf("缺少终帧/DONE:%s", body)
}
}
// 场景:首端点 401 → 凭证级冷却;下一请求直接跳过它走次端点。
func TestCredentialCooldownSkipsEndpoint(t *testing.T) {
bad := fakeOpenAI(t, 0, http.StatusUnauthorized, "")
good := fakeOpenAI(t, 0, 0, "好端点")
defer bad.Close()
defer good.Close()
srv := New(Config{}, registryWith(t, time.Second, oaEndpoint("bad", bad.URL, 10), oaEndpoint("good", good.URL, 20)))
ts := httptest.NewServer(srv)
defer ts.Close()
// 第一次:bad 401 → 冷却 → 切 good
resp, body := foPostChat(ts, foChatBody("run_a"))
if resp == nil || resp.StatusCode != http.StatusOK || !strings.Contains(body, "好端点") {
t.Fatalf("首次应切到好端点:%v %s", resp, body)
}
// 第二次:bad 仍在冷却,直接命中 good(若未跳过会再花一次 401 往返——这里断言成功即可)
resp2, body2 := foPostChat(ts, foChatBody("run_b"))
if resp2 == nil || resp2.StatusCode != http.StatusOK || !strings.Contains(body2, "好端点") {
t.Fatalf("冷却后应直达好端点:%v", resp2)
}
}
// 场景:全部候选失败且未写字节 → HJ-2001 JSON(不是半截 SSE)。
func TestAllCandidatesFailedReturnsJsonError(t *testing.T) {
bad := fakeOpenAI(t, 0, http.StatusBadGateway, "")
defer bad.Close()
srv := New(Config{}, registryWith(t, time.Second, oaEndpoint("bad", bad.URL, 10)))
ts := httptest.NewServer(srv)
defer ts.Close()
resp, body := foPostChat(ts, foChatBody("run_x"))
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("状态码 = %d", resp.StatusCode)
}
if !strings.Contains(body, "HJ-2001") || strings.Contains(body, "text/event-stream") {
t.Fatalf("应为 JSON 错误而非 SSE:%s", body)
}
}
// 场景:用量聚合端点返回 ok 计数与 token。
func TestUsageAggregate(t *testing.T) {
good := fakeOpenAI(t, 0, 0, "内容")
defer good.Close()
srv := New(Config{}, registryWith(t, time.Second, oaEndpoint("good", good.URL, 10)))
ts := httptest.NewServer(srv)
defer ts.Close()
if resp, _ := foPostChat(ts, foChatBody("run_u")); resp == nil || resp.StatusCode != 200 {
t.Fatal("请求失败")
}
usageResp, err := ts.Client().Get(ts.URL + "/me/usage")
if err != nil {
t.Fatal(err)
}
var agg []map[string]any
defer func() { _ = usageResp.Body.Close() }()
if err := json.NewDecoder(usageResp.Body).Decode(&agg); err != nil {
t.Fatal(err)
}
if len(agg) != 1 || agg[0]["ok"].(float64) != 1 || agg[0]["prompt_tokens"].(float64) != 3 {
t.Fatalf("聚合不符:%v", agg)
}
}
+128 -19
View File
@@ -1,33 +1,42 @@
// Package server 神识 HTTP 服务:数据面(IR 入站 → SSE 出站)与健康面。
// Package server 神识 HTTP 服务:数据面(IR 入站 → SSE 出站)、健康面与用量面。
//
// 故障切换红线:首帧到达前不提交 SSE 响应——失败可整体换道并返回 JSON 错误;
// 已写出字节后不再换道,如实以 error 终帧收尾(shenshi-gateway.md §4/§5)。
package server
import (
"context"
"encoding/json"
"net/http"
"time"
"hunjing/shenshi/internal/ir"
"hunjing/shenshi/internal/metering"
"hunjing/shenshi/internal/relay"
"hunjing/shenshi/internal/route"
"hunjing/shenshi/internal/transform"
)
// Config 服务配置。
type Config struct {
ServiceToken string // 灵台→神识服务令牌;为空时开发档不校验
ServiceToken string
}
// Server 神识 HTTP 服务。
type Server struct {
cfg Config
routes *route.Registry
usage *metering.Log
mux *http.ServeMux
}
// New 构建服务并注册路由。
func New(cfg Config, routes *route.Registry) *Server {
s := &Server{cfg: cfg, routes: routes, mux: http.NewServeMux()}
s := &Server{cfg: cfg, routes: routes, usage: metering.New(10_000), mux: http.NewServeMux()}
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /readyz", s.handleHealth)
s.mux.Handle("POST /v1/chat/completions", s.auth(http.HandlerFunc(s.handleChat)))
s.mux.Handle("GET /me/usage", s.auth(http.HandlerFunc(s.handleUsage)))
return s
}
@@ -41,8 +50,7 @@ func (s *Server) auth(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
credential := r.Header.Get("Authorization")
if credential != "Bearer "+s.cfg.ServiceToken {
if r.Header.Get("Authorization") != "Bearer "+s.cfg.ServiceToken {
writeError(w, http.StatusUnauthorized, "HJ-2003", "服务令牌无效")
return
}
@@ -50,7 +58,7 @@ func (s *Server) auth(next http.Handler) http.Handler {
})
}
// handleChat 数据面:解析 IR → 路由 → 适配器流出 SSE。
// handleChat 数据面:IR → 候选迭代(首字超时/冷却标记/粘性)→ SSE。
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var req ir.ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -61,35 +69,136 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "HJ-2002", err.Error())
return
}
endpoints, err := s.routes.Resolve(req.Slot)
candidates, err := s.routes.Candidates(req.Slot, req.RunID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "HJ-2001", err.Error())
return
}
relay.SSEHeaders(w)
w.WriteHeader(http.StatusOK)
var lastErr error
for _, endpoint := range candidates {
res := s.tryEndpoint(w, r, endpoint, &req)
s.usage.Record(res.Attempt)
if res.Committed {
// 已向客户端写出:无论成败都不能再换道
if res.Status == "ok" {
s.routes.Sticky.Remember(req.RunID, endpoint.Name)
}
return
}
// 未提交:分类冷却后换下一候选
if res.Upstream != nil {
if res.Upstream.StatusCode == 401 || res.Upstream.StatusCode == 403 {
s.routes.Cooldowns.Mark(route.CredentialCooldownKey(endpoint), true)
} else {
s.routes.Cooldowns.Mark(route.BindingCooldownKey(endpoint), false)
}
} else {
s.routes.Cooldowns.Mark(route.BindingCooldownKey(endpoint), false)
}
lastErr = res.Err
}
writeError(w, http.StatusServiceUnavailable, "HJ-2001", "全部候选端点失败:"+errString(lastErr))
}
type attempt struct {
metering.Attempt
Committed bool // 是否已向客户端写出 SSE
Upstream *transform.UpstreamError // 上游分类(可空)
Err error // 原始错误
}
func errString(err error) string {
if err == nil {
return "未知错误"
}
return err.Error()
}
// tryEndpoint 单端点尝试。
func (s *Server) tryEndpoint(w http.ResponseWriter, r *http.Request, endpoint route.Endpoint, req *ir.ChatRequest) attempt {
start := time.Now()
ctx, cancel := context.WithTimeout(r.Context(), s.routes.FirstByteTimeout)
defer cancel()
deltas := make(chan ir.Delta, 16)
streamErr := make(chan error, 1)
errCh := make(chan error, 1)
go func() {
defer close(deltas)
streamErr <- endpoints[0].Adaptor.Stream(r.Context(), &req, deltas)
errCh <- endpoint.Adaptor.Stream(ctx, req, deltas)
}()
res := attempt{Attempt: metering.Attempt{
RunID: req.RunID, CharacterID: req.CharacterID,
Model: req.Model, Slot: req.Slot, Endpoint: endpoint.Name,
}}
// 阶段一:等首帧或失败——此阶段未提交响应,可整体回退
var first ir.Delta
select {
case d, ok := <-deltas:
if !ok {
res.Err = <-errCh
res.Upstream, _ = res.Err.(*transform.UpstreamError)
res.Status, res.TotalMS = failStatus(res.Err), time.Since(start).Milliseconds()
return res
}
first = d
case <-ctx.Done():
res.Status, res.Err = "first_byte_timeout", ctx.Err()
res.TotalMS = time.Since(start).Milliseconds()
return res
}
// 阶段二:首帧已到——提交 SSE,流式转发
relay.SSEHeaders(w)
w.WriteHeader(http.StatusOK)
res.Committed = true
res.FirstTokenMS = time.Since(start).Milliseconds()
_ = relay.WriteDelta(w, first)
for d := range deltas {
if err := relay.WriteDelta(w, d); err != nil {
return // 客户端断开:取消上游由 r.Context() 传播
if d.Delta != "" || d.FinishReason != "" {
if err := relay.WriteDelta(w, d); err != nil {
break // 客户端断开
}
}
if d.FinishReason != "" {
res.Attempt.PromptTokens = d.UsagePromptTokens
res.Attempt.CompletionTokens = d.UsageCompletionTokens
}
}
if err := <-streamErr; err != nil {
// 流已开始,只能如实中断:写一条错误帧后终止(不伪装 [DONE])
_ = relay.WriteDelta(w, ir.Delta{FinishReason: "error", Delta: ""})
err := <-errCh
res.TotalMS = time.Since(start).Milliseconds()
if err == nil {
_ = relay.WriteDone(w)
return
res.Status = "ok"
return res
}
if err := relay.WriteDone(w); err != nil {
return
// 流中失败:如实以 error 终帧收尾,不伪装完成
res.Err = err
res.Upstream, _ = err.(*transform.UpstreamError)
res.Status = "error"
res.ErrorCode = "stream_written"
_ = relay.WriteDelta(w, ir.Delta{FinishReason: "error"})
_ = relay.WriteDone(w)
return res
}
func failStatus(err error) string {
if up, ok := err.(*transform.UpstreamError); ok {
if up.StatusCode == 0 {
return "timeout_or_network"
}
return "upstream_error"
}
return "error"
}
// handleUsage 用量聚合(/me/usage;BYOK 归属维度随 M4b 接入)。
func (s *Server) handleUsage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.usage.Aggregate())
}
// handleHealth 健康面。