Files
nyoungo 86492460b0 feat: 工具调用解析、Responses 状态缓存、幂等重放与模型别名
- 重写工具调用解析:XML、DSML 未加引号键、fence 内跳过、参数外溢等形状,并按请求携带的 tools 过滤工具名
- 新增 GET /v1/responses/{id} 与 previous_response_id 有界状态缓存(进程内、TTL、store=false 不写入)
- /v1/chat/completions 支持 Idempotency-Key 幂等回放(1 MiB 上限、24h TTL、并发去重)
- 模型别名与 default_model 降级;错误体统一输出 param/code
- 补 e2e 修复场景与 docs/compat-audit.md 兼容性审计
2026-10-09 00:50:01 +08:00

287 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/nyoungo/dsfree2api/internal/config"
"github.com/nyoungo/dsfree2api/internal/logbuf"
"github.com/nyoungo/dsfree2api/internal/metrics"
"github.com/nyoungo/dsfree2api/internal/turnstile"
)
const examplePath = "../../config.example.toml"
const testKey = "sk-dsfr-local-change-me"
func newTestServer(t *testing.T, mutate func(*config.Config)) *httptest.Server {
t.Helper()
cfg, err := config.Load(examplePath)
if err != nil {
t.Fatalf("load config: %v", err)
}
cfg.Security.APIKeys = []string{testKey}
if mutate != nil {
mutate(cfg)
}
met := metrics.New(t.TempDir())
met.Start()
t.Cleanup(met.Close)
logs := logbuf.New(50)
ts := turnstile.New(cfg.Turnstile, "")
srv := New(cfg, nil, met, logs, ts, slog.New(slog.NewTextHandler(io.Discard, nil)))
tsrv := httptest.NewServer(srv.Handler())
t.Cleanup(tsrv.Close)
return tsrv
}
func get(t *testing.T, url string, headers map[string]string) (*http.Response, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(body)
}
func TestModelsRequiresAuth(t *testing.T) {
srv := newTestServer(t, nil)
resp, _ := get(t, srv.URL+"/v1/models", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestModelsRejectsWrongKey(t *testing.T) {
srv := newTestServer(t, nil)
resp, _ := get(t, srv.URL+"/v1/models", map[string]string{"Authorization": "Bearer sk-wrong"})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestModelsListsWithAuth(t *testing.T) {
srv := newTestServer(t, nil)
resp, body := get(t, srv.URL+"/v1/models", map[string]string{"Authorization": "Bearer " + testKey})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d body=%s", resp.StatusCode, body)
}
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode: %v", err)
}
// 6 个 [models.*] + config.example.toml 里配的 2 个别名
if len(out.Data) != 8 {
t.Fatalf("len(data) = %d, want 8 (6 models + 2 aliases)", len(out.Data))
}
ids := map[string]bool{}
for _, m := range out.Data {
ids[m.ID] = true
}
for _, want := range []string{"deepseek-v4-flash-es", "deepseek-v4-pro-de", "gpt-5"} {
if !ids[want] {
t.Errorf("missing model %q", want)
}
}
}
func TestModelsWithXApiKeyHeader(t *testing.T) {
srv := newTestServer(t, nil)
resp, _ := get(t, srv.URL+"/v1/models", map[string]string{"X-Api-Key": testKey})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestNoKeysMeansNoAuth(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.Security.APIKeys = nil })
resp, _ := get(t, srv.URL+"/v1/models", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestRateLimit(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.Limits.RatePerMinute = 1 })
h := map[string]string{"Authorization": "Bearer " + testKey}
if resp, _ := get(t, srv.URL+"/v1/models", h); resp.StatusCode != http.StatusOK {
t.Fatalf("first status = %d, want 200", resp.StatusCode)
}
if resp, _ := get(t, srv.URL+"/v1/models", h); resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("second status = %d, want 429", resp.StatusCode)
}
}
func TestHealthAndRoot(t *testing.T) {
srv := newTestServer(t, nil)
resp, body := get(t, srv.URL+"/health", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("health status = %d", resp.StatusCode)
}
var health map[string]any
if err := json.Unmarshal([]byte(body), &health); err != nil {
t.Fatalf("decode: %v", err)
}
if health["status"] != "ok" {
t.Errorf("health = %v", health)
}
if _, ok := health["models"]; !ok {
t.Errorf("health missing models: %v", health)
}
}
func TestUnknownRouteIs404(t *testing.T) {
srv := newTestServer(t, nil)
resp, _ := get(t, srv.URL+"/v1/nope", map[string]string{"Authorization": "Bearer " + testKey})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func post(t *testing.T, url string, headers map[string]string, body string) (*http.Response, string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(raw)
}
func anthropicErrType(body string) string {
var out struct {
Type string `json:"type"`
Error struct {
Type string `json:"type"`
} `json:"error"`
}
_ = json.Unmarshal([]byte(body), &out)
if out.Type == "error" {
return out.Error.Type
}
return ""
}
// openaiErrType 解析 OpenAI 风格错误体 {"error":{"type":...}}。
func openaiErrType(body string) string {
var out struct {
Error struct {
Type string `json:"type"`
} `json:"error"`
}
_ = json.Unmarshal([]byte(body), &out)
return out.Error.Type
}
// openaiErrMsg 取出 error.message(JSON 会把 ">" 转义成 >,
// 所以断言文案必须走反序列化,不能拿原文子串匹配)。
func openaiErrMsg(body string) string {
var out struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal([]byte(body), &out)
return out.Error.Message
}
func TestMessagesRequiresAuthInAnthropicFormat(t *testing.T) {
srv := newTestServer(t, nil)
resp, body := post(t, srv.URL+"/v1/messages", nil, `{}`)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d body=%s, want 401", resp.StatusCode, body)
}
if got := anthropicErrType(body); got != "authentication_error" {
t.Errorf("error type = %q, want authentication_error; body=%s", got, body)
}
}
func TestMessagesValidation(t *testing.T) {
srv := newTestServer(t, nil)
h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"}
resp, body := post(t, srv.URL+"/v1/messages", h, `{}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty body status = %d body=%s, want 400", resp.StatusCode, body)
}
if got := anthropicErrType(body); got != "invalid_request_error" {
t.Errorf("error type = %q, want invalid_request_error", got)
}
resp, body = post(t, srv.URL+"/v1/messages", h,
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}]}`)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown model status = %d body=%s, want 404", resp.StatusCode, body)
}
if got := anthropicErrType(body); got != "not_found_error" {
t.Errorf("error type = %q, want not_found_error", got)
}
}
func TestResponsesValidation(t *testing.T) {
srv := newTestServer(t, nil)
h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"}
resp, body := post(t, srv.URL+"/v1/responses", h, `{"model":"deepseek-v4-flash-de"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("missing input status = %d body=%s, want 400", resp.StatusCode, body)
}
resp, body = post(t, srv.URL+"/v1/responses", h,
`{"model":"no-such-model","input":"hi"}`)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown model status = %d body=%s, want 404", resp.StatusCode, body)
}
}
// 回归:n>1 必须 400 —— 网关只产 1 条 choice,静默忽略会让客户端以为
// 拿到了 n 条候选。校验排在模型查找之前(模型不存在也先报参数错)。
func TestChatRejectsNGreaterThanOne(t *testing.T) {
srv := newTestServer(t, nil)
h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"}
resp, body := post(t, srv.URL+"/v1/chat/completions", h,
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}],"n":2}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("n=2 status = %d body=%s, want 400", resp.StatusCode, body)
}
if !strings.Contains(openaiErrMsg(body), "n > 1") {
t.Errorf("error message should mention n > 1, body=%s", body)
}
if got := openaiErrType(body); got != "invalid_request_error" {
t.Errorf("error type = %q, want invalid_request_error; body=%s", got, body)
}
// n=1(含省略)照常放行到模型查找,返回 404
resp, body = post(t, srv.URL+"/v1/chat/completions", h,
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}],"n":1}`)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("n=1 status = %d body=%s, want 404", resp.StatusCode, body)
}
}