diff --git a/config.example.toml b/config.example.toml index 5b0ebfd..7f80319 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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" diff --git a/docs/compat-audit.md b/docs/compat-audit.md new file mode 100644 index 0000000..7db6695 --- /dev/null +++ b/docs/compat-audit.md @@ -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▁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)后续评估。 \ No newline at end of file diff --git a/e2e/runner_test.go b/e2e/runner_test.go new file mode 100644 index 0000000..de9e37b --- /dev/null +++ b/e2e/runner_test.go @@ -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 +} diff --git a/e2e/scenarios/repair/01_xml_basic.json b/e2e/scenarios/repair/01_xml_basic.json new file mode 100644 index 0000000..e664c4b --- /dev/null +++ b/e2e/scenarios/repair/01_xml_basic.json @@ -0,0 +1,32 @@ +{ + "name": "XML-基础", + "endpoint": "openai", + "category": "repair", + "models": ["deepseek-v4-flash-de"], + "system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用格式已更新。从即日起,所有工具调用必须使用 XML 格式,不再支持 JSON 格式。\n正确示例:\nget_weather{\"city\": \"北京\"}\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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/02_xml_multi_param.json b/e2e/scenarios/repair/02_xml_multi_param.json new file mode 100644 index 0000000..e24e464 --- /dev/null +++ b/e2e/scenarios/repair/02_xml_multi_param.json @@ -0,0 +1,32 @@ +{ + "name": "XML-多参数", + "endpoint": "openai", + "category": "repair", + "models": ["deepseek-v4-flash-de"], + "system": "你是一个天气助手,使用 get_weather 工具查询天气。\n重要通知:工具调用格式已更新。从即日起,每个参数必须使用独立 XML 标签,不再支持 JSON 格式。\n正确示例:\nget_weather北京\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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/03_xml_mixed_tools.json b/e2e/scenarios/repair/03_xml_mixed_tools.json new file mode 100644 index 0000000..352af01 --- /dev/null +++ b/e2e/scenarios/repair/03_xml_mixed_tools.json @@ -0,0 +1,43 @@ +{ + "name": "XML-混合工具", + "endpoint": "openai", + "category": "repair", + "models": ["deepseek-v4-flash-de"], + "system": "你是一个全能助手,可以使用 get_weather 和 web_search 工具。\n重要通知:工具调用格式已更新。从即日起,所有工具调用必须使用 XML 格式,不再支持 JSON 格式。\n正确示例:\nget_weather{\"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"] + } + } + }, + { + "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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/04_mixed_xml_json.json b/e2e/scenarios/repair/04_mixed_xml_json.json new file mode 100644 index 0000000..6f4b468 --- /dev/null +++ b/e2e/scenarios/repair/04_mixed_xml_json.json @@ -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正确示例:\nget_weather[{\"arguments\": {\"city\": \"北京\"}}]\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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/05_json_wrong_field_names.json b/e2e/scenarios/repair/05_json_wrong_field_names.json new file mode 100644 index 0000000..05b398f --- /dev/null +++ b/e2e/scenarios/repair/05_json_wrong_field_names.json @@ -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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/06_json_arguments_as_string.json b/e2e/scenarios/repair/06_json_arguments_as_string.json new file mode 100644 index 0000000..f6cfaf4 --- /dev/null +++ b/e2e/scenarios/repair/06_json_arguments_as_string.json @@ -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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/07_json_bracket_mismatch.json b/e2e/scenarios/repair/07_json_bracket_mismatch.json new file mode 100644 index 0000000..2b8d62f --- /dev/null +++ b/e2e/scenarios/repair/07_json_bracket_mismatch.json @@ -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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/08_json_missing_bracket.json b/e2e/scenarios/repair/08_json_missing_bracket.json new file mode 100644 index 0000000..f855878 --- /dev/null +++ b/e2e/scenarios/repair/08_json_missing_bracket.json @@ -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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/09_json_swapped_name_arguments.json b/e2e/scenarios/repair/09_json_swapped_name_arguments.json new file mode 100644 index 0000000..a8ee99c --- /dev/null +++ b/e2e/scenarios/repair/09_json_swapped_name_arguments.json @@ -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 + } +} \ No newline at end of file diff --git a/e2e/scenarios/repair/10_json_parameters_spilled.json b/e2e/scenarios/repair/10_json_parameters_spilled.json new file mode 100644 index 0000000..bbbf069 --- /dev/null +++ b/e2e/scenarios/repair/10_json_parameters_spilled.json @@ -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 + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index f7b9101..3170c54 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/internal/anthropic/convert.go b/internal/anthropic/convert.go index 4404e5f..70a24ab 100644 --- a/internal/anthropic/convert.go +++ b/internal/anthropic/convert.go @@ -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 } diff --git a/internal/anthropic/convert_test.go b/internal/anthropic/convert_test.go index 039c708..d4f619f 100644 --- a/internal/anthropic/convert_test.go +++ b/internal/anthropic/convert_test.go @@ -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) + } +} diff --git a/internal/api/alias_test.go b/internal/api/alias_test.go new file mode 100644 index 0000000..97ca3a1 --- /dev/null +++ b/internal/api/alias_test.go @@ -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) + } +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7c30dd1..61c8d29 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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) } diff --git a/internal/api/chat.go b/internal/api/chat.go index f3575a0..441438e 100644 --- a/internal/api/chat.go +++ b/internal/api/chat.go @@ -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) diff --git a/internal/api/idempotency.go b/internal/api/idempotency.go new file mode 100644 index 0000000..bf7d371 --- /dev/null +++ b/internal/api/idempotency.go @@ -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 } diff --git a/internal/api/messages.go b/internal/api/messages.go index a22bf2c..5aca310 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -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 } diff --git a/internal/api/responses.go b/internal/api/responses.go index d57bf8e..d2feeeb 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -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() diff --git a/internal/api/responsestore.go b/internal/api/responsestore.go new file mode 100644 index 0000000..79db2af --- /dev/null +++ b/internal/api/responsestore.go @@ -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 +} diff --git a/internal/api/server.go b/internal/api/server.go index 4d47123..699087a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 diff --git a/internal/api/stateful_test.go b/internal/api/stateful_test.go new file mode 100644 index 0000000..4621b2a --- /dev/null +++ b/internal/api/stateful_test.go @@ -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) + } +} diff --git a/internal/config/alias_test.go b/internal/config/alias_test.go new file mode 100644 index 0000000..2093369 --- /dev/null +++ b/internal/config/alias_test.go @@ -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) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6cb2c6e..b6949d9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/openai/argstream.go b/internal/openai/argstream.go index e015cec..1293f36 100644 --- a/internal/openai/argstream.go +++ b/internal/openai/argstream.go @@ -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 { diff --git a/internal/openai/argstream_test.go b/internal/openai/argstream_test.go index db58555..27d2fab 100644 --- a/internal/openai/argstream_test.go +++ b/internal/openai/argstream_test.go @@ -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完成。" diff --git a/internal/openai/prompt.go b/internal/openai/prompt.go index 8918e05..2366f7b 100644 --- a/internal/openai/prompt.go +++ b/internal/openai/prompt.go @@ -207,6 +207,7 @@ func ResponsesToMessages(req *ResponsesRequest) ([]ChatMessage, error) { // reasoning and other item types have no chat representation } } + BackfillToolIDs(out) return out, nil } diff --git a/internal/openai/prompt_test.go b/internal/openai/prompt_test.go index bd0be05..257ce57 100644 --- a/internal/openai/prompt_test.go +++ b/internal/openai/prompt_test.go @@ -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) + } +} diff --git a/internal/openai/repair_test.go b/internal/openai/repair_test.go new file mode 100644 index 0000000..2b7ed31 --- /dev/null +++ b/internal/openai/repair_test.go @@ -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 …{json}. +func TestRepairXMLBasic(t *testing.T) { + raw := `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) + } +} + +// 02: XML parameters delivered as . +func TestRepairXMLMultiParam(t *testing.T) { + raw := `get_weather北京` + c := mustOneCall(t, raw, toolsFor("get_weather")) + if c.Function.Name != "get_weather" || argsField(t, c, "city") != "北京" { + t.Fatalf("call = %+v", c) + } +} + +// 03: several dialects inside one wrapper. +func TestRepairXMLInvokeCalls(t *testing.T) { + raw := `北京` + + `故宫` + _, 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 := `get_weather[{"arguments": {"city": "北京"}}]` + 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 := `[{name: "read_file", arguments: {path: "C:\Users\name"}}]` + 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|>get_weather<|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\nget_weather\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) + } +} diff --git a/internal/openai/stored.go b/internal/openai/stored.go new file mode 100644 index 0000000..00b1524 --- /dev/null +++ b/internal/openai/stored.go @@ -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) + } +} diff --git a/internal/openai/toolcalls.go b/internal/openai/toolcalls.go index 991f465..89af561 100644 --- a/internal/openai/toolcalls.go +++ b/internal/openai/toolcalls.go @@ -4,134 +4,88 @@ import ( "crypto/rand" "encoding/json" "strings" + "unicode" + "unicode/utf8" ) +// Special begin/end markers DeepSeek emits around a tool-call block. The +// separators use U+2581 / U+FF5C lookalikes, so tag matching normalizes them. +const ( + toolCallStart = "<|tool▁calls▁begin|>" + toolCallEnd = "<|tool▁calls▁end|>" +) + +// builtinStartTags / builtinEndTags mirror the reference defaults: the special +// tokens plus the / XML wrappers models hallucinate. +var ( + builtinStartTags = []string{toolCallStart, "<|tool_call_begin|>", "", ""} + builtinEndTags = []string{toolCallEnd, "<|tool_call_end|>", "", ""} +) + +// ParseOptions tunes tool-call extraction. ExtraStarts/ExtraEnds add tag +// wrappers beyond the built-ins; ToolNames lets the loose bare/spilled-object +// repair require a name that actually matches an offered tool. +type ParseOptions struct { + ExtraStarts []string + ExtraEnds []string + ToolNames []string +} + +func optionsForTools(tools []ToolDef) ParseOptions { + opts := ParseOptions{} + for _, t := range tools { + if n := strings.TrimSpace(t.Function.Name); n != "" { + opts.ToolNames = append(opts.ToolNames, n) + } + } + return opts +} + // TryParseToolCalls extracts an OpenAI-style tool_calls object from raw model -// output. It mirrors the Python implementation: plain JSON, ```json fences and -// generic ``` fences are all candidates. +// output. It mirrors the Python/Rust implementations: plain JSON, ```json +// fences, generic ``` fences, XML// wrappers and the usual +// model repairs (unquoted keys, invalid backslashes, field aliases, +// swapped/ spilled arguments) are all accepted. // // Returns the remaining prose plus the parsed tool calls (ok=false when the // text is ordinary chat). func TryParseToolCalls(text string) (string, []ToolCall, bool) { - trimmed := strings.TrimSpace(text) - var candidates []string - if strings.HasPrefix(trimmed, "{") { - candidates = append(candidates, trimmed) - } else if i := strings.Index(trimmed, `{"tool_calls"`); i > 0 { - // prose before the tool JSON: start the candidate at the payload - candidates = append(candidates, strings.TrimSpace(trimmed[i:])) - } - if strings.Contains(trimmed, "```json") { - for _, part := range strings.Split(trimmed, "```json")[1:] { - code := strings.TrimSpace(firstBlock(part)) - if strings.HasPrefix(code, "{") { - candidates = append(candidates, code) - } - } - } else if strings.Contains(trimmed, "```") { - for _, part := range strings.Split(trimmed, "```")[1:] { - code := strings.TrimSpace(firstBlock(part)) - if strings.HasPrefix(code, "{") { - candidates = append(candidates, code) - } - } - } + return TryParseToolCallsWith(text, ParseOptions{}) +} - for _, cand := range candidates { - var payload struct { - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function json.RawMessage `json:"function"` - } `json:"tool_calls"` - } - if !decodeToolPayload(cand, &payload) { +// TryParseToolCallsForTools is TryParseToolCalls with the offered tool names +// used to validate the looser repairs (bare object / spilled parameters). +func TryParseToolCallsForTools(text string, tools []ToolDef) (string, []ToolCall, bool) { + return TryParseToolCallsWith(text, optionsForTools(tools)) +} + +// TryParseToolCallsWith is TryParseToolCalls with explicit options. +func TryParseToolCallsWith(text string, opts ParseOptions) (string, []ToolCall, bool) { + if remaining, calls, ok := parseJSONToolCalls(text, opts); ok { + return remaining, calls, true + } + return parseTaggedToolCalls(text, opts) +} + +// --------------------------------------------------------------------------- +// JSON payload path +// --------------------------------------------------------------------------- + +func parseJSONToolCalls(text string, opts ParseOptions) (string, []ToolCall, bool) { + for _, cand := range jsonCandidates(text) { + var v any + if !decodeJSONValue(cand, &v) { continue } - // A prose-prefix candidate runs to the end of the text; keep only - // the payload itself so prose after the JSON survives in remaining. + calls := callsFromValue(v, opts, "") + if len(calls) == 0 { + continue + } + // A prose-prefix candidate runs to the end of the text; keep only the + // payload itself so prose after the JSON survives in remaining. if full, ok := jsonPrefix(cand); ok && len(full) < len(cand) { cand = full } - if payload.ToolCalls == nil { - continue - } - calls := make([]ToolCall, 0, len(payload.ToolCalls)) - bad := false - for i, tc := range payload.ToolCalls { - var fn struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` - } - // function 字段缺失或解析不出名字的条目是坏数据:标记 bad 让整 - // 个候选被丢弃,而不是把一个 name 为空的 tool call 交给客户端 - // (错误被吞掉后要到执行阶段才暴露)。 - if err := json.Unmarshal(tc.Function, &fn); err != nil || fn.Name == "" { - bad = true - break - } - args := "{}" - if len(fn.Arguments) > 0 { - var s string - if err := json.Unmarshal(fn.Arguments, &s); err == nil { - // Continuation stitching may have introduced raw control - // characters into the inner JSON; heal them and close any - // trailing string/brace the model forgot before the - // channel cap let it stop. - s = EscapeControlChars(s) - args = balanceJSON(s) - if !json.Valid([]byte(args)) { - // The model's close sequence may be one brace or - // quote short, leaving stray closers after the inner - // object: keep the first complete JSON value and - // drop the dangling tail. - if prefix, ok := jsonPrefix(s); ok { - args = prefix - } - } - if !json.Valid([]byte(args)) { - // Last resort: the reply leaked a raw quote or got - // cut mid-escape — re-anchor on the final string - // literal and rebuild so the client still receives - // a tool call with whatever content survived. - if closed, ok := forceCloseJSON(s); ok { - args = closed - } - } - if !json.Valid([]byte(args)) { - bad = true - break - } - } else { - // arguments already delivered as an object - var obj any - if json.Unmarshal(fn.Arguments, &obj) == nil { - raw, _ := json.Marshal(obj) - args = string(raw) - } - } - } - id := tc.ID - if id == "" { - id = "call_" + randomHex(8) - } - typ := tc.Type - if typ == "" { - typ = "function" - } - _ = i - calls = append(calls, ToolCall{ - ID: id, - Type: typ, - Function: FunctionCall{ - Name: fn.Name, - Arguments: args, - }, - }) - } - if bad { - continue - } remaining := strings.ReplaceAll(text, cand, "") remaining = strings.ReplaceAll(remaining, "```json", "") remaining = strings.ReplaceAll(remaining, "```", "") @@ -140,31 +94,821 @@ func TryParseToolCalls(text string) (string, []ToolCall, bool) { return text, nil, false } -// decodeToolPayload unmarshals cand, falling back to suffix balancing (the -// model broke out mid-string) and to the first complete JSON value (stray -// closers after a complete object). -func decodeToolPayload(cand string, payload any) bool { - if err := json.Unmarshal([]byte(cand), payload); err == nil { +// jsonCandidates collects the substrings that could hold a tool-call payload: +// the trimmed text itself, the suffix after a prose prefix, and fenced blocks. +func jsonCandidates(text string) []string { + trimmed := strings.TrimSpace(text) + var out []string + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + out = append(out, trimmed) + } else if i := strings.Index(trimmed, `{"tool_calls"`); i > 0 { + out = append(out, strings.TrimSpace(trimmed[i:])) + } else if i := firstJSONStart(trimmed); i > 0 { + out = append(out, strings.TrimSpace(trimmed[i:])) + } + if strings.Contains(trimmed, "```json") { + for _, part := range strings.Split(trimmed, "```json")[1:] { + out = appendCandidate(out, part) + } + } else if strings.Contains(trimmed, "```") { + for _, part := range strings.Split(trimmed, "```")[1:] { + out = appendCandidate(out, part) + } + } + return out +} + +func appendCandidate(out []string, part string) []string { + code := strings.TrimSpace(firstBlock(part)) + if strings.HasPrefix(code, "{") || strings.HasPrefix(code, "[") { + return append(out, code) + } + return out +} + +func firstJSONStart(s string) int { return strings.IndexAny(s, "{[") } + +func lastJSONEnd(s string) int { + i := strings.LastIndexByte(s, '}') + if j := strings.LastIndexByte(s, ']'); j > i { + i = j + } + return i +} + +// decodeJSONValue unmarshals raw, falling back through the repair ladder: +// unquoted keys / invalid backslashes, suffix balancing, the first complete +// JSON value, then the force-close rebuild. +func decodeJSONValue(raw string, out *any) bool { + try := func(s string) bool { + if s == "" { + return false + } + var v any + if err := json.Unmarshal([]byte(s), &v); err == nil { + *out = v + return true + } + return false + } + if try(raw) { return true } - if balanced := balanceJSON(cand); balanced != cand { - if err := json.Unmarshal([]byte(balanced), payload); err == nil { - return true + if fixed, ok := repairJSON(raw); ok && try(fixed) { + return true + } + // The candidate may carry prose or tag debris around the payload: retry + // the repairs on the bracketed JSON extent only. + if i := firstJSONStart(raw); i >= 0 { + if j := lastJSONEnd(raw); j > i { + sub := raw[i : j+1] + if fixed, ok := repairJSON(sub); ok && try(fixed) { + return true + } + if balanced := balanceJSON(sub); balanced != sub && try(balanced) { + return true + } } } - if prefix, ok := jsonPrefix(cand); ok { - if err := json.Unmarshal([]byte(prefix), payload); err == nil { - return true - } + if balanced := balanceJSON(raw); balanced != raw && try(balanced) { + return true } - if closed, ok := forceCloseJSON(cand); ok { - if err := json.Unmarshal([]byte(closed), payload); err == nil { + if prefix, ok := jsonPrefix(raw); ok && try(prefix) { + return true + } + if closed, ok := forceCloseJSON(raw); ok && try(closed) { + return true + } + return false +} + +// callsFromValue maps a decoded JSON value onto tool calls. A {"tool_calls": +// …} shell, a bare array or a bare object are all accepted. +func callsFromValue(v any, opts ParseOptions, fallbackName string) []ToolCall { + switch t := v.(type) { + case map[string]any: + if shell, ok := t["tool_calls"]; ok { + return callsFromCollection(shell, opts, fallbackName) + } + return normalizeAndMap([]any{t}, opts, fallbackName) + case []any: + return normalizeAndMap(t, opts, fallbackName) + } + return nil +} + +func callsFromCollection(v any, opts ParseOptions, fallbackName string) []ToolCall { + switch t := v.(type) { + case []any: + if len(t) == 0 { + return nil + } + return normalizeAndMap(t, opts, fallbackName) + case map[string]any: + return normalizeAndMap([]any{t}, opts, fallbackName) + } + return nil +} + +// normalizeAndMap requires every item to map onto a valid call: a single bad +// entry rejects the whole collection (a name-less call must never reach the +// client, where it only fails at execution time). +func normalizeAndMap(items []any, opts ParseOptions, fallbackName string) []ToolCall { + calls := make([]ToolCall, 0, len(items)) + for _, it := range items { + if m, ok := it.(map[string]any); ok && fallbackName != "" && !hasNameish(m) { + m["name"] = fallbackName + } + c, ok := callFromItem(it, opts) + if !ok { + return nil + } + calls = append(calls, c) + } + return calls +} + +func hasNameish(m map[string]any) bool { + if _, ok := m["function"]; ok { + return true + } + for _, k := range nameKeys { + if _, ok := m[k]; ok { return true } } return false } +var ( + nameKeys = []string{"name", "tool", "tool_name", "function_name", "fn"} + argKeys = []string{"arguments", "params", "parameters", "args", "input", "function_input"} + metaKeys = map[string]bool{ + "id": true, "type": true, "index": true, "function": true, + "name": true, "tool": true, "tool_name": true, "function_name": true, "fn": true, + "arguments": true, "params": true, "parameters": true, "args": true, + "input": true, "function_input": true, "call_id": true, + } +) + +// callFromItem maps one tool-call object onto a ToolCall, applying the field +// alias, name/arguments swap and spilled-parameter repairs. +func callFromItem(item any, opts ParseOptions) (ToolCall, bool) { + obj, ok := item.(map[string]any) + if !ok { + return ToolCall{}, false + } + name, args, ok := extractNameArgs(obj, opts) + if !ok || name == "" { + return ToolCall{}, false + } + argText, ok := encodeArguments(args) + if !ok { + return ToolCall{}, false + } + id := stringValue(obj["id"]) + if id == "" { + id = "call_" + randomHex(8) + } + typ := stringValue(obj["type"]) + if typ == "" { + typ = "function" + } + return ToolCall{ + ID: id, + Type: typ, + Function: FunctionCall{Name: name, Arguments: argText}, + }, true +} + +func extractNameArgs(obj map[string]any, opts ParseOptions) (string, any, bool) { + // Nested function object: {"function":{"name":…,"arguments":…}}. + if fn, ok := obj["function"].(map[string]any); ok { + name := firstString(fn, "name") + if name == "" { + name = firstString(obj, nameKeys...) + } + if name == "" { + return "", nil, false + } + if args, ok := lookupArg(fn); ok { + return name, args, true + } + if args, ok := lookupArg(obj); ok { + return name, args, true + } + if spilled := spilledArgs(obj); len(spilled) > 0 { + return name, spilled, true + } + return name, map[string]any{}, true + } + // function as a plain string alias: {"function":"get_weather","params":{…}}. + if fn, ok := obj["function"].(string); ok && fn != "" { + if args, ok := lookupArg(obj); ok { + return fn, args, true + } + return fn, spilledArgs(obj), true + } + // Swapped: name holds the parameter object, arguments holds the name. + if nameObj, ok := obj["name"].(map[string]any); ok { + if s, ok := obj["arguments"].(string); ok && s != "" { + return s, nameObj, true + } + } + name := firstString(obj, nameKeys...) + if name == "" { + return "", nil, false + } + if args, ok := lookupArg(obj); ok { + return name, args, true + } + // Parameters spilled to the top level: every non-meta key is an argument. + if spilled := spilledArgs(obj); len(spilled) > 0 { + if len(opts.ToolNames) == 0 || containsString(opts.ToolNames, name) { + return name, spilled, true + } + // The offered tool list is known and the name is not one of them: + // this is ordinary JSON, not a tool call. + return "", nil, false + } + return name, map[string]any{}, true +} + +func lookupArg(obj map[string]any) (any, bool) { + for _, k := range argKeys { + if v, ok := obj[k]; ok { + return v, true + } + } + return nil, false +} + +func spilledArgs(obj map[string]any) map[string]any { + out := map[string]any{} + for k, v := range obj { + if metaKeys[k] { + continue + } + out[k] = v + } + return out +} + +// encodeArguments renders the arguments value: a string is healed into valid +// JSON, anything else is marshalled as-is. +func encodeArguments(v any) (string, bool) { + switch t := v.(type) { + case nil: + return "{}", true + case string: + return healArgumentString(t) + default: + raw, err := json.Marshal(t) + if err != nil { + return "", false + } + return string(raw), true + } +} + +func healArgumentString(s string) (string, bool) { + s = EscapeControlChars(s) + if json.Valid([]byte(s)) { + return s, true + } + if fixed, ok := repairJSON(s); ok { + return fixed, true + } + if balanced := balanceJSON(s); json.Valid([]byte(balanced)) { + return balanced, true + } + if prefix, ok := jsonPrefix(s); ok { + return prefix, true + } + if closed, ok := forceCloseJSON(s); ok { + return closed, true + } + return "", false +} + +func firstString(obj map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := obj[k].(string); ok && s != "" { + return s + } + } + return "" +} + +func stringValue(v any) string { + s, _ := v.(string) + return s +} + +func containsString(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Tagged / XML path +// --------------------------------------------------------------------------- + +func parseTaggedToolCalls(text string, opts ParseOptions) (string, []ToolCall, bool) { + var calls []ToolCall + var remaining strings.Builder + pos, last := 0, 0 + for pos < len(text) { + start, startLen, startTag, ok := findStartTag(text[pos:], opts) + if !ok { + break + } + abs := pos + start + if isInsideCodeFence(text, abs) { + break + } + after := abs + startLen + innerEnd, end := len(text), len(text) + if p, l, found := findEndTag(text, after, opts, startTag); found && p >= after { + innerEnd, end = p, p+l + } + got := extractTaggedCalls(text[after:innerEnd], opts) + if len(got) > 0 { + remaining.WriteString(text[last:abs]) + calls = append(calls, got...) + last, pos = end, end + continue + } + pos = after + if pos <= abs { + pos = abs + 1 + } + } + if len(calls) == 0 { + return text, nil, false + } + remaining.WriteString(text[last:]) + return strings.TrimSpace(remaining.String()), calls, true +} + +// extractTaggedCalls parses the body between a tag pair: a wrapped JSON array +// or object first, then the XML dialects. +func extractTaggedCalls(inner string, opts ParseOptions) []ToolCall { + fallbackName := extractXMLName(inner) + if calls := parseJSONInText(inner, opts, fallbackName); len(calls) > 0 { + return calls + } + if calls := parseXMLInvokeCalls(inner, opts); len(calls) > 0 { + return calls + } + return parseXMLFunctionCalls(inner, opts) +} + +// parseJSONInText pulls the first JSON array/object out of inner and maps it, +// injecting fallbackName into items that carry arguments but no name (the +// mixed …[{"arguments":…}] +// shape). +func parseJSONInText(inner string, opts ParseOptions, fallbackName string) []ToolCall { + if i := strings.IndexByte(inner, '['); i >= 0 { + if j := strings.LastIndexByte(inner, ']'); j > i { + raw := inner[i : j+1] + var v any + if decodeJSONValue(raw, &v) { + return callsFromCollection(v, opts, fallbackName) + } + } + } + if i := strings.IndexByte(inner, '{'); i >= 0 { + if j := strings.LastIndexByte(inner, '}'); j > i { + raw := inner[i : j+1] + var v any + if decodeJSONValue(raw, &v) { + return callsFromValue(v, opts, fallbackName) + } + } + } + return nil +} + +func parseXMLInvokeCalls(inner string, opts ParseOptions) []ToolCall { + var calls []ToolCall + lower := strings.ToLower(inner) + pos := 0 + for { + i := strings.Index(lower[pos:], "') + if gt < 0 { + return calls + } + head := inner[abs : abs+gt+1] + bodyStart := abs + gt + 1 + ci := strings.Index(lower[bodyStart:], "") + if ci < 0 { + return calls + } + body := inner[abs : bodyStart+ci] + name := xmlAttr(head, "name") + if name == "" { + name = xmlTagValue(body, "name") + } + if name != "" { + calls = append(calls, makeXMLCall(name, parseXMLParams(body))) + } + pos = bodyStart + ci + len("") + } +} + +func parseXMLFunctionCalls(inner string, opts ParseOptions) []ToolCall { + var calls []ToolCall + lower := strings.ToLower(inner) + pos := 0 + for { + i := strings.Index(lower[pos:], "') + if gt < 0 { + return calls + } + head := inner[abs : abs+gt+1] + selfClose := strings.HasSuffix(strings.TrimSpace(head), "/>") + bodyStart := abs + gt + 1 + bodyEnd, next := len(inner), len(inner) + if !selfClose { + if ci := strings.Index(lower[bodyStart:], ""); ci >= 0 { + bodyEnd = bodyStart + ci + next = bodyEnd + len("") + } + } + body := inner[bodyStart:bodyEnd] + name := xmlAttr(head, "name") + if name == "" { + name = xmlTagValue(body, "name") + } + if name != "" { + calls = append(calls, makeXMLCall(name, parseXMLParams(body))) + } + if next >= len(inner) { + return calls + } + pos = next + } +} + +func makeXMLCall(name string, params map[string]any) ToolCall { + raw, err := json.Marshal(params) + if err != nil || len(raw) == 0 { + raw = []byte("{}") + } + return ToolCall{ + ID: "call_" + randomHex(8), + Type: "function", + Function: FunctionCall{Name: name, Arguments: string(raw)}, + } +} + +// parseXMLParams collects v / +// values, falling back to the JSON inside . +func parseXMLParams(body string) map[string]any { + params := map[string]any{} + lower := strings.ToLower(body) + for _, tag := range []string{"parameter", "param"} { + open := "<" + tag + closeTag := "" + pos := 0 + for { + i := strings.Index(lower[pos:], open) + if i < 0 { + break + } + abs := pos + i + gt := strings.IndexByte(body[abs:], '>') + if gt < 0 { + break + } + head := body[abs : abs+gt+1] + bodyStart := abs + gt + 1 + if strings.HasSuffix(strings.TrimSpace(head), "/>") { + pos = bodyStart + continue + } + name := xmlAttr(head, "name") + ci := strings.Index(lower[bodyStart:], closeTag) + if ci < 0 { + break + } + if name != "" { + params[name] = coerceXMLValue(body[bodyStart : bodyStart+ci]) + } + pos = bodyStart + ci + len(closeTag) + } + } + if len(params) == 0 { + if args := xmlTagValue(body, "arguments"); args != "" { + var v any + if decodeJSONValue(strings.TrimSpace(args), &v) { + if m, ok := v.(map[string]any); ok { + return m + } + } + } + } + return params +} + +func coerceXMLValue(s string) any { + t := strings.TrimSpace(s) + if t == "" { + return "" + } + var v any + if json.Unmarshal([]byte(t), &v) == nil { + return v + } + return s +} + +func extractXMLName(s string) string { + if v := xmlTagValue(s, "name"); v != "" { + return v + } + if i := strings.Index(strings.ToLower(s), "= 0 { + if v := xmlAttr(s[i:], "name"); v != "" { + return v + } + } + if i := strings.Index(strings.ToLower(s), "= 0 { + if v := xmlAttr(s[i:], "name"); v != "" { + return v + } + } + return "" +} + +// xmlTagValue returns the trimmed body of the first … pair. +func xmlTagValue(s, tag string) string { + lower := strings.ToLower(s) + open := "<" + strings.ToLower(tag) + i := strings.Index(lower, open) + if i < 0 { + return "" + } + gt := strings.IndexByte(s[i:], '>') + if gt < 0 { + return "" + } + bodyStart := i + gt + 1 + closeTag := "" + ci := strings.Index(lower[bodyStart:], closeTag) + if ci < 0 { + return "" + } + return strings.TrimSpace(s[bodyStart : bodyStart+ci]) +} + +// xmlAttr returns the value of attr="…" / attr='…' inside the first tag of s. +func xmlAttr(s, attr string) string { + lower := strings.ToLower(s) + key := strings.ToLower(attr) + i := strings.Index(lower, key) + if i < 0 { + return "" + } + rest := s[i+len(attr):] + rest = strings.TrimLeft(rest, " \t\r\n") + if rest == "" || rest[0] != '=' { + return "" + } + rest = strings.TrimLeft(rest[1:], " \t\r\n") + if rest == "" { + return "" + } + quote := rest[0] + if quote != '"' && quote != '\'' { + return "" + } + end := strings.IndexByte(rest[1:], quote) + if end < 0 { + return "" + } + return rest[1 : 1+end] +} + +func findStartTag(text string, opts ParseOptions) (int, int, string, bool) { + for _, tag := range builtinStartTags { + if p, l, ok := findTagFuzzy(text, tag); ok { + return p, l, tag, true + } + } + for _, tag := range opts.ExtraStarts { + if strings.TrimSpace(tag) == "" { + continue + } + if p, l, ok := findTagFuzzy(text, tag); ok { + return p, l, tag, true + } + } + return 0, 0, "", false +} + +func findEndTag(s string, from int, opts ParseOptions, startTag string) (int, int, bool) { + if from > len(s) { + from = len(s) + } + search := s[from:] + if open := strings.TrimRight(startTag, ">"); strings.HasPrefix(open, "<") && !strings.HasPrefix(open, ""); ok { + return from + p, l, true + } + } + for _, tag := range builtinEndTags { + if p, l, ok := findTagFuzzy(search, tag); ok { + return from + p, l, true + } + } + for _, tag := range opts.ExtraEnds { + if strings.TrimSpace(tag) == "" { + continue + } + if p, l, ok := findTagFuzzy(search, tag); ok { + return from + p, l, true + } + } + // A following start tag also terminates the block when the model forgot + // the end marker between two calls. + if p, l, ok := findTagFuzzy(search, startTag); ok { + return from + p, l, true + } + for _, tag := range builtinStartTags { + if p, l, ok := findTagFuzzy(search, tag); ok { + return from + p, l, true + } + } + return 0, 0, false +} + +// findTagFuzzy locates tag (with or without the trailing '>') in s, first by +// exact match then by the fullwidth/underscore-normalized fuzzy match. +func findTagFuzzy(s, tag string) (int, int, bool) { + partial := strings.TrimRight(tag, ">") + if partial == "" { + return 0, 0, false + } + if i := strings.Index(s, partial); i >= 0 { + l := len(partial) + if i+l < len(s) && s[i+l] == '>' { + l++ + } + return i, l, true + } + if p, l, ok := fuzzyMatchTag(s, partial); ok { + if p+l < len(s) && s[p+l] == '>' { + l++ + } + return p, l, true + } + return 0, 0, false +} + +// normTagChar normalizes the lookalike separators: '|'(U+FF5C) → '|', +// '▁'(U+2581) → '_'. +func normTagChar(r rune) rune { + switch r { + case '\uFF5C': + return '|' + case '\u2581': + return '_' + } + return r +} + +func eqTagChar(a, b rune) bool { + return a == b || normTagChar(a) == normTagChar(b) +} + +func fuzzyMatchTag(haystack, partial string) (int, int, bool) { + n := []rune(partial) + h := []rune(haystack) + if len(n) == 0 || len(h) < len(n) { + return 0, 0, false + } + for start := 0; start+len(n) <= len(h); start++ { + matched := true + for j := 0; j < len(n); j++ { + if !eqTagChar(n[j], h[start+j]) { + matched = false + break + } + } + if matched { + bpos, blen := 0, 0 + for _, r := range h[:start] { + bpos += utf8.RuneLen(r) + } + for _, r := range h[start : start+len(n)] { + blen += utf8.RuneLen(r) + } + return bpos, blen, true + } + } + return 0, 0, false +} + +func isInsideCodeFence(s string, pos int) bool { + if pos < 0 { + return false + } + if pos > len(s) { + pos = len(s) + } + return strings.Count(s[:pos], "```")%2 == 1 +} + +// repairJSON applies the two reference repairs — invalid backslashes then +// unquoted keys — and reports whether the result is valid JSON. +func repairJSON(s string) (string, bool) { + step1 := repairInvalidBackslashes(s) + if json.Valid([]byte(step1)) { + return step1, true + } + step2 := repairUnquotedKeys(step1) + if json.Valid([]byte(step2)) { + return step2, true + } + return "", false +} + +func repairInvalidBackslashes(s string) string { + runes := []rune(s) + var out strings.Builder + out.Grow(len(s)) + for i := 0; i < len(runes); i++ { + c := runes[i] + if c != '\\' { + out.WriteRune(c) + continue + } + if i+1 < len(runes) { + n := runes[i+1] + if strings.ContainsRune(`"\/bfnrtu`, n) { + out.WriteRune('\\') + out.WriteRune(n) + } else { + out.WriteString(`\\`) + out.WriteRune(n) + } + i++ + continue + } + out.WriteRune('\\') + } + return out.String() +} + +func repairUnquotedKeys(s string) string { + runes := []rune(s) + var out strings.Builder + out.Grow(len(s) + 32) + i := 0 + for i < len(runes) { + out.WriteRune(runes[i]) + if (runes[i] == '{' || runes[i] == ',') && i+1 < len(runes) { + i++ + for i < len(runes) && unicode.IsSpace(runes[i]) { + out.WriteRune(runes[i]) + i++ + } + if i < len(runes) && (unicode.IsLetter(runes[i]) || runes[i] == '_') { + keyStart := i + for i < len(runes) && (unicode.IsLetter(runes[i]) || unicode.IsDigit(runes[i]) || runes[i] == '_') { + i++ + } + if i < len(runes) && runes[i] == ':' { + out.WriteRune('"') + out.WriteString(string(runes[keyStart:i])) + out.WriteRune('"') + } else { + out.WriteString(string(runes[keyStart:i])) + continue + } + } + continue + } + i++ + } + return out.String() +} + // jsonPrefix returns the first complete JSON value in s, ignoring any // trailing garbage after it. func jsonPrefix(s string) (string, bool) { @@ -270,7 +1014,9 @@ func balanceJSON(s string) string { func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { - return "00000000"[:n] + // n*2 hex chars, same length as the happy path — and unlike + // "00000000"[:n] this cannot slice past the end for n > 8. + return strings.Repeat("0", n*2) } const hexdigits = "0123456789abcdef" out := make([]byte, n*2) diff --git a/internal/openai/toolids.go b/internal/openai/toolids.go new file mode 100644 index 0000000..a246c1f --- /dev/null +++ b/internal/openai/toolids.go @@ -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 + } +} diff --git a/internal/openai/toolids_test.go b/internal/openai/toolids_test.go new file mode 100644 index 0000000..74d8e95 --- /dev/null +++ b/internal/openai/toolids_test.go @@ -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) + } +} diff --git a/internal/openai/types.go b/internal/openai/types.go index a0d3a6f..9e1b034 100644 --- a/internal/openai/types.go +++ b/internal/openai/types.go @@ -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。