- Chat 接口拒绝 n>1(此前静默丢弃多余 choice),返回 400 结构化错误 - Responses 接口透传 text.format(json_schema / json_object),三种接口协议对齐 - TLS 指纹统一升级到 Chrome_150(internal/httpx.DefaultProfile),与 UA 匹配 - metrics 落盘失败不再静默:Save() 返回错误并打日志,临时文件失败会清理 - GET /api/config 脱敏 admin.password 与 turnstile.api_key(控制台从不展示, 不应随 5s 轮询下发);PUT 改走 ReplaceJSON,先校验后替换、失败回滚 - set_limits / set_turnstile 改指针部分更新:缺字段保持现状,不会把并发、 限流或求解开关按零值清掉 - 读请求体统一 64MB 上限;配置写盘原子替换 + 0600 权限 - main.go 改为 run() 返回退出码:日志落盘、指标落盘、xray 子进程回收全部 挂 defer,任何退出路径(含启动失败)都会执行,杜绝孤儿 xray - proxypool / turnstile / upstream / openai / config / httpx / logbuf 等 逐项补红绿回归测试,新增 14 个测试文件,race 下全绿
296 lines
8.3 KiB
Go
296 lines
8.3 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nyoungo/dsfree2api/internal/config"
|
|
"github.com/nyoungo/dsfree2api/internal/logbuf"
|
|
"github.com/nyoungo/dsfree2api/internal/metrics"
|
|
"github.com/nyoungo/dsfree2api/internal/turnstile"
|
|
"github.com/nyoungo/dsfree2api/internal/upstream"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
up *upstream.Client
|
|
met *metrics.Recorder
|
|
logs *logbuf.Buffer
|
|
ts *turnstile.Solver
|
|
log *slog.Logger
|
|
lim *limiter
|
|
start time.Time
|
|
}
|
|
|
|
func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *logbuf.Buffer, ts *turnstile.Solver, log *slog.Logger) *Server {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &Server{
|
|
cfg: cfg, up: up, met: met, logs: logs, ts: ts, log: log,
|
|
lim: newLimiter(),
|
|
start: time.Now(),
|
|
}
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
mux.HandleFunc("GET /{$}", s.handleRoot)
|
|
mux.HandleFunc("GET /v1/models", s.authed(s.handleModels))
|
|
mux.HandleFunc("POST /v1/chat/completions", s.authed(s.handleChat))
|
|
mux.HandleFunc("POST /v1/responses", s.authed(s.handleResponses))
|
|
mux.HandleFunc("POST /v1/messages", s.authedAnthropic(s.handleMessages))
|
|
return s.recoverPanic(s.logRequest(mux))
|
|
}
|
|
|
|
func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "dsfree2api"})
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
s.cfg.RLock()
|
|
models, sites := 0, []string{}
|
|
for id, m := range s.cfg.Models {
|
|
if m.Enabled {
|
|
models++
|
|
}
|
|
_ = id
|
|
}
|
|
for code, site := range s.cfg.Sites {
|
|
if site.Enabled {
|
|
sites = append(sites, code)
|
|
}
|
|
}
|
|
turnstileOn := s.cfg.Turnstile.Enabled
|
|
s.cfg.RUnlock()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"models": models,
|
|
"sites": sites,
|
|
"turnstile": turnstileOn,
|
|
"uptime_s": int(time.Since(s.start).Seconds()),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleModels(w http.ResponseWriter, _ *http.Request) {
|
|
s.cfg.RLock()
|
|
now := time.Now().Unix()
|
|
data := make([]map[string]any, 0, len(s.cfg.Models))
|
|
for id, m := range s.cfg.Models {
|
|
if !m.Enabled {
|
|
continue
|
|
}
|
|
data = append(data, map[string]any{
|
|
"id": id, "object": "model", "created": now, "owned_by": "deepseek-" + m.Site,
|
|
})
|
|
}
|
|
s.cfg.RUnlock()
|
|
sortModelInfo(data)
|
|
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
|
|
}
|
|
|
|
// ── middleware ───────────────────────────────────────────────────
|
|
|
|
// errWriter writes an auth/rate-limit failure in the wire format of the
|
|
// endpoint family it guards.
|
|
type errWriter func(w http.ResponseWriter, code int, msg, typ string)
|
|
|
|
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
|
|
return s.authedWith(next, writeError)
|
|
}
|
|
|
|
// authedAnthropic guards Anthropic-format endpoints, so failures come back as
|
|
// {"type":"error","error":{...}} instead of the OpenAI error shape.
|
|
func (s *Server) authedAnthropic(next http.HandlerFunc) http.HandlerFunc {
|
|
return s.authedWith(next, writeAnthropicError)
|
|
}
|
|
|
|
func (s *Server) authedWith(next http.HandlerFunc, fail errWriter) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
s.cfg.RLock()
|
|
keys := append([]string(nil), s.cfg.Security.APIKeys...)
|
|
s.cfg.RUnlock()
|
|
|
|
if len(keys) > 0 {
|
|
token := extractKey(r)
|
|
if token == "" || !matchesAny(keys, token) {
|
|
s.met.Record(metrics.Record{Status: metrics.StatusDenied})
|
|
fail(w, http.StatusUnauthorized, "invalid api key", "invalid_request_error")
|
|
return
|
|
}
|
|
s.cfg.RLock()
|
|
rate := int(s.cfg.Limits.RatePerMinute)
|
|
s.cfg.RUnlock()
|
|
if !s.lim.allowWithLimit(token, rate) {
|
|
s.met.Record(metrics.Record{Status: metrics.StatusRateLimit})
|
|
fail(w, http.StatusTooManyRequests, "rate limit exceeded", "rate_limit_error")
|
|
return
|
|
}
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func extractKey(r *http.Request) string {
|
|
if v := r.Header.Get("X-Api-Key"); v != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
auth := r.Header.Get("Authorization")
|
|
if auth == "" {
|
|
return ""
|
|
}
|
|
scheme, value, ok := strings.Cut(auth, " ")
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(scheme), "bearer") {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func matchesAny(keys []string, token string) bool {
|
|
ok := false
|
|
for _, k := range keys {
|
|
if subtle.ConstantTimeCompare([]byte(k), []byte(token)) == 1 {
|
|
ok = true
|
|
}
|
|
}
|
|
return ok
|
|
}
|
|
|
|
func (s *Server) logRequest(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/health" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
start := time.Now()
|
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
|
defer func() {
|
|
if r.URL.Path != "/" {
|
|
s.log.Debug("http",
|
|
"method", r.Method, "path", r.URL.Path,
|
|
"status", sw.status, "ms", time.Since(start).Milliseconds())
|
|
}
|
|
}()
|
|
next.ServeHTTP(sw, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) recoverPanic(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tw := &headerTracker{ResponseWriter: w}
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
s.log.Error("panic", "err", rec, "stack", string(debug.Stack()))
|
|
// 响应头已经发出去(SSE 已开流)就不能再改状态码:硬写 500
|
|
// 只会把错误 JSON 拼进事件流,客户端解析流会直接失败。
|
|
if !tw.wroteHeader {
|
|
writeJSON(tw, http.StatusInternalServerError, map[string]any{
|
|
"error": map[string]any{"message": "internal server error", "type": "server_error"},
|
|
})
|
|
}
|
|
}
|
|
}()
|
|
next.ServeHTTP(tw, r)
|
|
})
|
|
}
|
|
|
|
// headerTracker 记录响应头是否已发出,并把 Flush 透传给底层 writer。
|
|
type headerTracker struct {
|
|
http.ResponseWriter
|
|
wroteHeader bool
|
|
}
|
|
|
|
func (w *headerTracker) WriteHeader(code int) {
|
|
w.wroteHeader = true
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *headerTracker) Write(b []byte) (int, error) {
|
|
if !w.wroteHeader {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func (w *headerTracker) Flush() {
|
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
w.status = code
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *statusWriter) Flush() {
|
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, msg, typ string) {
|
|
writeJSON(w, code, map[string]any{
|
|
"error": map[string]any{"message": msg, "type": typ},
|
|
})
|
|
}
|
|
|
|
// maxRequestBodyBytes caps every public endpoint's body: these endpoints are
|
|
// unauthenticated when api_keys is empty, so an uncapped body would let one
|
|
// client pin a connection and allocate without bound.
|
|
const maxRequestBodyBytes = 8 << 20
|
|
|
|
type errorWriter func(w http.ResponseWriter, code int, msg, typ string)
|
|
|
|
// decodeBody caps the request body before JSON-decoding it and reports any
|
|
// failure through the caller's error shape (OpenAI vs Anthropic). It returns
|
|
// false after having written the error response.
|
|
func decodeBody(w http.ResponseWriter, r *http.Request, dst any, fail errorWriter) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
|
|
err := json.NewDecoder(r.Body).Decode(dst)
|
|
if err == nil {
|
|
return true
|
|
}
|
|
var mbe *http.MaxBytesError
|
|
if errors.As(err, &mbe) {
|
|
fail(w, http.StatusRequestEntityTooLarge, "request body too large", "invalid_request_error")
|
|
return false
|
|
}
|
|
fail(w, http.StatusBadRequest, "invalid JSON body: "+err.Error(), "invalid_request_error")
|
|
return false
|
|
}
|
|
|
|
func sortModelInfo(data []map[string]any) {
|
|
for i := 1; i < len(data); i++ {
|
|
for j := i; j > 0; j-- {
|
|
a, _ := data[j]["id"].(string)
|
|
b, _ := data[j-1]["id"].(string)
|
|
if a < b {
|
|
data[j], data[j-1] = data[j-1], data[j]
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|