Files
nyoungo 86492460b0 feat: 工具调用解析、Responses 状态缓存、幂等重放与模型别名
- 重写工具调用解析:XML、DSML 未加引号键、fence 内跳过、参数外溢等形状,并按请求携带的 tools 过滤工具名
- 新增 GET /v1/responses/{id} 与 previous_response_id 有界状态缓存(进程内、TTL、store=false 不写入)
- /v1/chat/completions 支持 Idempotency-Key 幂等回放(1 MiB 上限、24h TTL、并发去重)
- 模型别名与 default_model 降级;错误体统一输出 param/code
- 补 e2e 修复场景与 docs/compat-audit.md 兼容性审计
2026-10-09 00:50:01 +08:00

138 lines
6.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenAI 兼容性审计(Chat Completions / Responses / Messages)
> 审计基准:`openai-openapi` 的 `CreateChatCompletionRequest` /
> `CreateChatCompletionStreamResponse` / `CreateResponse` / `ResponseStreamEvent`
> schema,以及 `openai-python` 的运行时解析逻辑。
> 审计对象:本仓库(`github.com/nyoungo/dsfree2api`)。
>
> 本文档记录**已核对**的差异与修复结论。每条结论标注规范来源,便于回归对照。
---
## 1. 工具调用修复(tool-call repair lineage)
模型在受限通道下会产出各种畸形工具调用。网关在 `internal/openai` 内做
宽容解析,避免把原始文本透传给客户端。
**规范**:Chat Completions 的 `tool_calls` 条目必须含 `type="function"` 与
`function.name`(`function.arguments` 为 JSON 字符串);Responses 的
`function_call` 条目必须含 `call_id` / `name` / `arguments`。
**已实现的修复**(`internal/openai/toolcalls.go`):
- JSON 外壳:`{"tool_calls":[...]}`、裸数组、裸对象、```json / ``` 围栏;
- 未加引号的键(`{name: "f"}` → `{"name": "f"}`);
- 非法反斜杠(`C:\Users` → `C:\\Users`);
- 字段别名:`function`/`params`/`parameters`/`args`/`input`;
- `name` 与 `arguments` 互换(`name` 为对象、`arguments` 为字符串);
- 参数外溢到顶层(`{"name":"f","city":"北京"}`);
- XML 方言:`<tool_call>` / `<tool_calls>` / `<invoke name>` + `<parameter>`,
以及 `<function><name>…<arguments>…`;
- 特殊标记 `<|tool▁calls▁begin|>` / `<|tool▁calls▁end|>`,并对 `|`(U+FF5C)、
`▁`(U+2581) 做模糊匹配;
- 代码围栏内的 XML 示例会被跳过(`isInsideCodeFence`),但围栏内的 JSON
仍按既有行为解析(由 `TestTryParseToolCallsMarkdownJSONBlock` 固定)。
**约束**:`function.name` 缺失的条目会被整条候选丢弃,绝不返回 `name` 为空的
调用(否则错误会被推迟到执行阶段才暴露)。
**已知差距**:流式路径(`internal/openai/argstream.go`)只对流式化的
`{"tool_calls":…}` 形状增量下发;上面这些新形状会在 `Finalize` 时作为完整
`tool_calls` 重发(`resend`),行为正确但不增量。可接受。
**回归**:`internal/openai/repair_test.go` 覆盖 10 个场景主题
(01–04 XML、05 字段别名、06 arguments 字符串、07/08 括号、09 互换、
10 参数外溢)。
---
## 2. 服务`param`/`code`字段缺失(OpenAI Error schema)
**规范**:OpenAI `Error` 的 `required` 为 `["type", "message", "param", "code"]`;
SDK 常按 `code` 区分 `model_not_found` 等语义。
**修复**:`internal/api/server.go` 的 `writeError` 现在总是输出
`message` / `type` / `param` / `code` 四个字段(不适用时为 `null`),并按状态码
推导:
- `401` → `code="invalid_api_key"`
- `404` 且 message 以 `model not found` 开头 → `param="model"`, `code="model_not_found"`
- `429` → `code="rate_limit_exceeded"`
- `413` → `code="request_too_large"`
- `502`/`504` → `code="upstream_error"` / `upstream_timeout"`
- `idempotency_error` → `code="idempotency_error"`
Anthropic 信封(`writeAnthropicError`)保持 `{"type":"error","error":{type,message}}`
不变。
---
## 3. Responses API 状态(`previous_response_id` / `store`)
**规范**:`CreateResponse` 支持 `store`(默认 `true`)与 `previous_response_id`;
`GET /v1/responses/{id}` 返回响应快照。
**修复**:
- `openai.ResponsesRequest` 新增 `store *bool` 与 `previous_response_id string`;
- 进程内、有界、带 TTL 的缓存(`internal/api/responsestore.go`),
容量默认 256、TTL 默认 1h,可由 `[responses]` 配置;
- 非流式与流式完成路径都会写入(`store:false` 跳过);
- `previous_response_id` 命中时用 `openai.HistoryMessages` 重建上下文,
拼接到本轮 input 之前(system/developer 指令保持在最前);
- 过期 / 未知 `previous_response_id` → `400 invalid_request_error`;
`GET` 未知或过期 → `404`。
**取舍**:绝不落盘(响应可能含用户内容),重启即失效,与上游无关的
无状态网关定位一致。
---
## 4. `Idempotency-Key` 语义
**规范**:Stripe / OpenAI 约定。
实现(`internal/api/idempotency.go`),作用于
`POST /v1/chat/completions`、`/v1/responses`、`/v1/messages`:
- 作用域 = `(API key, method+path, Idempotency-Key)`;
- 指纹 = `sha256(scope|key|body)`;
- 同键 + 不同请求体 → `400 idempotency_error`;
- 首次执行期间再次到达 → `409 idempotency_error`;
- 已完成 → 逐字节回放(状态码 / `Content-Type` / 响应体),
并带 `idempotent-replayed: true`;
- 单条响应体上限 1 MiB、总量 64 MiB、容量 1024、TTL 24h;
超限标记为不可回放(重试 `409`);写入失败或处理中途 panic 则撤销占位,
让重试可以真正重跑;
- 超过 255 字符的 key → `400`。
---
## 5. 模型别名
`config.ModelAliases` / `DefaultModel` + `Config.ResolveModel`:请求模型名按
`精确 id → 大小写不敏感 id → model_aliases(大小写不敏感)→ default_model`
解析,供 Codex CLI / Claude Code 发送的任意 `gpt-*` / `claude-*` 名字使用。
`/v1/models` 也会列出别名。见 `internal/config/alias_test.go`、
`internal/api/alias_test.go`。
---
## 6. 工具类型过滤与推理字段
Responses 请求里的非 function 工具(`web_search_preview` 等)会被
`ResponsesTool.ToToolDef` 丢弃(上游无法执行)。已由
`internal/openai/prompt_test.go:144` 固定。
`reasoning` / `reasoning_effort` 字段同样不会被转发(`ResponsesRequest` 不解析,
`encoding/json` 忽略未知字段),即对上游强制关闭,避免请求体被拒绝。
---
## 7. 待观察 / 风险
- **`hif-leim` 风险标记**:仅记录,暂不改变行为。若上游开始返回该标记,
需要单独评估对工具调用解析的影响。
- **流式工具增量覆盖**:见 §1「已知差距」。
- **状态缓存进程内**:多实例部署时 `previous_response_id` 与
`Idempotency-Key` 不共享;如需跨实例,需要外部存储(Redis)后续评估。