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

192 lines
7.0 KiB
Go

package api
import (
"net/http"
"strings"
"testing"
"time"
"github.com/nyoungo/dsfree2api/internal/openai"
)
func authHeaders(key string) map[string]string {
return map[string]string{"Authorization": "Bearer " + key, "Content-Type": "application/json"}
}
// ── H2: Responses store ──────────────────────────────────────────
func TestGetResponseUnknownIs404(t *testing.T) {
srv := newTestServer(t, nil)
resp, body := get(t, srv.URL+"/v1/responses/resp_missing", map[string]string{"Authorization": "Bearer " + testKey})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d body=%s, want 404", resp.StatusCode, body)
}
if openaiErrType(body) == "" {
t.Errorf("expected an error body, got %s", body)
}
}
func TestPreviousResponseIDUnknownIs400(t *testing.T) {
srv := newTestServer(t, nil)
resp, body := post(t, srv.URL+"/v1/responses", authHeaders(testKey),
`{"model":"deepseek-v4-flash-de","input":"hi","previous_response_id":"resp_missing"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", resp.StatusCode, body)
}
}
func TestResponseStoreEvictsAndExpires(t *testing.T) {
s := newResponseStore(2, time.Minute)
s.insert("a", openai.StoredTurn{InputText: "1"})
s.insert("b", openai.StoredTurn{InputText: "2"})
s.insert("c", openai.StoredTurn{InputText: "3"})
if _, ok := s.get("a"); ok {
t.Fatal("oldest entry must be evicted")
}
if turn, ok := s.get("b"); !ok || turn.InputText != "2" {
t.Fatalf("b = %+v ok=%v", turn, ok)
}
if s.len() != 2 {
t.Fatalf("len = %d, want 2", s.len())
}
// Expire c manually and confirm get treats it as missing.
s.mu.Lock()
e := s.entries["c"]
e.inserted = time.Now().Add(-time.Hour)
s.entries["c"] = e
s.mu.Unlock()
if _, ok := s.get("c"); ok {
t.Fatal("expired entry must be missing")
}
if s.len() != 1 {
t.Fatalf("len after expiry = %d, want 1", s.len())
}
}
func TestResponseStoreGetResponse(t *testing.T) {
s := newResponseStore(4, time.Minute)
s.insert("r1", openai.StoredTurn{Response: map[string]any{"id": "r1", "object": "response"}})
snap, ok := s.getResponse("r1")
if !ok || snap["object"] != "response" {
t.Fatalf("snapshot = %v ok=%v", snap, ok)
}
if _, ok := s.getResponse("nope"); ok {
t.Fatal("unknown id must miss")
}
}
// ── H3: Idempotency ──────────────────────────────────────────────
func TestIdempotencyReplaysCompletedResponse(t *testing.T) {
srv := newTestServer(t, nil)
h := authHeaders(testKey)
h["Idempotency-Key"] = "idem-replay"
body := `{"model":"no-such-model","messages":[{"role":"user","content":"hi"}]}`
resp, first := post(t, srv.URL+"/v1/chat/completions", h, body)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("first status = %d body=%s, want 404", resp.StatusCode, first)
}
if resp.Header.Get("idempotent-replayed") != "" {
t.Fatalf("first response must not be marked replayed")
}
resp, second := post(t, srv.URL+"/v1/chat/completions", h, body)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("replay status = %d body=%s, want 404", resp.StatusCode, second)
}
if resp.Header.Get("idempotent-replayed") != "true" {
t.Fatalf("replay header missing; headers=%v", resp.Header)
}
if second != first {
t.Fatalf("replayed body differs:\n first=%s\nsecond=%s", first, second)
}
}
func TestIdempotencyConflictDifferentBody(t *testing.T) {
srv := newTestServer(t, nil)
h := authHeaders(testKey)
h["Idempotency-Key"] = "idem-conflict"
if resp, _ := post(t, srv.URL+"/v1/chat/completions", h,
`{"model":"no-such-model","messages":[{"role":"user","content":"a"}]}`); resp.StatusCode != http.StatusNotFound {
t.Fatalf("first status = %d, want 404", resp.StatusCode)
}
resp, body := post(t, srv.URL+"/v1/chat/completions", h,
`{"model":"no-such-model","messages":[{"role":"user","content":"b"}]}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("conflict status = %d body=%s, want 400", resp.StatusCode, body)
}
if openaiErrType(body) != "idempotency_error" {
t.Fatalf("error type = %q, want idempotency_error; body=%s", openaiErrType(body), body)
}
}
func TestIdempotencyRejectsLongKey(t *testing.T) {
srv := newTestServer(t, nil)
h := authHeaders(testKey)
h["Idempotency-Key"] = strings.Repeat("k", 256)
resp, body := post(t, srv.URL+"/v1/chat/completions", h, `{"model":"x","messages":[]}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", resp.StatusCode, body)
}
}
func TestIdempotencyAnthropicErrorShape(t *testing.T) {
srv := newTestServer(t, nil)
h := authHeaders(testKey)
h["Idempotency-Key"] = "idem-anthropic"
h["Content-Type"] = "application/json"
// Two different bodies → conflict, rendered in Anthropic error shape.
if resp, _ := post(t, srv.URL+"/v1/messages", h,
`{"model":"no-such-model","messages":[{"role":"user","content":"a"}]}`); resp.StatusCode != http.StatusNotFound {
t.Fatalf("first status = %d, want 404", resp.StatusCode)
}
resp, body := post(t, srv.URL+"/v1/messages", h,
`{"model":"no-such-model","messages":[{"role":"user","content":"b"}]}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", resp.StatusCode, body)
}
if got := anthropicErrType(body); got != "invalid_request_error" {
t.Fatalf("error type = %q, want invalid_request_error; body=%s", got, body)
}
}
func TestIdempotencyStoreLifecycle(t *testing.T) {
s := newIdempotencyStore()
fp := idemFingerprint("scope", "k", []byte("body"))
first := s.begin("scope", "k", fp)
if first.kind != idemFresh || first.guard == nil {
t.Fatalf("first begin = %+v, want fresh", first)
}
if got := s.begin("scope", "k", fp); got.kind != idemInProgress {
t.Fatalf("second begin = %v, want in-progress", got.kind)
}
if got := s.begin("scope", "k", idemFingerprint("scope", "k", []byte("other"))); got.kind != idemConflict {
t.Fatalf("different body = %v, want conflict", got.kind)
}
first.guard.finish(200, "application/json", []byte(`{"ok":true}`), false, false)
replay := s.begin("scope", "k", fp)
if replay.kind != idemReplay || replay.recorded.status != 200 || string(replay.recorded.body) != `{"ok":true}` {
t.Fatalf("replay = %+v", replay)
}
// Overflow marks the entry unreplayable (409 on the next try).
ov := s.begin("scope", "ov", idemFingerprint("scope", "ov", []byte("x")))
ov.guard.finish(200, "application/json", []byte("big"), true, false)
if got := s.begin("scope", "ov", idemFingerprint("scope", "ov", []byte("x"))); got.kind != idemUnreplayable {
t.Fatalf("overflow begin = %v, want unreplayable", got.kind)
}
// A dropped (unfinished) guard frees the key so a retry can re-run.
dropped := s.begin("scope", "drop", idemFingerprint("scope", "drop", []byte("y")))
dropped.guard.release()
if got := s.begin("scope", "drop", idemFingerprint("scope", "drop", []byte("y"))); got.kind != idemFresh {
t.Fatalf("after release begin = %v, want fresh", got.kind)
}
}