- 重写工具调用解析: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 兼容性审计
192 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|