M0:神识 Go 网关骨架(IR 入站/SSE 出站/stub 适配器/服务令牌/健康检查)及测试与镜像
This commit is contained in:
1 parent
7ef3a15061
commit
f3058a64c3
10 files changed
+514
No files matched your search
@@ -0,0 +1,110 @@
|
||||
// Package server 神识 HTTP 服务:数据面(IR 入站 → SSE 出站)与健康面。
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"hunjing/shenshi/internal/ir"
|
||||
"hunjing/shenshi/internal/relay"
|
||||
"hunjing/shenshi/internal/route"
|
||||
)
|
||||
|
||||
// Config 服务配置。
|
||||
type Config struct {
|
||||
ServiceToken string // 灵台→神识服务令牌;为空时开发档不校验
|
||||
}
|
||||
|
||||
// Server 神识 HTTP 服务。
|
||||
type Server struct {
|
||||
cfg Config
|
||||
routes *route.Registry
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New 构建服务并注册路由。
|
||||
func New(cfg Config, routes *route.Registry) *Server {
|
||||
s := &Server{cfg: cfg, routes: routes, 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)))
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP 实现 http.Handler。
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
||||
|
||||
// auth 服务令牌校验中间件;未配置令牌时放行(本地开发档)。
|
||||
func (s *Server) auth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.ServiceToken == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
credential := r.Header.Get("Authorization")
|
||||
if credential != "Bearer "+s.cfg.ServiceToken {
|
||||
writeError(w, http.StatusUnauthorized, "HJ-2003", "服务令牌无效")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
writeError(w, http.StatusBadRequest, "HJ-2002", "IR 请求解析失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "HJ-2002", err.Error())
|
||||
return
|
||||
}
|
||||
endpoints, err := s.routes.Resolve(req.Slot)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "HJ-2001", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
relay.SSEHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
deltas := make(chan ir.Delta, 16)
|
||||
streamErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(deltas)
|
||||
streamErr <- endpoints[0].Adaptor.Stream(r.Context(), &req, deltas)
|
||||
}()
|
||||
for d := range deltas {
|
||||
if err := relay.WriteDelta(w, d); err != nil {
|
||||
return // 客户端断开:取消上游由 r.Context() 传播
|
||||
}
|
||||
}
|
||||
if err := <-streamErr; err != nil {
|
||||
// 流已开始,只能如实中断:写一条错误帧后终止(不伪装 [DONE])
|
||||
_ = relay.WriteDelta(w, ir.Delta{FinishReason: "error", Delta: ""})
|
||||
_ = relay.WriteDone(w)
|
||||
return
|
||||
}
|
||||
if err := relay.WriteDone(w); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleHealth 健康面。
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// writeError 统一错误结构(API 契约 §5)。
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
body := map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hunjing/shenshi/internal/route"
|
||||
)
|
||||
|
||||
func newTestServer(token string) *httptest.Server {
|
||||
srv := New(Config{ServiceToken: token}, route.New())
|
||||
return httptest.NewServer(srv)
|
||||
}
|
||||
|
||||
func chatBody() string {
|
||||
return `{"ir_version":1,"slot":"chat","messages":[` +
|
||||
`{"role":"system","content":"契约占位"},{"role":"user","content":"你好"}],` +
|
||||
`"run_id":"run_test","stream":true}`
|
||||
}
|
||||
|
||||
func post(t *testing.T, ts *httptest.Server, token, body string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/chat/completions", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
var sb strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
sb.WriteString(scanner.Text())
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return resp, sb.String()
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamsEchoAndDone(t *testing.T) {
|
||||
ts := newTestServer("")
|
||||
resp, body := post(t, ts, "", chatBody())
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("X-Accel-Buffering"); got != "no" {
|
||||
t.Fatalf("X-Accel-Buffering = %q", got)
|
||||
}
|
||||
if !strings.Contains(body, `"delta":"【神识回声桩】"`) && !strings.Contains(body, "神识回声桩") {
|
||||
t.Fatalf("缺少回声增量: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"finish_reason":"stop"`) {
|
||||
t.Fatalf("缺少终帧用量: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "data: [DONE]") {
|
||||
t.Fatalf("缺少 [DONE]: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTokenRejectsInvalidCredential(t *testing.T) {
|
||||
ts := newTestServer("secret-token")
|
||||
resp, body := post(t, ts, "wrong", chatBody())
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("状态码 = %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(body, "HJ-2003") {
|
||||
t.Fatalf("缺少错误码: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidIRRejected(t *testing.T) {
|
||||
ts := newTestServer("")
|
||||
resp, body := post(t, ts, "", `{"ir_version":99,"messages":[],"run_id":"","stream":true}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("状态码 = %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(body, "HJ-2002") {
|
||||
t.Fatalf("缺少错误码: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSlotNoEndpoint(t *testing.T) {
|
||||
ts := newTestServer("")
|
||||
resp, body := post(t, ts, "", `{"ir_version":1,"slot":"vision","messages":[{"role":"user","content":"x"}],"run_id":"run_t","stream":true}`)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("状态码 = %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(body, "HJ-2001") {
|
||||
t.Fatalf("缺少错误码: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
ts := newTestServer("")
|
||||
resp, err := ts.Client().Get(ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user