- 重写工具调用解析: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 兼容性审计
138 lines
6.1 KiB
Markdown
138 lines
6.1 KiB
Markdown
# 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)后续评估。 |