- 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 下全绿
95 lines
3.2 KiB
Go
95 lines
3.2 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/nyoungo/dsfree2api/internal/openai"
|
||
)
|
||
|
||
// 回归:非流式 /v1/chat/completions 的 choices[0].message.role 必须是
|
||
// "assistant"。Role 的 json tag 没有 omitempty(types.go:163),漏设就会
|
||
// 返回空串;流式路径与非流式路径必须一致。
|
||
func TestBuildCompletionRoleIsAssistant(t *testing.T) {
|
||
resp := buildCompletion(openai.ChatCompletionRequest{Model: "m"}, "p", "hello")
|
||
if len(resp.Choices) != 1 {
|
||
t.Fatalf("choices = %d", len(resp.Choices))
|
||
}
|
||
got := resp.Choices[0].Message.Role
|
||
if got != "assistant" {
|
||
raw, _ := json.Marshal(resp.Choices[0].Message)
|
||
t.Fatalf("choices[0].message.role = %q (want \"assistant\"), raw=%s", got, raw)
|
||
}
|
||
}
|
||
|
||
// 回归:/v1/responses 的 usage 必须用 Responses API 的字段名
|
||
// (input_tokens/output_tokens),而不是 Chat Completions 的
|
||
// prompt_tokens/completion_tokens —— 后者会让客户端读到 None。
|
||
// 官方文档:"Chat Completions reports prompt_tokens, completion_tokens, and
|
||
// total_tokens. Responses reports input_tokens, output_tokens, and total_tokens."
|
||
func TestResponsesUsageFieldNames(t *testing.T) {
|
||
m := responsesUsage(openai.Usage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3})
|
||
raw, err := json.Marshal(m)
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
var got map[string]any
|
||
if err := json.Unmarshal(raw, &got); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
for _, want := range []string{"input_tokens", "output_tokens", "total_tokens"} {
|
||
if _, ok := got[want]; !ok {
|
||
t.Fatalf("Responses API usage 缺少 %s,实际字段: %v", want, keys(got))
|
||
}
|
||
}
|
||
for _, bad := range []string{"prompt_tokens", "completion_tokens"} {
|
||
if _, ok := got[bad]; ok {
|
||
t.Fatalf("Responses API usage 不应包含 Chat 字段 %s,实际字段: %v", bad, keys(got))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回归:请求体必须有上限(api_keys 为空时这些端点未鉴权),超限要返回 413
|
||
// 而不是把整个 body 读进内存。
|
||
func TestDecodeBodyRejectsOversizedPayload(t *testing.T) {
|
||
payload := []byte(`{"model":"m","messages":[{"role":"user","content":"` +
|
||
strings.Repeat("a", maxRequestBodyBytes+4096) + `"}]}`)
|
||
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(payload))
|
||
w := httptest.NewRecorder()
|
||
|
||
var req openai.ChatCompletionRequest
|
||
if decodeBody(w, r, &req, writeError) {
|
||
t.Fatalf("超过上限的请求体被接受了")
|
||
}
|
||
if w.Code != http.StatusRequestEntityTooLarge {
|
||
t.Fatalf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge)
|
||
}
|
||
}
|
||
|
||
// 回归:未超限的正常请求体仍然可以解码。
|
||
func TestDecodeBodyAcceptsNormalPayload(t *testing.T) {
|
||
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
||
strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`))
|
||
w := httptest.NewRecorder()
|
||
|
||
var req openai.ChatCompletionRequest
|
||
if !decodeBody(w, r, &req, writeError) {
|
||
t.Fatalf("正常请求体被拒绝: %s", w.Body.String())
|
||
}
|
||
if req.Model != "m" {
|
||
t.Fatalf("model = %q", req.Model)
|
||
}
|
||
}
|
||
|
||
func keys(m map[string]any) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|