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 兼容性审计
This commit is contained in:
nyoungo committed 2026-10-09 00:50:01 +08:00
1 parent e44e960fe9
commit 86492460b0
37 files changed
+3571 -157

No files matched your search

+16
View File
@@ -128,6 +128,12 @@ warn_ratio = 0.2
[runtime]
data_dir = "./data"
# ── Responses API 状态缓存(previous_response_id / GET /v1/responses/{id})──
# 进程内、有界、带 TTL;store=false 的请求不写入。重启即失效。
[responses]
store_capacity = 256 # 最多保存的响应条数(1–100000)
store_ttl_seconds = 3600 # 条目存活秒数(1–604800)
[sites.de]
base_url = "https://deepseek.de"
ajax_url = "https://deepseek.de/wp-admin/admin-ajax.php"
@@ -198,3 +204,13 @@ label = "DeepSeek V4-Pro FR"
page_path = "/pro/"
bot_id = 27647
post_id = 27616
# 模型别名:把客户端发来的任意模型名(Codex CLI / Claude Code 会发 gpt-* 、claude-* 等)
# 映射到上面 [models.*] 里已启用的 id。键不区分大小写。
[model_aliases]
"gpt-5" = "deepseek-v4-flash-de"
"claude-sonnet-4-5" = "deepseek-v4-pro-de"
# 默认模型:model 和 model_aliases 都没命中时降级到此模型(留空 = 未知模型仍 404)。
# 注意:别名解析是尽力而为的兼容手段,显式配置别名优于依赖降级。
# default_model = "deepseek-v4-flash-de"
+138
View File
@@ -0,0 +1,138 @@
# OpenAI 兼容性审计(Chat Completions / Responses / Messages)
> 审计基准:`openai-openapi` 的 `CreateChatCompletionRequest` /
> `CreateChatCompletionStreamResponse` / `CreateResponse` / `ResponseStreamEvent`
> schema,以及 `openai-python` 的运行时解析逻辑。
> 审计对象:本仓库(`github.com/nyoungo/dsfree2api`)。
>
> 本文档记录**已核对**的差异与修复结论。每条结论标注规范来源,便于回归对照。
---
## 1. 工具调用修复(tool-call repair lineage)
模型在受限通道下会产出各种畸形工具调用。网关在 `internal/openai` 内做
宽容解析,避免把原始文本透传给客户端。
**规范**:Chat Completions 的 `tool_calls` 条目必须含 `type="function"` 与
`function.name`(`function.arguments` 为 JSON 字符串);Responses 的
`function_call` 条目必须含 `call_id` / `name` / `arguments`。
**已实现的修复**(`internal/openai/toolcalls.go`):
- JSON 外壳:`{"tool_calls":[...]}`、裸数组、裸对象、```json / ``` 围栏;
- 未加引号的键(`{name: "f"}` → `{"name": "f"}`);
- 非法反斜杠(`C:\Users` → `C:\\Users`);
- 字段别名:`function`/`params`/`parameters`/`args`/`input`;
- `name` 与 `arguments` 互换(`name` 为对象、`arguments` 为字符串);
- 参数外溢到顶层(`{"name":"f","city":"北京"}`);
- XML 方言:`<tool_call>` / `<tool_calls>` / `<invoke name>` + `<parameter>`,
以及 `<function><name>…<arguments>…`;
- 特殊标记 `<|tool▁calls▁begin|>` / `<|tool▁calls▁end|>`,并对 `|`(U+FF5C)、
`▁`(U+2581) 做模糊匹配;
- 代码围栏内的 XML 示例会被跳过(`isInsideCodeFence`),但围栏内的 JSON
仍按既有行为解析(由 `TestTryParseToolCallsMarkdownJSONBlock` 固定)。
**约束**:`function.name` 缺失的条目会被整条候选丢弃,绝不返回 `name` 为空的
调用(否则错误会被推迟到执行阶段才暴露)。
**已知差距**:流式路径(`internal/openai/argstream.go`)只对流式化的
`{"tool_calls":…}` 形状增量下发;上面这些新形状会在 `Finalize` 时作为完整
`tool_calls` 重发(`resend`),行为正确但不增量。可接受。
**回归**:`internal/openai/repair_test.go` 覆盖 10 个场景主题
(01–04 XML、05 字段别名、06 arguments 字符串、07/08 括号、09 互换、
10 参数外溢)。
---
## 2. 服务`param`/`code`字段缺失(OpenAI Error schema)
**规范**:OpenAI `Error` 的 `required` 为 `["type", "message", "param", "code"]`;
SDK 常按 `code` 区分 `model_not_found` 等语义。
**修复**:`internal/api/server.go` 的 `writeError` 现在总是输出
`message` / `type` / `param` / `code` 四个字段(不适用时为 `null`),并按状态码
推导:
- `401` → `code="invalid_api_key"`
- `404` 且 message 以 `model not found` 开头 → `param="model"`, `code="model_not_found"`
- `429` → `code="rate_limit_exceeded"`
- `413` → `code="request_too_large"`
- `502`/`504` → `code="upstream_error"` / `upstream_timeout"`
- `idempotency_error` → `code="idempotency_error"`
Anthropic 信封(`writeAnthropicError`)保持 `{"type":"error","error":{type,message}}`
不变。
---
## 3. Responses API 状态(`previous_response_id` / `store`)
**规范**:`CreateResponse` 支持 `store`(默认 `true`)与 `previous_response_id`;
`GET /v1/responses/{id}` 返回响应快照。
**修复**:
- `openai.ResponsesRequest` 新增 `store *bool` 与 `previous_response_id string`;
- 进程内、有界、带 TTL 的缓存(`internal/api/responsestore.go`),
容量默认 256、TTL 默认 1h,可由 `[responses]` 配置;
- 非流式与流式完成路径都会写入(`store:false` 跳过);
- `previous_response_id` 命中时用 `openai.HistoryMessages` 重建上下文,
拼接到本轮 input 之前(system/developer 指令保持在最前);
- 过期 / 未知 `previous_response_id` → `400 invalid_request_error`;
`GET` 未知或过期 → `404`。
**取舍**:绝不落盘(响应可能含用户内容),重启即失效,与上游无关的
无状态网关定位一致。
---
## 4. `Idempotency-Key` 语义
**规范**:Stripe / OpenAI 约定。
实现(`internal/api/idempotency.go`),作用于
`POST /v1/chat/completions`、`/v1/responses`、`/v1/messages`:
- 作用域 = `(API key, method+path, Idempotency-Key)`;
- 指纹 = `sha256(scope|key|body)`;
- 同键 + 不同请求体 → `400 idempotency_error`;
- 首次执行期间再次到达 → `409 idempotency_error`;
- 已完成 → 逐字节回放(状态码 / `Content-Type` / 响应体),
并带 `idempotent-replayed: true`;
- 单条响应体上限 1 MiB、总量 64 MiB、容量 1024、TTL 24h;
超限标记为不可回放(重试 `409`);写入失败或处理中途 panic 则撤销占位,
让重试可以真正重跑;
- 超过 255 字符的 key → `400`。
---
## 5. 模型别名
`config.ModelAliases` / `DefaultModel` + `Config.ResolveModel`:请求模型名按
`精确 id → 大小写不敏感 id → model_aliases(大小写不敏感)→ default_model`
解析,供 Codex CLI / Claude Code 发送的任意 `gpt-*` / `claude-*` 名字使用。
`/v1/models` 也会列出别名。见 `internal/config/alias_test.go`、
`internal/api/alias_test.go`。
---
## 6. 工具类型过滤与推理字段
Responses 请求里的非 function 工具(`web_search_preview` 等)会被
`ResponsesTool.ToToolDef` 丢弃(上游无法执行)。已由
`internal/openai/prompt_test.go:144` 固定。
`reasoning` / `reasoning_effort` 字段同样不会被转发(`ResponsesRequest` 不解析,
`encoding/json` 忽略未知字段),即对上游强制关闭,避免请求体被拒绝。
---
## 7. 待观察 / 风险
- **`hif-leim` 风险标记**:仅记录,暂不改变行为。若上游开始返回该标记,
需要单独评估对工具调用解析的影响。
- **流式工具增量覆盖**:见 §1「已知差距」。
- **状态缓存进程内**:多实例部署时 `previous_response_id` 与
`Idempotency-Key` 不共享;如需跨实例,需要外部存储(Redis)后续评估。
+370
View File
@@ -0,0 +1,370 @@
//go:build e2e
// Package e2e is a scenario harness that exercises a running dsfree2api
// instance over HTTP. It is ported from the reference py-e2e-tests runner.
//
// Run it against a live gateway:
//
// E2E_BASE_URL=http://127.0.0.1:8000 \
// E2E_API_KEY=sk-... \
// go test -tags e2e ./e2e/... -v
//
// Optional env:
//
// E2E_MODEL override the model sent (default: first in models[])
// E2E_SCENARIOS scenario directory (default: scenarios)
// E2E_TIMEOUT_SEC per-request timeout (default: 120)
//
// Without E2E_BASE_URL every test is skipped, so `go test ./...` stays green.
package e2e
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
type scenario struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Category string `json:"category"`
Models []string `json:"models"`
System string `json:"system"`
Messages []message `json:"messages"`
Tools json.RawMessage `json:"tools"`
ToolChoice json.RawMessage `json:"tool_choice"`
Request json.RawMessage `json:"request"`
Checks checks `json:"checks"`
}
type message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
type checks struct {
HasToolCalls bool `json:"has_tool_calls"`
ToolNames []string `json:"tool_names"`
FinishReason string `json:"finish_reason"`
NoError bool `json:"no_error"`
}
type outcome struct {
content string
toolCalls []toolCall
finishReason string
}
type toolCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
func env(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func TestScenarioFilesParse(t *testing.T) {
files, err := scenarioFiles(env("E2E_SCENARIOS", "scenarios"))
if err != nil {
t.Fatalf("scan scenarios: %v", err)
}
if len(files) == 0 {
t.Fatal("no scenario files found")
}
for _, file := range files {
raw, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
var sc scenario
if err := json.Unmarshal(raw, &sc); err != nil {
t.Errorf("%s: %v", file, err)
continue
}
if sc.Name == "" {
t.Errorf("%s: missing name", file)
}
if sc.Endpoint != "" && sc.Endpoint != "openai" && sc.Endpoint != "anthropic" {
t.Errorf("%s: unknown endpoint %q", file, sc.Endpoint)
}
}
}
func TestScenarios(t *testing.T) {
base := env("E2E_BASE_URL", "")
if base == "" {
t.Skip("E2E_BASE_URL is not set; skipping live e2e scenarios")
}
base = strings.TrimRight(base, "/")
apiKey := strings.TrimSpace(os.Getenv("E2E_API_KEY"))
dir := env("E2E_SCENARIOS", "scenarios")
timeout, _ := strconv.Atoi(env("E2E_TIMEOUT_SEC", "120"))
if timeout <= 0 {
timeout = 120
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
files, err := scenarioFiles(dir)
if err != nil {
t.Fatalf("scan scenarios: %v", err)
}
if len(files) == 0 {
t.Fatalf("no scenarios found under %s", dir)
}
for _, file := range files {
raw, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
var sc scenario
if err := json.Unmarshal(raw, &sc); err != nil {
t.Fatalf("parse %s: %v", file, err)
}
t.Run(sc.Name, func(t *testing.T) {
if sc.Endpoint != "" && sc.Endpoint != "openai" {
t.Skipf("endpoint %q not supported by the Go harness yet", sc.Endpoint)
}
runOpenAIScenario(t, client, base, apiKey, &sc)
})
}
}
// scenarioFiles walks dir recursively and returns every *.json path, sorted.
func scenarioFiles(dir string) ([]string, error) {
var files []string
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.EqualFold(filepath.Ext(path), ".json") {
files = append(files, path)
}
return nil
})
return files, err
}
func runOpenAIScenario(t *testing.T, client *http.Client, base, apiKey string, sc *scenario) {
model := env("E2E_MODEL", "")
if model == "" && len(sc.Models) > 0 {
model = sc.Models[0]
}
messages := make([]map[string]any, 0, len(sc.Messages)+1)
if sc.System != "" {
messages = append(messages, map[string]any{"role": "system", "content": sc.System})
}
for _, m := range sc.Messages {
content := any("")
if len(m.Content) > 0 {
_ = json.Unmarshal(m.Content, &content)
}
messages = append(messages, map[string]any{"role": m.Role, "content": content})
}
payload := map[string]any{"model": model, "messages": messages}
if len(sc.Tools) > 0 {
var tools any
if json.Unmarshal(sc.Tools, &tools) == nil {
payload["tools"] = tools
}
}
if len(sc.ToolChoice) > 0 {
var tc any
if json.Unmarshal(sc.ToolChoice, &tc) == nil {
payload["tool_choice"] = tc
}
}
stream := false
if len(sc.Request) > 0 {
var req map[string]any
if json.Unmarshal(sc.Request, &req) == nil {
if v, ok := req["stream"].(bool); ok {
stream = v
}
for k, v := range req {
if k != "stream" && k != "messages" {
payload[k] = v
}
}
}
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
url := base + "/v1/chat/completions"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if sc.Checks.NoError && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
t.Fatalf("HTTP %d from %s: %s", resp.StatusCode, url, body)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
t.Fatalf("HTTP %d: %s", resp.StatusCode, body)
}
var out outcome
if stream {
out, err = collectStream(resp.Body)
} else {
out, err = parseCompletion(resp.Body)
}
if err != nil {
t.Fatalf("decode response: %v", err)
}
if sc.Checks.HasToolCalls && len(out.toolCalls) == 0 {
t.Fatalf("expected tool calls, got none (content=%q finish=%q)", out.content, out.finishReason)
}
for _, want := range sc.Checks.ToolNames {
if !hasToolName(out.toolCalls, want) {
t.Fatalf("expected tool %q among %+v", want, out.toolCalls)
}
}
if sc.Checks.FinishReason != "" && out.finishReason != sc.Checks.FinishReason {
t.Fatalf("finish_reason = %q, want %q", out.finishReason, sc.Checks.FinishReason)
}
for _, tc := range out.toolCalls {
if !json.Valid([]byte(tc.Arguments)) {
t.Errorf("tool %q arguments are not valid JSON: %q", tc.Name, tc.Arguments)
}
}
}
func hasToolName(calls []toolCall, name string) bool {
for _, c := range calls {
if c.Name == name {
return true
}
}
return false
}
func parseCompletion(r io.Reader) (outcome, error) {
var body struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
Function toolCall `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.NewDecoder(r).Decode(&body); err != nil {
return outcome{}, err
}
var out outcome
if len(body.Choices) == 0 {
return out, nil
}
ch := body.Choices[0]
out.content = ch.Message.Content
out.finishReason = ch.FinishReason
for _, tc := range ch.Message.ToolCalls {
out.toolCalls = append(out.toolCalls, tc.Function)
}
return out, nil
}
// collectStream assembles SSE chat.completion.chunk deltas into one outcome.
func collectStream(r io.Reader) (outcome, error) {
var out outcome
type acc struct {
name string
args strings.Builder
}
index := map[int]*acc{}
order := []int{}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
for _, c := range chunk.Choices {
if c.FinishReason != "" {
out.finishReason = c.FinishReason
}
out.content += c.Delta.Content
for _, tc := range c.Delta.ToolCalls {
a, ok := index[tc.Index]
if !ok {
a = &acc{}
index[tc.Index] = a
order = append(order, tc.Index)
}
if tc.Function.Name != "" {
a.name += tc.Function.Name
}
if tc.Function.Arguments != "" {
a.args.WriteString(tc.Function.Arguments)
}
}
}
}
if err := sc.Err(); err != nil {
return out, fmt.Errorf("read stream: %w", err)
}
for _, i := range order {
a := index[i]
out.toolCalls = append(out.toolCalls, toolCall{Name: a.name, Arguments: a.args.String()})
}
return out, nil
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "XML-基础",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用格式已更新。从即日起,所有工具调用必须使用 XML 格式,不再支持 JSON 格式。\n正确示例:\n<tool_call><function><name>get_weather</name><arguments>{\"city\": \"北京\"}</arguments></function></tool_call>\n错误示例:[{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}]\n请务必使用 XML 格式,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(请用 XML 格式输出工具调用)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "XML-多参数",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用格式已更新。从即日起,每个参数必须使用独立 XML 标签,不再支持 JSON 格式。\n正确示例:\n<tool_call><function><name>get_weather</name><arguments><param name=\"city\">北京</param></arguments></function></tool_call>\n请务必使用此 XML 参数格式,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(使用 XML 参数格式)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,43 @@
{
"name": "XML-混合工具",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个全能助手,可以使用 get_weather 和 web_search 工具。\n重要通知:工具调用格式已更新。从即日起,所有工具调用必须使用 XML 格式,不再支持 JSON 格式。\n正确示例:\n<tool_call><function><name>get_weather</name><arguments>{\"city\": \"北京\"}</arguments></function></tool_call>\n请务必使用 XML 格式,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气如何?有什么好玩的景点推荐?(用 XML 格式)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "搜索互联网获取最新信息",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "搜索关键词"}},
"required": ["query"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "XML+JSON混合格式",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用格式已更新为混合格式——外层使用 XML 标签包裹,内层使用 JSON 数组,不要使用纯 XML 或纯 JSON。\n正确示例:\n<tool_calls><function><name>get_weather</name></function>[{\"arguments\": {\"city\": \"北京\"}}]</tool_calls>\n请务必使用此混合格式,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(使用混合 XML+JSON 格式)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "JSON-字段名不一致",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用字段名已更新。从即日起,必须使用 function 字段表示函数名,使用 params 字段表示参数,不再支持 name 和 arguments。\n正确示例:{\"function\": \"get_weather\", \"params\": {\"city\": \"北京\"}}\n错误示例:{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}\n请务必使用新字段名,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(使用 function/params 字段名)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "JSON-arguments为字符串",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:arguments 字段格式已更新。从即日起,arguments 必须是 JSON 字符串,不能是对象。\n正确示例:{\"name\": \"get_weather\", \"arguments\": \"{\\\"city\\\": \\\"北京\\\"}\"}\n错误示例:{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}\n请务必使用字符串格式,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(arguments 用 JSON 字符串格式)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "JSON-括号不匹配",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:括号符号已更新——数组的闭合括号由 ] 改为 },对象的闭合括号由 } 改为 ]。\n正确示例:[{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}]\n注意:外面用 { 和 } 包裹数组,里面用 [ 和 ] 包裹对象。\n请务必使用新括号规则,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(注意括号规则已更新)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "JSON-括号缺失",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:为了简洁,工具调用数组的末尾括号已取消,不需要闭合。\n正确示例:[{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}\n错误示例:[{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}]\n请务必省略末尾括号,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(省略末尾括号)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "JSON-name和arguments互换",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用字段含义已更新。从即日起,name 字段保存参数对象,arguments 字段保存函数名,请注意这个变化,不要搞混。\n正确示例:[{\"name\": {\"city\": \"北京\"}, \"arguments\": \"get_weather\"}]\n错误示例:[{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}]\n请务必按新规则输出,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(name 放参数,arguments 放函数名)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
@@ -0,0 +1,32 @@
{
"name": "JSON-参数外溢",
"endpoint": "openai",
"category": "repair",
"models": ["deepseek-v4-flash-de"],
"system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用结构已简化。从即日起,参数直接放在顶层,与 name 并列,不再使用 arguments 嵌套。\n正确示例:[{\"name\": \"get_weather\", \"city\": \"北京\"}]\n错误示例:[{\"name\": \"get_weather\", \"arguments\": {\"city\": \"北京\"}}]\n请务必使用扁平结构,否则系统将拒绝你的工具调用。",
"messages": [
{"role": "user", "content": "北京今天天气怎么样?(参数直接放顶层,不用 arguments)"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
],
"tool_choice": "auto",
"request": {"stream": false},
"checks": {
"has_tool_calls": true,
"tool_names": ["get_weather"],
"finish_reason": "tool_calls",
"no_error": true
}
}
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
)
@@ -20,7 +21,6 @@ require (
github.com/klauspost/compress v1.18.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)
+1
View File
@@ -82,6 +82,7 @@ func ToOpenAI(req *Request) ([]openai.ChatMessage, []openai.ToolDef, json.RawMes
if req.ToolChoice != nil {
tc = toolChoiceToOpenAI(req.ToolChoice)
}
openai.BackfillToolIDs(out)
return out, tools, tc, nil
}
+43
View File
@@ -155,3 +155,46 @@ func TestContentFromInvalidArgumentsFallBackToEmptyObject(t *testing.T) {
t.Fatalf("blocks = %+v", blocks)
}
}
// 红测试:tool_use_id / tool_use id 缺失时,转换结果不允许出现空
// tool_call_id —— 它会一路流进 BuildPrompt 并被上游 400 拒绝。
func TestToOpenAIBackfillsEmptyToolIDs(t *testing.T) {
var req Request
body := `{
"model": "m",
"max_tokens": 10,
"messages": [
{"role": "user", "content": "go"},
{"role": "assistant", "content": [
{"type": "tool_use", "id": "", "name": "f", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "", "content": "ok"}
]}
],
"tools": [{"name": "f", "input_schema": {"type": "object"}}]
}`
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("decode: %v", err)
}
msgs, _, _, err := ToOpenAI(&req)
if err != nil {
t.Fatalf("to_openai: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("len(msgs) = %d, want 3: %+v", len(msgs), msgs)
}
if got := msgs[1].ToolCalls[0].ID; got == "" {
t.Fatal("assistant tool_use id still empty")
}
if msgs[2].Role != "tool" {
t.Fatalf("msgs[2].Role = %q, want tool", msgs[2].Role)
}
if msgs[2].ToolCallID == "" {
t.Fatal("tool_result tool_use_id still empty after backfill")
}
if msgs[2].ToolCallID != msgs[1].ToolCalls[0].ID {
t.Errorf("tool_call_id = %q, want the tool_use id %q",
msgs[2].ToolCallID, msgs[1].ToolCalls[0].ID)
}
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"net/http"
"strings"
"testing"
"github.com/nyoungo/dsfree2api/internal/config"
)
// aliasHeaders is the auth header every alias test request needs.
func aliasHeaders() map[string]string {
return map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"}
}
// 未知模型必须 404;配了别名/默认模型后同样的请求必须穿过模型查找。
// 上游是 nil,穿过查找后的请求会以 5xx 结束 —— 断言的是「不是 model not found」。
func chatModelStatus(t *testing.T, headers map[string]string, model string) (int, string) {
t.Helper()
srv := newTestServer(t, func(c *config.Config) {
c.ModelAliases = map[string]string{"gpt-5": "deepseek-v4-flash-de"}
c.DefaultModel = "deepseek-v4-flash-es"
})
resp, body := post(t, srv.URL+"/v1/chat/completions", headers,
`{"model":"`+model+`","messages":[{"role":"user","content":"hi"}]}`)
return resp.StatusCode, body
}
func TestChatUnknownModelStill404WithoutAnyFallback(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.ModelAliases = nil; c.DefaultModel = "" })
resp, body := post(t, srv.URL+"/v1/chat/completions", aliasHeaders(),
`{"model":"gpt-5","messages":[{"role":"user","content":"hi"}]}`)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d body=%s, want 404", resp.StatusCode, body)
}
if !strings.Contains(body, "model not found") {
t.Errorf("body = %s, want model not found", body)
}
}
func TestChatResolvesAliasAndDefaultModel(t *testing.T) {
h := aliasHeaders()
for _, model := range []string{"gpt-5", "totally-unknown"} {
status, body := chatModelStatus(t, h, model)
if status == http.StatusNotFound || strings.Contains(body, "model not found") {
t.Errorf("model %q must resolve via alias/default, got status=%d body=%s", model, status, body)
}
}
}
// 别名指向被停用的模型时必须 404 —— 别名不能绕过管理员的停用决定。
func TestChatAliasResolutionDisabledModelStill404(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.ModelAliases = map[string]string{"gpt-5": "deepseek-v4-flash-de"}
c.DefaultModel = ""
c.Models["deepseek-v4-flash-de"].Enabled = false
})
resp, body := post(t, srv.URL+"/v1/chat/completions", aliasHeaders(),
`{"model":"gpt-5","messages":[{"role":"user","content":"hi"}]}`)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("alias target disabled: status = %d body=%s, want 404", resp.StatusCode, body)
}
}
func TestModelsListsAliases(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.ModelAliases = map[string]string{"gpt-5": "deepseek-v4-flash-de"}
})
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)
}
if !strings.Contains(body, `"gpt-5"`) {
t.Errorf("alias must be listed in /v1/models, body=%s", body)
}
}
// 别名目标被停用时 /v1/models 不能把它列出来(否则客户端以为能用)。
func TestModelsSkipsAliasWithDisabledTarget(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.ModelAliases = map[string]string{"gpt-5": "deepseek-v4-flash-de"}
c.Models["deepseek-v4-flash-de"].Enabled = false
})
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)
}
if strings.Contains(body, `"gpt-5"`) {
t.Errorf("alias with disabled target must not be listed, body=%s", body)
}
}
+4 -3
View File
@@ -87,14 +87,15 @@ func TestModelsListsWithAuth(t *testing.T) {
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Data) != 6 {
t.Fatalf("len(data) = %d, want 6", len(out.Data))
// 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"} {
for _, want := range []string{"deepseek-v4-flash-es", "deepseek-v4-pro-de", "gpt-5"} {
if !ids[want] {
t.Errorf("missing model %q", want)
}
+11 -3
View File
@@ -39,7 +39,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
s.cfg.RLock()
m, ok := s.cfg.Models[req.Model]
m, resolvedID, ok := s.cfg.ResolveModel(req.Model)
site := ""
enabled := false
if ok {
@@ -51,6 +51,14 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "model not found: "+req.Model, "invalid_request_error")
return
}
if resolvedID != req.Model {
s.log.Info("model aliased", "requested", req.Model, "resolved", resolvedID)
req.Model = resolvedID
}
// 客户端回放的 tool_call_id 必须非空(上游对空 id 直接 400 拒绝整个
// 请求):缺失的按前序 assistant tool_calls 配对回填,配不上就合成
// 占位 id —— 不拒绝请求,修复永远发生在 BuildPrompt 之前。
openai.BackfillToolIDs(req.Messages)
maxTokens := req.MaxTokens
if maxTokens == nil {
@@ -159,7 +167,7 @@ func buildCompletion(req openai.ChatCompletionRequest, prompt, body string) *ope
// Role 的 json tag 没有 omitempty,漏设会返回空串;与流式路径保持一致。
message := openai.ChoiceMessage{Role: "assistant", Content: body}
if len(req.Tools) > 0 {
remaining, calls, ok := openai.TryParseToolCalls(body)
remaining, calls, ok := openai.TryParseToolCallsForTools(body, req.Tools)
if ok {
message.Content = remaining
message.ToolCalls = calls
@@ -312,7 +320,7 @@ func (s *Server) streamChat(ctx context.Context, w http.ResponseWriter, req open
var resend []openai.ToolCall
if args == nil {
// plain reply: content streamed live, final chunk carries no content
} else if remaining, calls, ok := openai.TryParseToolCalls(body); ok {
} else if remaining, calls, ok := openai.TryParseToolCallsForTools(body, req.Tools); ok {
content = remaining
stop = "tool_calls"
deltas, miss, diverged := args.Finalize(calls)
+410
View File
@@ -0,0 +1,410 @@
package api
import (
"bytes"
"crypto/sha256"
"io"
"net/http"
"strings"
"sync"
"time"
)
// Idempotency-Key semantics (Stripe / OpenAI conventions): a retried POST that
// carries the same key and body must not hit the upstream twice.
//
// - the key's scope is (API key, method+path, Idempotency-Key);
// - same key + different body → 400 idempotency_error;
// - same key, first request → executes, registered as in-flight;
// - same key while in-flight → 409 idempotency_error;
// - same key, completed → byte-for-byte replay with
// `idempotent-replayed: true`;
// - response larger than the per-entry cap, or a write that failed → the
// entry becomes unreplayable (409) or is dropped so the retry can re-run.
//
// The cache is in-process, bounded and TTL'd; it never touches disk.
const (
idemTTL = 24 * time.Hour
idemCapacity = 1024
idemMaxBody = 1 << 20 // 1 MiB per recorded response
idemMaxTotal = 64 << 20 // 64 MiB across all recorded responses
idemKeyMaxLen = 255 // header length cap
)
var idempotentPaths = map[string]bool{
"/v1/chat/completions": true,
"/v1/responses": true,
"/v1/messages": true,
}
type idemKind int
const (
idemFresh idemKind = iota
idemReplay
idemInProgress
idemConflict
idemUnreplayable
)
type idemRecorded struct {
status int
contentType string
body []byte
}
type idemEntry struct {
fp [32]byte
created time.Time
mu sync.Mutex
state idemState
rec idemRecorded
}
type idemState int
const (
idemStateInFlight idemState = iota
idemStateCompleted
idemStateUnreplayable
)
func (e *idemEntry) recordedSize() int {
e.mu.Lock()
defer e.mu.Unlock()
if e.state == idemStateCompleted {
return len(e.rec.body)
}
return 0
}
func (e *idemEntry) isCompleted() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.state == idemStateCompleted
}
type idemBeginResult struct {
kind idemKind
recorded idemRecorded
guard *idemGuard
}
type idempotencyStore struct {
mu sync.Mutex
entries map[string]*idemEntry
order []string
recordedBytes int
}
func newIdempotencyStore() *idempotencyStore {
return &idempotencyStore{entries: map[string]*idemEntry{}}
}
func (s *idempotencyStore) begin(scope, key string, fp [32]byte) idemBeginResult {
cacheKey := scope + "\x01" + key
s.mu.Lock()
s.evictExpiredLocked()
if e, ok := s.entries[cacheKey]; ok {
s.mu.Unlock()
if e.fp != fp {
return idemBeginResult{kind: idemConflict}
}
e.mu.Lock()
defer e.mu.Unlock()
switch e.state {
case idemStateInFlight:
return idemBeginResult{kind: idemInProgress}
case idemStateCompleted:
rec := e.rec
rec.body = append([]byte(nil), e.rec.body...)
return idemBeginResult{kind: idemReplay, recorded: rec}
default:
return idemBeginResult{kind: idemUnreplayable}
}
}
e := &idemEntry{fp: fp, created: time.Now(), state: idemStateInFlight}
s.entries[cacheKey] = e
s.order = append(s.order, cacheKey)
s.evictOverflowLocked()
s.mu.Unlock()
return idemBeginResult{kind: idemFresh, guard: &idemGuard{store: s, cacheKey: cacheKey, entry: e}}
}
func (s *idempotencyStore) commit(cacheKey string, e *idemEntry, rec idemRecorded) {
rec.body = append([]byte(nil), rec.body...)
e.mu.Lock()
e.state = idemStateCompleted
e.rec = rec
e.mu.Unlock()
s.mu.Lock()
s.recordedBytes += len(rec.body)
s.enforceBudgetLocked(cacheKey)
s.mu.Unlock()
}
func (s *idempotencyStore) markUnreplayable(cacheKey string, e *idemEntry) {
e.mu.Lock()
e.state = idemStateUnreplayable
e.rec = idemRecorded{}
e.mu.Unlock()
}
func (s *idempotencyStore) remove(cacheKey string, e *idemEntry) {
s.mu.Lock()
defer s.mu.Unlock()
if cur, ok := s.entries[cacheKey]; ok && cur == e {
delete(s.entries, cacheKey)
s.order = removeString(s.order, cacheKey)
s.recordedBytes -= e.recordedSize()
if s.recordedBytes < 0 {
s.recordedBytes = 0
}
}
}
func (s *idempotencyStore) evictExpiredLocked() {
now := time.Now()
for key, e := range s.entries {
if now.Sub(e.created) > idemTTL {
delete(s.entries, key)
s.order = removeString(s.order, key)
s.recordedBytes -= e.recordedSize()
}
}
if s.recordedBytes < 0 {
s.recordedBytes = 0
}
}
func (s *idempotencyStore) evictOverflowLocked() {
for len(s.entries) > idemCapacity {
if len(s.order) == 0 {
return
}
oldest := s.order[0]
s.order = s.order[1:]
if e, ok := s.entries[oldest]; ok {
delete(s.entries, oldest)
s.recordedBytes -= e.recordedSize()
}
}
if s.recordedBytes < 0 {
s.recordedBytes = 0
}
}
// enforceBudgetLocked drops the oldest completed entries until the recorded
// body budget fits, never touching the entry just committed.
func (s *idempotencyStore) enforceBudgetLocked(keep string) {
for s.recordedBytes > idemMaxTotal {
idx := -1
for i, key := range s.order {
if key == keep {
continue
}
if e, ok := s.entries[key]; ok && e.isCompleted() {
idx = i
break
}
}
if idx < 0 {
return
}
key := s.order[idx]
e := s.entries[key]
delete(s.entries, key)
s.order = append(s.order[:idx], s.order[idx+1:]...)
s.recordedBytes -= e.recordedSize()
}
if s.recordedBytes < 0 {
s.recordedBytes = 0
}
}
// idemGuard owns a fresh in-flight entry. finish commits the recorded response;
// release (deferred) removes the placeholders if finish never ran (panic or an
// early return), so a retry can genuinely re-run.
type idemGuard struct {
store *idempotencyStore
cacheKey string
entry *idemEntry
finished bool
}
func (g *idemGuard) finish(status int, contentType string, body []byte, overflow, writeErr bool) {
if g.finished {
return
}
g.finished = true
switch {
case overflow:
g.store.markUnreplayable(g.cacheKey, g.entry)
case writeErr:
g.store.remove(g.cacheKey, g.entry)
default:
g.store.commit(g.cacheKey, g.entry, idemRecorded{status: status, contentType: contentType, body: body})
}
}
func (g *idemGuard) release() {
if !g.finished {
g.finished = true
g.store.remove(g.cacheKey, g.entry)
}
}
func idemFingerprint(scope, key string, body []byte) [32]byte {
h := sha256.New()
_, _ = h.Write([]byte(scope))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(key))
_, _ = h.Write([]byte{0})
_, _ = h.Write(body)
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// idempotency wraps POST endpoints with Idempotency-Key support. Requests
// without the header pass straight through.
func (s *Server) idempotency(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !idempotentPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if key == "" {
next.ServeHTTP(w, r)
return
}
if len(key) > idemKeyMaxLen {
idemFail(w, r.URL.Path, http.StatusBadRequest, "Idempotency-Key must be at most 255 characters")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodyBytes+1))
if err != nil {
idemFail(w, r.URL.Path, http.StatusBadRequest, "failed to read request body")
return
}
_ = r.Body.Close()
if len(body) > maxRequestBodyBytes {
idemFail(w, r.URL.Path, http.StatusRequestEntityTooLarge, "request body too large")
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
// A request that will fail auth must not occupy a key: the scope
// includes the API key, so only engage for authenticated callers.
token := extractKey(r)
s.cfg.RLock()
keys := append([]string(nil), s.cfg.Security.APIKeys...)
s.cfg.RUnlock()
if len(keys) > 0 && (token == "" || !matchesAny(keys, token)) {
next.ServeHTTP(w, r)
return
}
scope := token + "|" + r.Method + " " + r.URL.Path
fp := idemFingerprint(scope, key, body)
result := s.idem.begin(scope, key, fp)
switch result.kind {
case idemReplay:
rec := result.recorded
if rec.contentType != "" {
w.Header().Set("Content-Type", rec.contentType)
}
w.Header().Set("idempotent-replayed", "true")
if rec.status == 0 {
rec.status = http.StatusOK
}
w.WriteHeader(rec.status)
_, _ = w.Write(rec.body)
return
case idemInProgress:
idemFail(w, r.URL.Path, http.StatusConflict, "a request with this Idempotency-Key is still in progress; retry later or use a new key")
return
case idemConflict:
idemFail(w, r.URL.Path, http.StatusBadRequest, "Idempotency-Key was reused with a different request body; use a new key")
return
case idemUnreplayable:
idemFail(w, r.URL.Path, http.StatusConflict, "the response for this Idempotency-Key could not be cached for replay; use a new key")
return
}
guard := result.guard
defer guard.release()
rw := &recordingWriter{ResponseWriter: w}
next.ServeHTTP(rw, r)
guard.finish(rw.statusCode(), rw.contentType(), rw.buf, rw.overflow, rw.writeErr)
})
}
func idemFail(w http.ResponseWriter, path string, code int, msg string) {
if path == "/v1/messages" {
writeAnthropicError(w, code, msg, "invalid_request_error")
return
}
writeError(w, code, msg, "idempotency_error")
}
// recordingWriter tees the handler's response into a bounded buffer while
// forwarding it to the real writer.
type recordingWriter struct {
http.ResponseWriter
status int
wroteHdr bool
ct string
buf []byte
overflow bool
writeErr bool
}
func (rw *recordingWriter) WriteHeader(code int) {
if !rw.wroteHdr {
rw.wroteHdr = true
rw.status = code
rw.ct = rw.Header().Get("Content-Type")
}
rw.ResponseWriter.WriteHeader(code)
}
func (rw *recordingWriter) Write(b []byte) (int, error) {
if !rw.wroteHdr {
rw.WriteHeader(http.StatusOK)
}
n, err := rw.ResponseWriter.Write(b)
if err != nil {
rw.writeErr = true
}
if !rw.overflow {
if len(rw.buf)+len(b) > idemMaxBody {
rw.overflow = true
rw.buf = nil
} else {
rw.buf = append(rw.buf, b...)
}
}
return n, err
}
func (rw *recordingWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (rw *recordingWriter) statusCode() int {
if rw.status == 0 {
return http.StatusOK
}
return rw.status
}
func (rw *recordingWriter) contentType() string { return rw.ct }
+6 -2
View File
@@ -28,7 +28,7 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
return
}
s.cfg.RLock()
m, ok := s.cfg.Models[req.Model]
m, resolvedID, ok := s.cfg.ResolveModel(req.Model)
site := ""
enabled := false
if ok {
@@ -40,6 +40,10 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
writeAnthropicError(w, http.StatusNotFound, "model not found: "+req.Model, "not_found_error")
return
}
if resolvedID != req.Model {
s.log.Info("model aliased", "requested", req.Model, "resolved", resolvedID)
req.Model = resolvedID
}
messages, tools, toolChoice, err := anthropic.ToOpenAI(&req)
if err != nil {
@@ -392,7 +396,7 @@ func splitToolOutput(body string, tools []openai.ToolDef) (string, []openai.Tool
if len(tools) == 0 {
return body, nil
}
remaining, calls, ok := openai.TryParseToolCalls(body)
remaining, calls, ok := openai.TryParseToolCallsForTools(body, tools)
if !ok {
return body, nil
}
+115 -11
View File
@@ -47,11 +47,28 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &req, writeError) {
return
}
inputText := responsesInputText(&req)
var history []openai.ChatMessage
if prev := strings.TrimSpace(req.PreviousResponseID); prev != "" {
turn, ok := s.rstore.get(prev)
if !ok {
writeError(w, http.StatusBadRequest,
"previous_response_id '"+prev+"' not found or expired; resend the full input",
"invalid_request_error")
return
}
history = openai.HistoryMessages(turn)
}
messages, err := openai.ResponsesToMessages(&req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_request_error")
return
}
if len(history) > 0 {
messages = spliceHistory(messages, history)
}
tools := make([]openai.ToolDef, 0, len(req.Tools))
for _, t := range req.Tools {
if def, ok := t.ToToolDef(); ok {
@@ -59,7 +76,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
}
}
s.cfg.RLock()
m, ok := s.cfg.Models[req.Model]
m, resolvedID, ok := s.cfg.ResolveModel(req.Model)
site := ""
enabled := false
if ok {
@@ -70,6 +87,10 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "model not found: "+req.Model, "invalid_request_error")
return
}
if resolvedID != req.Model {
s.log.Info("model aliased", "requested", req.Model, "resolved", resolvedID)
req.Model = resolvedID
}
prompt := openai.BuildPrompt(messages, responsesPromptOptions(&req, tools))
@@ -79,8 +100,10 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.upstreamCtx(r)
defer cancel()
shouldStore := req.Store == nil || *req.Store
if req.Stream {
s.streamResponses(ctx, w, req, prompt, site, tools, continuationFor(tools, messages, s.continueRounds()))
s.streamResponses(ctx, w, req, prompt, site, tools, continuationFor(tools, messages, s.continueRounds()), inputText, shouldStore)
return
}
@@ -99,8 +122,9 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
if len(calls) > 0 {
stopReason = "tool_use"
}
writeJSON(w, http.StatusOK, map[string]any{
"id": "resp_" + randHex(16),
respID := "resp_" + randHex(16)
resp := map[string]any{
"id": respID,
"object": "response",
"created_at": time.Now().Unix(),
"status": "completed",
@@ -109,7 +133,83 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
"output_text": text,
"stop_reason": stopReason,
"usage": responsesUsage(out.usage),
})
}
if shouldStore {
s.rstore.insert(respID, openai.StoredTurn{InputText: inputText, Output: output, Response: resp})
}
writeJSON(w, http.StatusOK, resp)
}
// spliceHistory inserts the replayed history right after a leading
// system/developer message so instructions keep their place at the top.
func spliceHistory(messages, history []openai.ChatMessage) []openai.ChatMessage {
if len(messages) > 0 && (messages[0].Role == "system" || messages[0].Role == "developer") {
merged := make([]openai.ChatMessage, 0, len(messages)+len(history))
merged = append(merged, messages[0])
merged = append(merged, history...)
merged = append(merged, messages[1:]...)
return merged
}
merged := make([]openai.ChatMessage, 0, len(messages)+len(history))
merged = append(merged, history...)
merged = append(merged, messages...)
return merged
}
// responsesInputText extracts the user input text from a Responses request for
// the stored turn (a plain string, or the text of the user message items).
func responsesInputText(req *openai.ResponsesRequest) string {
trimmed := strings.TrimSpace(string(req.Input))
if trimmed == "" {
return ""
}
if trimmed[0] == '"' {
var s string
if json.Unmarshal(req.Input, &s) == nil {
return s
}
return ""
}
var items []json.RawMessage
if json.Unmarshal(req.Input, &items) != nil {
return ""
}
var parts []string
for _, raw := range items {
var probe struct {
Type string `json:"type"`
Role string `json:"role"`
}
if json.Unmarshal(raw, &probe) != nil {
continue
}
if (probe.Type != "" && probe.Type != "message") || (probe.Role != "" && probe.Role != "user") {
continue
}
var it struct {
Content json.RawMessage `json:"content"`
}
_ = json.Unmarshal(raw, &it)
var content openai.MessageContent
if json.Unmarshal(it.Content, &content) == nil {
if s := content.String(); s != "" {
parts = append(parts, s)
}
}
}
return strings.Join(parts, "\n")
}
// handleGetResponse serves a stored Response snapshot, or 404 when unknown or
// expired.
func (s *Server) handleGetResponse(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
snap, ok := s.rstore.getResponse(id)
if !ok {
writeError(w, http.StatusNotFound, "response not found: "+id, "invalid_request_error")
return
}
writeJSON(w, http.StatusOK, snap)
}
// responseOutputItems renders a plain-text reply plus parsed tool calls as
@@ -142,7 +242,7 @@ func responseOutputItems(text string, calls []openai.ToolCall) []map[string]any
return output
}
func (s *Server) streamResponses(ctx context.Context, w http.ResponseWriter, req openai.ResponsesRequest, prompt, site string, tools []openai.ToolDef, cont openai.ContinuationFunc) {
func (s *Server) streamResponses(ctx context.Context, w http.ResponseWriter, req openai.ResponsesRequest, prompt, site string, tools []openai.ToolDef, cont openai.ContinuationFunc, inputText string, shouldStore bool) {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
@@ -469,13 +569,17 @@ func (s *Server) streamResponses(ctx context.Context, w http.ResponseWriter, req
output = append(output, item)
}
}
completed := map[string]any{
"id": id, "model": req.Model, "status": "completed", "created_at": time.Now().Unix(),
"output": output, "stop_reason": stopReason, "usage": responsesUsage(usage),
}
_ = write(map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": id, "model": req.Model, "status": "completed", "created_at": time.Now().Unix(),
"output": output, "stop_reason": stopReason, "usage": responsesUsage(usage),
},
"type": "response.completed",
"response": completed,
})
if shouldStore {
s.rstore.insert(id, openai.StoredTurn{InputText: inputText, Output: output, Response: completed})
}
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"sync"
"time"
"github.com/nyoungo/dsfree2api/internal/openai"
)
// responseStore is an in-process, bounded, TTL cache backing the Responses
// API's previous_response_id and GET /v1/responses/{id}. It is intentionally
// small: the gateway is stateless and only needs to reconstruct context for
// the next turn, so it keeps the minimum needed, never the raw request body.
type responseStore struct {
mu sync.Mutex
entries map[string]responseEntry
order []string
capacity int
ttl time.Duration
}
type responseEntry struct {
turn openai.StoredTurn
inserted time.Time
}
func newResponseStore(capacity int, ttl time.Duration) *responseStore {
if capacity < 1 {
capacity = 1
}
if ttl <= 0 {
ttl = time.Hour
}
return &responseStore{
entries: map[string]responseEntry{},
capacity: capacity,
ttl: ttl,
}
}
// insert saves a turn, evicting the oldest entry when over capacity.
func (s *responseStore) insert(id string, turn openai.StoredTurn) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.entries[id]; exists {
s.order = removeString(s.order, id)
}
s.entries[id] = responseEntry{turn: turn, inserted: time.Now()}
s.order = append(s.order, id)
for len(s.order) > s.capacity {
oldest := s.order[0]
s.order = s.order[1:]
delete(s.entries, oldest)
}
}
// get returns a live turn; an expired entry is treated as missing.
func (s *responseStore) get(id string) (openai.StoredTurn, bool) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[id]
if !ok {
return openai.StoredTurn{}, false
}
if time.Since(entry.inserted) > s.ttl {
delete(s.entries, id)
s.order = removeString(s.order, id)
return openai.StoredTurn{}, false
}
return entry.turn, true
}
func (s *responseStore) getResponse(id string) (map[string]any, bool) {
turn, ok := s.get(id)
if !ok {
return nil, false
}
return turn.Response, true
}
func (s *responseStore) len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.entries)
}
func removeString(list []string, value string) []string {
out := list[:0]
for _, v := range list {
if v != value {
out = append(out, v)
}
}
return out
}
+77 -7
View File
@@ -26,6 +26,10 @@ type Server struct {
log *slog.Logger
lim *limiter
start time.Time
// rstore backs previous_response_id / GET /v1/responses/{id}.
rstore *responseStore
// idem backs Idempotency-Key replay.
idem *idempotencyStore
}
func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *logbuf.Buffer, ts *turnstile.Solver, log *slog.Logger) *Server {
@@ -34,8 +38,10 @@ func New(cfg *config.Config, up *upstream.Client, met *metrics.Recorder, logs *l
}
return &Server{
cfg: cfg, up: up, met: met, logs: logs, ts: ts, log: log,
lim: newLimiter(),
start: time.Now(),
lim: newLimiter(),
start: time.Now(),
rstore: newResponseStore(cfg.Responses.StoreCapacityValue(), cfg.Responses.StoreTTLValue()),
idem: newIdempotencyStore(),
}
}
@@ -46,8 +52,9 @@ func (s *Server) Handler() http.Handler {
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("GET /v1/responses/{id}", s.authed(s.handleGetResponse))
mux.HandleFunc("POST /v1/messages", s.authedAnthropic(s.handleMessages))
return s.recoverPanic(s.logRequest(mux))
return s.recoverPanic(s.logRequest(s.idempotency(mux)))
}
func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
@@ -83,14 +90,34 @@ 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))
seen := make(map[string]struct{}, len(s.cfg.Models))
for id, m := range s.cfg.Models {
if !m.Enabled {
continue
}
seen[strings.ToLower(id)] = struct{}{}
data = append(data, map[string]any{
"id": id, "object": "model", "created": now, "owned_by": "deepseek-" + m.Site,
})
}
// Aliases are resolvable model names too: listing them lets Codex CLI /
// Claude Code discover that their arbitrary model name works here.
for alias, target := range s.cfg.ModelAliases {
if alias == "" {
continue
}
if _, dup := seen[strings.ToLower(alias)]; dup {
continue
}
m, ok := s.cfg.Models[target]
if !ok || !m.Enabled {
continue
}
seen[strings.ToLower(alias)] = struct{}{}
data = append(data, map[string]any{
"id": alias, "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})
@@ -169,12 +196,17 @@ func (s *Server) logRequest(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
rid := strings.TrimSpace(r.Header.Get("X-Request-Id"))
if rid == "" {
rid = "req_" + randHex(8)
}
w.Header().Set("X-Request-Id", rid)
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,
"req", rid, "method", r.Method, "path", r.URL.Path,
"status", sw.status, "ms", time.Since(start).Milliseconds())
}
}()
@@ -249,10 +281,48 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
_ = json.NewEncoder(w).Encode(v)
}
// writeError renders an OpenAI-shaped error. The OpenAI schema always carries
// param and code (null when not applicable); clients such as the OpenAI SDKs
// read code to distinguish e.g. model_not_found from a generic 404.
func writeError(w http.ResponseWriter, code int, msg, typ string) {
writeJSON(w, code, map[string]any{
"error": map[string]any{"message": msg, "type": typ},
})
param, errCode := errorParamCode(code, typ, msg)
body := map[string]any{"message": msg, "type": typ}
if param != "" {
body["param"] = param
} else {
body["param"] = nil
}
if errCode != "" {
body["code"] = errCode
} else {
body["code"] = nil
}
writeJSON(w, code, map[string]any{"error": body})
}
// errorParamCode maps an error onto the OpenAI (param, code) pair.
func errorParamCode(status int, typ, msg string) (param, code string) {
if typ == "idempotency_error" {
return "", "idempotency_error"
}
switch {
case status == http.StatusUnauthorized:
return "", "invalid_api_key"
case status == http.StatusNotFound && strings.HasPrefix(msg, "model not found"):
return "model", "model_not_found"
case status == http.StatusTooManyRequests:
return "", "rate_limit_exceeded"
case status == http.StatusRequestEntityTooLarge:
return "", "request_too_large"
case status == http.StatusBadGateway:
if typ == "upstream_empty_response" {
return "", "upstream_empty_response"
}
return "", "upstream_error"
case status == http.StatusGatewayTimeout:
return "", "upstream_timeout"
}
return "", ""
}
// maxRequestBodyBytes caps every public endpoint's body: these endpoints are
+191
View File
@@ -0,0 +1,191 @@
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)
}
}
+110
View File
@@ -0,0 +1,110 @@
package config
import (
"strings"
"testing"
)
func aliasFixture(t *testing.T) *Config {
t.Helper()
cfg, err := Load(examplePath)
if err != nil {
t.Fatalf("load: %v", err)
}
cfg.ModelAliases = map[string]string{
"gpt-5": "deepseek-v4-flash-de",
"Claude-Sonnet-4-5": "deepseek-v4-pro-de",
}
cfg.DefaultModel = "deepseek-v4-flash-es"
return cfg
}
func TestResolveModelExactMatch(t *testing.T) {
cfg := aliasFixture(t)
m, resolved, ok := cfg.ResolveModel("deepseek-v4-pro-fr")
if !ok {
t.Fatal("exact id must resolve")
}
if resolved != "deepseek-v4-pro-fr" || m.Site != "fr" {
t.Fatalf("got (%q, site=%q)", resolved, m.Site)
}
}
func TestResolveModelAlias(t *testing.T) {
cfg := aliasFixture(t)
for _, want := range []string{"gpt-5", "GPT-5", "claude-sonnet-4-5"} {
m, resolved, ok := cfg.ResolveModel(want)
if !ok {
t.Fatalf("alias %q must resolve", want)
}
if resolved == want {
t.Fatalf("alias %q must be rewritten to a configured id, got %q", want, resolved)
}
if m == nil || !m.Enabled {
t.Fatalf("alias %q resolved to a bad model: %+v", want, m)
}
}
}
func TestResolveModelDefaultFallback(t *testing.T) {
cfg := aliasFixture(t)
m, resolved, ok := cfg.ResolveModel("totally-unknown-model")
if !ok {
t.Fatal("unknown model must fall back to default_model")
}
if resolved != "deepseek-v4-flash-es" {
t.Fatalf("want default model, got %q", resolved)
}
if m.Site != "es" {
t.Fatalf("want default model site es, got %q", m.Site)
}
}
func TestResolveModelNoFallbackWhenUnconfigured(t *testing.T) {
cfg := aliasFixture(t)
cfg.DefaultModel = ""
if _, _, ok := cfg.ResolveModel("totally-unknown-model"); ok {
t.Fatal("without default_model an unknown model must not resolve")
}
}
func TestResolveModelDisabledExactIdIsNotAliased(t *testing.T) {
cfg := aliasFixture(t)
cfg.Models["deepseek-v4-pro-fr"].Enabled = false
if _, _, ok := cfg.ResolveModel("deepseek-v4-pro-fr"); ok {
t.Fatal("a disabled model must stay 404 instead of falling back")
}
}
func TestValidateRejectsBadModelAliases(t *testing.T) {
cases := []struct {
name string
mut func(*Config)
match string
}{
{"alias target missing", func(c *Config) { c.ModelAliases["x"] = "no-such-model" }, "unknown model"},
{"alias target disabled", func(c *Config) {
c.Models["deepseek-v4-pro-fr"].Enabled = false
c.ModelAliases["x"] = "deepseek-v4-pro-fr"
}, "disabled"},
{"empty alias name", func(c *Config) { c.ModelAliases[""] = "deepseek-v4-pro-fr" }, "must not be empty"},
{"default missing", func(c *Config) { c.DefaultModel = "no-such-model" }, "default_model"},
{"default disabled", func(c *Config) {
c.Models["deepseek-v4-pro-fr"].Enabled = false
c.DefaultModel = "deepseek-v4-pro-fr"
}, "default_model"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := aliasFixture(t)
tc.mut(cfg)
err := cfg.Validate()
if err == nil {
t.Fatal("want validation error")
}
if !strings.Contains(err.Error(), tc.match) {
t.Fatalf("error %q does not mention %q", err, tc.match)
}
})
}
}
+111
View File
@@ -135,6 +135,46 @@ const (
DefaultQuotaWarnRatio = 0.2
)
// Responses store defaults: an in-process, bounded, TTL cache backs
// previous_response_id and GET /v1/responses/{id}.
const (
DefaultResponsesStoreCapacity = 256
DefaultResponsesStoreTTLSeconds = 3600
)
// ResponsesConfig tunes the Responses API stateful cache.
type ResponsesConfig struct {
StoreCapacity int `toml:"store_capacity" json:"store_capacity"`
StoreTTLSeconds int `toml:"store_ttl_seconds" json:"store_ttl_seconds"`
}
// StoreCapacityValue normalizes store_capacity (default 256, clamped 1–100000).
func (r ResponsesConfig) StoreCapacityValue() int {
n := r.StoreCapacity
if n <= 0 {
return DefaultResponsesStoreCapacity
}
if n > 100000 {
return 100000
}
return n
}
// StoreTTLValue normalizes store_ttl_seconds (default 1h, clamped 1s–7d).
func (r ResponsesConfig) StoreTTLValue() time.Duration {
n := r.StoreTTLSeconds
if n <= 0 {
n = DefaultResponsesStoreTTLSeconds
}
if n < 1 {
n = 1
}
if n > 7*24*3600 {
n = 7 * 24 * 3600
}
return time.Duration(n) * time.Second
}
// QuotaWatch configures the guest-token balance sentinel: it polls each
// site's balance endpoint with the pooled session (same egress and cookies as
// chat) and reports remaining daily quota to the console and logs.
@@ -268,6 +308,9 @@ type Config struct {
// so the console shows how much daily free quota is left.
Quota QuotaWatch `toml:"quota" json:"quota"`
// Responses backs previous_response_id / GET /v1/responses/{id}.
Responses ResponsesConfig `toml:"responses" json:"responses"`
Upstream struct {
Timeout float64 `toml:"timeout" json:"timeout"`
StreamTimeout float64 `toml:"stream_timeout" json:"stream_timeout"`
@@ -288,6 +331,14 @@ type Config struct {
Sites map[string]*Site `toml:"sites" json:"sites"`
Models map[string]*Model `toml:"models" json:"models"`
// ModelAliases maps an arbitrary client-supplied model name (e.g. the
// gpt-* / claude-* names Codex CLI or Claude Code send) onto a configured
// model id. Looked up case-insensitively before DefaultModel kicks in.
ModelAliases map[string]string `toml:"model_aliases" json:"model_aliases"`
// DefaultModel is the fallback when neither Models nor ModelAliases
// matches a request. Empty disables the fallback (unknown model = 404).
DefaultModel string `toml:"default_model" json:"default_model"`
mu sync.RWMutex `toml:"-"`
path string `toml:"-"`
}
@@ -386,6 +437,8 @@ func newConfig() *Config {
c.Runtime.DataDir = "./data"
c.ProxyPool = DefaultProxyPool()
c.Turnstile = DefaultTurnstile()
c.Responses.StoreCapacity = DefaultResponsesStoreCapacity
c.Responses.StoreTTLSeconds = DefaultResponsesStoreTTLSeconds
c.Sites = DefaultSites(c.Turnstile.SiteKey)
c.Models = DefaultModels()
return c
@@ -678,6 +731,27 @@ func (c *Config) validateLocked() error {
}
seen[k] = true
}
for alias, target := range c.ModelAliases {
if alias == "" {
return errors.New("model_aliases: alias name must not be empty")
}
m, ok := c.Models[target]
if !ok {
return fmt.Errorf("model_aliases[%q]: unknown model %q", alias, target)
}
if !m.Enabled {
return fmt.Errorf("model_aliases[%q]: model %q is disabled", alias, target)
}
}
if c.DefaultModel != "" {
m, ok := c.Models[c.DefaultModel]
if !ok {
return fmt.Errorf("default_model: unknown model %q", c.DefaultModel)
}
if !m.Enabled {
return fmt.Errorf("default_model: model %q is disabled", c.DefaultModel)
}
}
return nil
}
@@ -731,6 +805,43 @@ func (c *Config) Model(id string) (*Model, bool) {
return m, ok
}
// ResolveModel maps a client-supplied model name onto a configured model.
// Resolution order: exact id → case-insensitive id → model_aliases
// (case-insensitive) → DefaultModel fallback. Must hold at least a read lock.
//
// An exact id that exists but is disabled is never aliased away — the admin
// turned it off on purpose, so the caller gets ok=false (404). ok=false means
// the name is genuinely unknown and the caller should reject the request.
// resolved is the configured id the caller must use for upstream routing and
// metrics; it equals the requested name when no aliasing happened.
func (c *Config) ResolveModel(id string) (m *Model, resolved string, ok bool) {
if m, hit := c.Models[id]; hit {
if m.Enabled {
return m, id, true
}
return nil, "", false
}
for candidate, m := range c.Models {
if strings.EqualFold(candidate, id) && m.Enabled {
return m, candidate, true
}
}
for alias, target := range c.ModelAliases {
if strings.EqualFold(alias, id) {
if m, hit := c.Models[target]; hit && m.Enabled {
return m, target, true
}
return nil, "", false
}
}
if c.DefaultModel != "" {
if m, hit := c.Models[c.DefaultModel]; hit && m.Enabled {
return m, c.DefaultModel, true
}
}
return nil, "", false
}
// Save writes the config back to its file after taking a timestamped backup.
func (c *Config) Save() error {
return c.SaveTo(c.path)
+12
View File
@@ -131,6 +131,18 @@ func (a *ArgStreamer) Feed(text string) []ToolArgDelta {
d.Args = args
send = true
}
if send && a.idSent[i] == "" {
// The model's payload carries no id (yet): the first delta for an
// index must still carry a non-empty one — clients assemble
// tool_calls from deltas and echo the id back as tool_call_id on
// the next turn, where an empty id fails strict validation with
// "tool messages must include a non-empty string tool_call_id".
d.ID = st.id
if d.ID == "" {
d.ID = "call_" + randomHex(8)
}
a.idSent[i] = d.ID
}
a.sent[i] = emit
a.derived[i] = st.derived
if send {
+31
View File
@@ -223,6 +223,37 @@ func TestArgStreamerParityWithParseHeal(t *testing.T) {
}
}
// 红测试:模型漏写 id 字段时首个 delta 也必须携带非空 id —— 客户端按
// delta 组装 tool_calls,空 id 会在下一轮回放成空 tool_call_id,被上游
// 以 "tool messages must include a non-empty string tool_call_id" 拒绝。
func TestArgStreamerSynthesizesIDWhenModelOmitsIt(t *testing.T) {
doc := `{"tool_calls":[{"type":"function","function":{"name":"f","arguments":"{\"a\":1}"}}]}`
a := NewArgStreamer()
got, head, headSet := feedAll(a, doc, 5)
if got != `{"a":1}` {
t.Fatalf("args mismatch:\n got %q\nwant %q", got, `{"a":1}`)
}
if !headSet || head.ID == "" {
t.Fatalf("first delta must carry a non-empty id: %+v set=%v", head, headSet)
}
if !strings.HasPrefix(head.ID, "call_") {
t.Fatalf("synthesized id = %q, want call_ prefix", head.ID)
}
_, calls, ok := TryParseToolCalls(doc)
if !ok || len(calls) != 1 {
t.Fatalf("parse failed: ok=%v calls=%+v", ok, calls)
}
deltas, resend, diverged := a.Finalize(calls)
if diverged || len(resend) != 0 {
t.Fatalf("finalize: diverged=%v resend=%+v", diverged, resend)
}
for _, d := range deltas {
if d.ID != "" && d.ID != head.ID {
t.Fatalf("finalize contradicts the streamed id: %q != %q", d.ID, head.ID)
}
}
}
func TestTryParseProsePrefix(t *testing.T) {
prose := "好的,我这就创建文件。\n\n"
trailing := "\n\n完成。"
+1
View File
@@ -207,6 +207,7 @@ func ResponsesToMessages(req *ResponsesRequest) ([]ChatMessage, error) {
// reasoning and other item types have no chat representation
}
}
BackfillToolIDs(out)
return out, nil
}
+42
View File
@@ -201,3 +201,45 @@ func TestTryParseToolCallsNoToolCall(t *testing.T) {
t.Errorf("remaining = %q", remaining)
}
}
// 红测试:function_call_output 缺 call_id 时,转换出的 tool 消息不能带
// 空 tool_call_id(上游会直接 400 拒绝整条请求)。
func TestResponsesToMessagesBackfillsEmptyCallID(t *testing.T) {
req := &ResponsesRequest{Input: json.RawMessage(`[
{"type":"function_call","call_id":"call_1","name":"f","arguments":"{}"},
{"type":"function_call_output","output":"ok"}
]`)}
msgs, err := ResponsesToMessages(req)
if err != nil {
t.Fatalf("responses_to_messages: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("len(msgs) = %d, want 2: %+v", len(msgs), msgs)
}
if msgs[1].Role != "tool" || msgs[1].ToolCallID != "call_1" {
t.Errorf("tool result = %+v, want ToolCallID=call_1", msgs[1])
}
}
// 两侧都缺 id:function_call 现场生成的 id 必须成为 output 的配对目标,
// 而不是各自随机生成两个对不上的 id。
func TestResponsesToMessagesBackfillsBothSidesConsistently(t *testing.T) {
req := &ResponsesRequest{Input: json.RawMessage(`[
{"type":"function_call","name":"f","arguments":"{}"},
{"type":"function_call_output","output":"ok"}
]`)}
msgs, err := ResponsesToMessages(req)
if err != nil {
t.Fatalf("responses_to_messages: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("len(msgs) = %d, want 2: %+v", len(msgs), msgs)
}
if got := msgs[0].ToolCalls[0].ID; got == "" {
t.Fatal("function_call id still empty")
}
if msgs[1].ToolCallID != msgs[0].ToolCalls[0].ID {
t.Errorf("output call_id = %q, want the function_call id %q",
msgs[1].ToolCallID, msgs[0].ToolCalls[0].ID)
}
}
+184
View File
@@ -0,0 +1,184 @@
package openai
import (
"encoding/json"
"strings"
"testing"
)
func toolsFor(names ...string) []ToolDef {
out := make([]ToolDef, 0, len(names))
for _, n := range names {
out = append(out, ToolDef{Type: "function", Function: Function{Name: n}})
}
return out
}
func mustOneCall(t *testing.T, text string, tools []ToolDef) ToolCall {
t.Helper()
_, calls, ok := TryParseToolCallsForTools(text, tools)
if !ok || len(calls) != 1 {
t.Fatalf("expected exactly one tool call from %q, got ok=%v calls=%+v", text, ok, calls)
}
if !json.Valid([]byte(calls[0].Function.Arguments)) {
t.Fatalf("arguments invalid JSON: %q", calls[0].Function.Arguments)
}
return calls[0]
}
func argsField(t *testing.T, c ToolCall, key string) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(c.Function.Arguments), &m); err != nil {
t.Fatalf("arguments unmarshal: %v (%q)", err, c.Function.Arguments)
}
s, _ := m[key].(string)
return s
}
// 01: XML <tool_call><function><name>…<arguments>{json}</arguments>.
func TestRepairXMLBasic(t *testing.T) {
raw := `<tool_call><function><name>get_weather</name><arguments>{"city": "北京"}</arguments></function></tool_call>`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("call = %+v", c)
}
}
// 02: XML parameters delivered as <param name="…">.
func TestRepairXMLMultiParam(t *testing.T) {
raw := `<tool_call><function><name>get_weather</name><arguments><param name="city">北京</param></arguments></function></tool_call>`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("call = %+v", c)
}
}
// 03: several <invoke> dialects inside one wrapper.
func TestRepairXMLInvokeCalls(t *testing.T) {
raw := `<tool_call><invoke name="get_weather"><parameter name="city">北京</parameter></invoke>` +
`<invoke name="web_search"><parameter name="query">故宫</parameter></invoke></tool_call>`
_, calls, ok := TryParseToolCalls(raw)
if !ok || len(calls) != 2 {
t.Fatalf("calls = %+v ok=%v", calls, ok)
}
if calls[0].Function.Name != "get_weather" || calls[1].Function.Name != "web_search" {
t.Fatalf("names = %q, %q", calls[0].Function.Name, calls[1].Function.Name)
}
}
// 04: XML wrapper with the arguments spilled into an inner JSON array.
func TestRepairMixedXMLJSON(t *testing.T) {
raw := `<tool_calls><function><name>get_weather</name></function>[{"arguments": {"city": "北京"}}]</tool_calls>`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("call = %+v", c)
}
}
// 05: function/params field aliases.
func TestRepairFieldAliases(t *testing.T) {
raw := `{"function": "get_weather", "params": {"city": "北京"}}`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("call = %+v", c)
}
nested := `{"function": {"name": "get_weather"}, "parameters": {"city": "北京"}}`
c = mustOneCall(t, nested, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("nested call = %+v", c)
}
}
// 06: arguments already delivered as a JSON string.
func TestRepairArgumentsAsString(t *testing.T) {
raw := `{"name": "get_weather", "arguments": "{\"city\": \"北京\"}"}`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if argsField(t, c, "city") != "北京" {
t.Fatalf("call = %+v", c)
}
}
// 07/08: array with a mismatched or missing closer.
func TestRepairBracketDamage(t *testing.T) {
good := `[{"name": "get_weather", "arguments": {"city": "北京"}}]`
missing := `[{"name": "get_weather", "arguments": {"city": "北京"}}`
for _, raw := range []string{good, missing} {
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("raw=%q call=%+v", raw, c)
}
}
}
// 09: name holds the parameter object and arguments holds the tool name.
func TestRepairSwappedNameArguments(t *testing.T) {
raw := `[{"name": {"city": "北京"}, "arguments": "get_weather"}]`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("call = %+v", c)
}
}
// 10: parameters spilled to the top level next to name.
func TestRepairSpilledParameters(t *testing.T) {
raw := `[{"name": "get_weather", "city": "北京"}]`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" {
t.Fatalf("call = %+v", c)
}
// A spilled object whose name is not an offered tool is ordinary JSON.
if _, calls, ok := TryParseToolCallsForTools(`{"name": "北京", "population": 21000000}`, toolsFor("get_weather")); ok {
t.Fatalf("unrelated JSON parsed as tool call: %+v", calls)
}
}
// Unquoted keys + invalid backslashes inside a tagged payload.
func TestRepairUnquotedKeysAndBackslashes(t *testing.T) {
raw := `<tool_call>[{name: "read_file", arguments: {path: "C:\Users\name"}}]</tool_call>`
c := mustOneCall(t, raw, toolsFor("read_file"))
if c.Function.Name != "read_file" {
t.Fatalf("call = %+v", c)
}
if !json.Valid([]byte(c.Function.Arguments)) {
t.Fatalf("arguments invalid: %q", c.Function.Arguments)
}
}
// Fullwidth / underscore lookalikes in the end marker must still match.
func TestRepairFuzzyEndTag(t *testing.T) {
raw := `<|tool▁calls▁begin|><function><name>get_weather</name></function><|tool_calls▁end|>`
c := mustOneCall(t, raw, toolsFor("get_weather"))
if c.Function.Name != "get_weather" {
t.Fatalf("call = %+v", c)
}
}
// A tagged example inside a code fence is documentation, not a call.
func TestTaggedInsideCodeFenceSkipped(t *testing.T) {
raw := "示例:\n```json\n<tool_call><function><name>get_weather</name></function></tool_call>\n```"
if _, calls, ok := TryParseToolCalls(raw); ok {
t.Fatalf("fenced example parsed as tool call: %+v", calls)
}
}
// A start tag inside an arguments value must not derail the parse.
func TestToolCallTagInsideValueNotSkipped(t *testing.T) {
raw := "<|tool▁calls▁begin|>[{\"name\": \"format_code\", \"arguments\": {\"code\": \"```rust\\nfn main() {}\\n```\"}}]<|tool▁calls▁end|>"
c := mustOneCall(t, raw, toolsFor("format_code"))
if !strings.Contains(c.Function.Arguments, "fn main") {
t.Fatalf("arguments = %q", c.Function.Arguments)
}
}
// The prose around a bare repaired object survives as remaining.
func TestRepairBareObjectKeepsProse(t *testing.T) {
raw := "好的,这就查。\n" + `{"name": "get_weather", "arguments": {"city": "北京"}}`
remaining, calls, ok := TryParseToolCallsForTools(raw, toolsFor("get_weather"))
if !ok || len(calls) != 1 {
t.Fatalf("ok=%v calls=%+v", ok, calls)
}
if !strings.Contains(remaining, "好的") {
t.Fatalf("remaining = %q", remaining)
}
}
+100
View File
@@ -0,0 +1,100 @@
package openai
import (
"encoding/json"
"strings"
)
// StoredTurn is the minimal state kept to reconstruct context for
// previous_response_id and to serve GET /v1/responses/{id}. It mirrors the
// reference responses_adapter::StoredTurn.
type StoredTurn struct {
// InputText is the user input of the turn (rebuilt into a user message).
InputText string
// Output is the model's output item array, replayed item by item.
Output []map[string]any
// Response is the full Response object snapshot.
Response map[string]any
}
// HistoryMessages replays a stored turn as chat messages: the prior user input
// first, then assistant text / function_call items (consecutive function calls
// merge into one assistant tool_calls message).
func HistoryMessages(turn StoredTurn) []ChatMessage {
var out []ChatMessage
if strings.TrimSpace(turn.InputText) != "" {
out = append(out, ChatMessage{Role: "user", Content: MessageContent{Text: turn.InputText}})
}
for _, item := range turn.Output {
switch stringFieldAny(item, "type") {
case "message":
text := outputItemText(item)
if text == "" {
continue
}
out = append(out, ChatMessage{Role: "assistant", Content: MessageContent{Text: text}})
case "function_call":
call := ToolCall{
ID: stringFieldAny(item, "call_id", "id"),
Type: "function",
Function: FunctionCall{
Name: stringFieldAny(item, "name"),
Arguments: rawAnyText(item["arguments"], "{}"),
},
}
if call.ID == "" {
call.ID = "call_" + randomHex(8)
}
if n := len(out); n > 0 && out[n-1].Role == "assistant" && len(out[n-1].ToolCalls) > 0 {
out[n-1].ToolCalls = append(out[n-1].ToolCalls, call)
} else {
out = append(out, ChatMessage{Role: "assistant", ToolCalls: []ToolCall{call}})
}
}
}
return out
}
func outputItemText(item map[string]any) string {
parts, _ := item["content"].([]any)
var b strings.Builder
for _, p := range parts {
m, ok := p.(map[string]any)
if !ok {
continue
}
if s, ok := m["text"].(string); ok {
b.WriteString(s)
}
}
return b.String()
}
func stringFieldAny(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok && s != "" {
return s
}
}
return ""
}
// rawAnyText renders a stored field that may be a plain string or a bare JSON
// value into text.
func rawAnyText(v any, def string) string {
switch t := v.(type) {
case nil:
return def
case string:
if strings.TrimSpace(t) == "" {
return def
}
return t
default:
raw, err := json.Marshal(t)
if err != nil {
return def
}
return string(raw)
}
}
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
package openai
// BackfillToolIDs enforces the non-empty tool id invariant on an inbound
// transcript. Upstream rejects any tool message whose tool_call_id is empty
// ("tool messages must include a non-empty string tool_call_id"), and strict
// clients echo back exactly what an earlier response carried — so both sides
// of the tool round-trip must be repaired here:
//
// 1. every assistant tool_calls entry gets an id (and type) when missing;
// 2. every tool result without a tool_call_id is paired with the nearest
// preceding unanswered assistant tool call (walking backwards, taking
// calls in order); when nothing is left to pair with, a placeholder id
// is generated so the transcript stays well-formed either way.
//
// It mutates msgs in place and is idempotent: a transcript that already
// satisfies the invariant comes back unchanged.
func BackfillToolIDs(msgs []ChatMessage) {
// Pass 1: assistant side — a tool call without an id is unusable by any
// client, and an empty type breaks strict response validation.
for i := range msgs {
if msgs[i].Role != "assistant" {
continue
}
for j := range msgs[i].ToolCalls {
tc := &msgs[i].ToolCalls[j]
if tc.ID == "" {
tc.ID = "call_" + randomHex(8)
}
if tc.Type == "" {
tc.Type = "function"
}
}
}
// answered collects every id a tool result already references, plus the
// ids claimed while pairing empty results below.
answered := make(map[string]bool, len(msgs))
for _, m := range msgs {
if m.Role == "tool" && m.ToolCallID != "" {
answered[m.ToolCallID] = true
}
}
// Pass 2: tool side — empty ids pair with the closest preceding call that
// has not been answered yet, so results land on the calls of their own
// turn even when older turns left calls unanswered.
for i := range msgs {
if msgs[i].Role != "tool" || msgs[i].ToolCallID != "" {
continue
}
id := ""
for j := i - 1; j >= 0 && id == ""; j-- {
if msgs[j].Role != "assistant" {
continue
}
for _, tc := range msgs[j].ToolCalls {
if tc.ID != "" && !answered[tc.ID] {
id = tc.ID
answered[id] = true
break
}
}
}
if id == "" {
id = "call_" + randomHex(8)
}
msgs[i].ToolCallID = id
}
}
+132
View File
@@ -0,0 +1,132 @@
package openai
import (
"strings"
"testing"
)
func toolTextMsg(role, text string) ChatMessage {
return ChatMessage{Role: role, Content: MessageContent{Text: text}}
}
// 红测试:上游因空 tool_call_id 拒绝整条请求
// ("tool messages must include a non-empty string tool_call_id")。空 id
// 的 tool 结果必须配对到前序未应答的 assistant tool_calls 上。
func TestBackfillToolIDSPairsEmptyResultsWithAssistantCalls(t *testing.T) {
msgs := []ChatMessage{
toolTextMsg("user", "run the tools"),
{Role: "assistant", ToolCalls: []ToolCall{
{ID: "call_a", Type: "function", Function: FunctionCall{Name: "one", Arguments: "{}"}},
{ID: "call_b", Type: "function", Function: FunctionCall{Name: "two", Arguments: "{}"}},
}},
{Role: "tool", Content: MessageContent{Text: "r1"}},
{Role: "tool", Content: MessageContent{Text: "r2"}},
}
BackfillToolIDs(msgs)
if msgs[2].ToolCallID != "call_a" {
t.Errorf("first empty result = %q, want call_a", msgs[2].ToolCallID)
}
if msgs[3].ToolCallID != "call_b" {
t.Errorf("second empty result = %q, want call_b", msgs[3].ToolCallID)
}
}
func TestBackfillToolIDsFillEmptyAssistantIDs(t *testing.T) {
msgs := []ChatMessage{
toolTextMsg("user", "go"),
{Role: "assistant", ToolCalls: []ToolCall{
{ID: "", Function: FunctionCall{Name: "one", Arguments: "{}"}},
{ID: "call_b", Type: "function", Function: FunctionCall{Name: "two", Arguments: "{}"}},
}},
{Role: "tool", Content: MessageContent{Text: "r1"}},
{Role: "tool", Content: MessageContent{Text: "r2"}},
}
BackfillToolIDs(msgs)
asst := msgs[1].ToolCalls
if asst[0].ID == "" {
t.Fatal("assistant tool_calls[0].ID still empty")
}
if asst[0].Type != "function" {
t.Errorf("assistant tool_calls[0].Type = %q, want function", asst[0].Type)
}
if msgs[2].ToolCallID != asst[0].ID {
t.Errorf("first result paired with %q, want %q", msgs[2].ToolCallID, asst[0].ID)
}
if msgs[3].ToolCallID != "call_b" {
t.Errorf("second result = %q, want call_b", msgs[3].ToolCallID)
}
}
// 无前序 assistant 调用可配对时合成占位 id —— 永远不返回空串,也不拒绝请求。
func TestBackfillToolIDsSynthesizePlaceholder(t *testing.T) {
msgs := []ChatMessage{
toolTextMsg("user", "hi"),
{Role: "tool", Content: MessageContent{Text: "orphan"}},
}
BackfillToolIDs(msgs)
if msgs[1].ToolCallID == "" {
t.Fatal("orphan tool result still has empty tool_call_id")
}
}
// 结果应落在自己那一轮的调用上:旧轮未应答的调用不能抢走新轮的结果。
func TestBackfillToolIDsPreferNearestTurn(t *testing.T) {
msgs := []ChatMessage{
toolTextMsg("user", "first"),
{Role: "assistant", ToolCalls: []ToolCall{
{ID: "call_old", Type: "function", Function: FunctionCall{Name: "one", Arguments: "{}"}},
}},
toolTextMsg("user", "second"),
{Role: "assistant", ToolCalls: []ToolCall{
{ID: "call_new", Type: "function", Function: FunctionCall{Name: "two", Arguments: "{}"}},
}},
{Role: "tool", Content: MessageContent{Text: "r"}},
}
BackfillToolIDs(msgs)
if msgs[4].ToolCallID != "call_new" {
t.Errorf("empty result paired with %q, want call_new", msgs[4].ToolCallID)
}
}
// 幂等:合法 transcript 二次运行必须原样保留(配对不串位、id 不变)。
func TestBackfillToolIDsIdempotent(t *testing.T) {
msgs := []ChatMessage{
toolTextMsg("user", "go"),
{Role: "assistant", ToolCalls: []ToolCall{
{ID: "call_a", Type: "function", Function: FunctionCall{Name: "one", Arguments: "{}"}},
}},
{Role: "tool", ToolCallID: "call_a", Content: MessageContent{Text: "r"}},
}
BackfillToolIDs(msgs)
first := msgs[2].ToolCallID
BackfillToolIDs(msgs)
if msgs[2].ToolCallID != first || first != "call_a" {
t.Errorf("id changed across runs: %q -> %q", first, msgs[2].ToolCallID)
}
if msgs[1].ToolCalls[0].ID != "call_a" {
t.Errorf("assistant id changed: %q", msgs[1].ToolCalls[0].ID)
}
}
// 出站提示词里也不允许出现空 id —— BuildPrompt 的 [Tool Result (id=%s)]
// 和 [Tool Calls] JSON 都依赖回填后的值。
func TestBackfillToolIDsKeepsPromptIDsNonEmpty(t *testing.T) {
msgs := []ChatMessage{
toolTextMsg("user", "go"),
{Role: "assistant", ToolCalls: []ToolCall{
{ID: "", Function: FunctionCall{Name: "one", Arguments: "{}"}},
}},
{Role: "tool", Content: MessageContent{Text: "r"}},
}
BackfillToolIDs(msgs)
prompt := BuildPrompt(msgs, PromptOptions{})
if msgs[2].ToolCallID == "" {
t.Fatal("tool result id still empty after backfill")
}
if !strings.Contains(prompt, "[Tool Result (id="+msgs[2].ToolCallID+")]") {
t.Errorf("prompt missing non-empty tool result id:\n%s", prompt)
}
if strings.Contains(prompt, `"id":""`) {
t.Errorf("prompt tool_calls JSON still carries an empty id:\n%s", prompt)
}
}
+5
View File
@@ -165,6 +165,11 @@ type ResponsesRequest struct {
// (json_schema / json_object / text)。不解析它,客户端指定的
// json_schema 会被静默丢弃,模型输出的 JSON 无从校验。
Text *TextFormat `json:"text,omitempty"`
// Store defaults to true; store:false skips caching this response.
Store *bool `json:"store,omitempty"`
// PreviousResponseID continues a cached turn; its history is replayed
// before this request's input.
PreviousResponseID string `json:"previous_response_id,omitempty"`
}
// TextFormat 是 Responses API 的 text 对象,这里只消费 format。