1
0
Files
nex/openspec/specs/protocol-adapter-openai/spec.md
lanyuanxiaoyao b7e205f4b6 refactor: 优化 URL 路径拼接,修复 /v1 重复问题
## 主要变更

**核心修改**:
- 路由定义:/:protocol/v1/*path → /:protocol/*path
- proxy_handler:nativePath 直接使用 path 参数,不添加 /v1 前缀
- OpenAI 适配器:DetectInterfaceType 和 BuildUrl 去掉 /v1 前缀
- Anthropic 适配器:保持 /v1 前缀(Claude Code 兼容)

**URL 格式变化**:
- OpenAI: /openai/v1/chat/completions → /openai/chat/completions
- Anthropic: /anthropic/v1/messages (保持不变)

**base_url 配置**:
- OpenAI: 配置到版本路径,如 https://api.openai.com/v1
- Anthropic: 不配置版本路径,如 https://api.anthropic.com

## 测试验证

- 所有单元测试通过
- 所有集成测试通过
- 真实 API 测试验证成功
- 跨协议转换正常工作

## 文档更新

- 更新 backend/README.md URL 格式说明
- 同步 OpenSpec 规范文件
2026-04-21 20:21:17 +08:00

360 lines
14 KiB
Markdown
Raw 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.
# Protocol Adapter - OpenAI
## Purpose
实现 OpenAI 协议的完整 ProtocolAdapter支持请求/响应编解码、流式转换和错误处理,遵循 OpenAI Chat Completions API 规范。
### Requirement: 实现 OpenAI ProtocolAdapter
系统 SHALL 全新实现 OpenAI 协议的完整 ProtocolAdapter对照 `docs/conversion_openai.md`。不沿用旧 `internal/protocol/openai/` 代码。
- `protocolName()` SHALL 返回 `"openai"`
- `supportsPassthrough()` SHALL 返回 true
- `buildHeaders(provider)` SHALL 构建 `Authorization: Bearer <api_key>``Content-Type: application/json`
- `buildUrl(nativePath, interfaceType)` SHALL 按接口类型映射 URL 路径,不带 `/v1` 前缀
- `supportsInterface()` SHALL 对 CHAT、MODELS、MODEL_INFO、EMBEDDINGS、RERANK 返回 true
#### Scenario: 认证 Header 构建
- **WHEN** 调用 buildHeaders(provider)
- **THEN** SHALL 设置 `Authorization: Bearer <provider.api_key>`
- **THEN** SHALL 设置 `Content-Type: application/json`
- **WHEN** provider.adapter_config 包含 organization
- **THEN** SHALL 设置 `OpenAI-Organization` Header
#### Scenario: URL 映射
- **WHEN** interfaceType == CHAT
- **THEN** SHALL 映射为 `/chat/completions`
- **WHEN** interfaceType == MODELS
- **THEN** SHALL 映射为 `/models`
- **WHEN** interfaceType == EMBEDDINGS
- **THEN** SHALL 映射为 `/embeddings`
### Requirement: OpenAI 请求解码OpenAI → Canonical
系统 SHALL 实现完整的 OpenAI ChatCompletionRequest 到 CanonicalRequest 的解码。
#### Scenario: System/Developer 消息提取
- **WHEN** OpenAI messages 中包含 role="system" 或 role="developer" 的消息
- **THEN** SHALL 提取为 canonical.systemString
- **THEN** 多条 system/developer 消息 SHALL 合并以 `\n\n` 分隔
- **THEN** SHALL 从 messages 数组中移除这些消息
#### Scenario: Assistant 消息中的 tool_calls 解码
- **WHEN** OpenAI assistant 消息包含 tool_calls
- **THEN** SHALL 将每个 tool_call 解码为 ContentBlock{type: "tool_use", id, name, input}
- **THEN** function.argumentsJSON 字符串SHALL 解析为原始 JSON 对象
#### Scenario: Tool 消息解码
- **WHEN** OpenAI 消息 role="tool"
- **THEN** SHALL 解码为 CanonicalMessage{role: "tool", content: [ToolResultBlock{tool_use_id, content}]}
- **THEN** tool_call_id SHALL 映射为 tool_use_id
#### Scenario: 参数映射
- **WHEN** 解码 OpenAI 请求参数
- **THEN** max_completion_tokens优先或 max_tokens SHALL 映射为 parameters.max_tokens
- **THEN** stopString 或 ArraySHALL 规范化为 parameters.stop_sequencesArray
- **THEN** temperature/top_p/frequency_penalty/presence_penalty SHALL 直接映射
#### Scenario: 公共字段映射
- **WHEN** 解码 OpenAI 公共字段
- **THEN** user SHALL 映射为 user_id
- **THEN** response_format SHALL 映射为 output_format
- **THEN** parallel_tool_calls SHALL 映射为 parallel_tool_use
- **THEN** reasoning_effort SHALL 映射为 thinking 配置("none" → disabled, 其他 → enabled+effort
#### Scenario: 废弃字段兼容
- **WHEN** OpenAI 请求包含 functions 或 function_call 字段
- **THEN** SHALL 转换为对应的 tools/tool_choice 格式
- **THEN** 仅在 tools/tool_choice 未设置时使用废弃字段
### Requirement: OpenAI 请求编码Canonical → OpenAI
系统 SHALL 实现完整的 CanonicalRequest 到 OpenAI ChatCompletionRequest 的编码。
#### Scenario: 模型名称覆盖
- **WHEN** 编码请求
- **THEN** SHALL 使用 provider.model_name 覆盖 canonical.model
#### Scenario: System 消息注入
- **WHEN** canonical.system 不为空
- **THEN** SHALL 编码为 messages 数组头部的 role="system" 消息
#### Scenario: Assistant <20><>息中 tool_calls 编码
- **WHEN** CanonicalMessage{role: "assistant"} 包含 tool_use 类型 ContentBlock
- **THEN** SHALL 提取到 message.tool_calls 数组({id, type: "function", function: {name, arguments}}
- **THEN** arguments SHALL 序列化为 JSON 字符串
#### Scenario: 角色交替合并
- **WHEN** Canonical 消息序列中存在连续同角色消息
- **THEN** SHALL 合并为单条 OpenAI 消息
- **THEN** 文本内容 SHALL 合并连接
#### Scenario: 参数编码
- **WHEN** 编码 CanonicalRequest 参数
- **THEN** parameters.max_tokens SHALL 映射为 max_completion_tokens
- **THEN** thinking.type=="disabled" SHALL 映射为 reasoning_effort="none"
- **THEN** thinking.effort SHALL 直接映射为 reasoning_effort
### Requirement: OpenAI 响应解码OpenAI → Canonical
系统 SHALL 实现 OpenAI ChatCompletionResponse 到 CanonicalResponse 的解码。
#### Scenario: 内容块解码
- **WHEN** OpenAI response.choice[0].message 包含 content
- **THEN** SHALL 解码为 TextBlock
- **WHEN** 包含 tool_calls
- **THEN** SHALL 解码为 ToolUseBlock 数组
- **WHEN** 包含 reasoning_content非标准兼容提供商
- **THEN** SHALL 解码为 ThinkingBlock
#### Scenario: 停止原因映射
- **WHEN** 解码 finish_reason
- **THEN** "stop" SHALL 映射为 "end_turn"
- **THEN** "length" SHALL 映射为 "max_tokens"
- **THEN** "tool_calls" SHALL 映射为 "tool_use"
- **THEN** "content_filter" SHALL 映射为 "content_filter"
#### Scenario: Usage 映射
- **WHEN** 解码 OpenAI usage
- **THEN** prompt_tokens SHALL 映射为 input_tokens
- **THEN** completion_tokens SHALL 映射为 output_tokens
- **THEN** prompt_tokens_details.cached_tokens SHALL 映射为 cache_read_tokens
### Requirement: OpenAI 响应编码Canonical → OpenAI
系统 SHALL 实现 CanonicalResponse 到 OpenAI ChatCompletionResponse 的编码。
#### Scenario: ThinkingBlock 编码
- **WHEN** CanonicalResponse 包含 ThinkingBlock
- **THEN** SHALL 编码为 message.reasoning_content非标准字段兼容提供商使用
#### Scenario: 降级处理
- **WHEN** canonical.stop_reason 为 "stop_sequence" 或 "refusal"
- **THEN** SHALL 映射为 finish_reason "stop"
- **WHEN** canonical.stop_reason 为 "pause_turn"
- **THEN** SHALL 映射为 finish_reason "stop"(降级)
### Requirement: OpenAI 流式解码器
系统 SHALL 实现 OpenAIStreamDecoder将 OpenAI SSE delta chunk 转换为 CanonicalStreamEvent。
Decoder SHALL 维护状态机:
- messageStarted: 是否已发送 MessageStartEvent
- openBlocks: 当前打开的 block index 集合
- toolCallIdMap/toolCallNameMap/toolCallArguments: 工具调用索引映射和参数累积
- textBlockStarted/thinkingBlockStarted: 文本/思考 block 生命周期追踪
- utf8Remainder: UTF-8 跨 chunk 安全缓冲
#### Scenario: 首个 chunk 触发 MessageStartEvent
- **WHEN** 收到第一个有效 chunk
- **THEN** SHALL 发出 MessageStartEvent包含 id 和 model
#### Scenario: delta.content 触发 text block 事件
- **WHEN** 收到 delta.content 首次出现
- **THEN** SHALL 发出 ContentBlockStartEvent(text) + ContentBlockDeltaEvent(text_delta)
- **WHEN** 收到 delta.content 后续出现
- **THEN** SHALL 发出 ContentBlockDeltaEvent(text_delta)
#### Scenario: delta.tool_calls 触发 tool_use block 事件
- **WHEN** delta.tool_calls[i] 首次出现(含 id
- **THEN** SHALL 发出 ContentBlockStartEvent(tool_use)
- **WHEN** delta.tool_calls[i].function.arguments 增量到达
- **THEN** SHALL 发出 ContentBlockDeltaEvent(input_json_delta)
#### Scenario: delta.reasoning_content 触发 thinking block 事件
- **WHEN** delta.reasoning_content 出现(非标准字段)
- **THEN** SHALL 发出 ContentBlockStartEvent(thinking) + ContentBlockDeltaEvent(thinking_delta)
#### Scenario: finish_reason 触发关闭事件
- **WHEN** finish_reason 非空
- **THEN** SHALL 为所有 open blocks 发出 ContentBlockStopEvent
- **THEN** SHALL 发出 MessageDeltaEvent含 stop_reason 映射)
- **THEN** SHALL 发出 MessageStopEvent
#### Scenario: usage chunk 处理
- **WHEN** 收到 choices 为空但含 usage 的 chunk
- **THEN** SHALL 发出 MessageDeltaEvent仅含 usage
#### Scenario: [DONE] 信号处理
- **WHEN** 收到 `data: [DONE]`
- **THEN** SHALL 触发 flush() 关闭所有 open blocks
#### Scenario: UTF-8 跨 chunk 安全
- **WHEN** chunk 边界截断了 UTF-8 多字节序列
- **THEN** SHALL 使用 utf8Remainder 缓冲不完整字节
- **THEN** 下一个 chunk 到达时 SHALL 拼接后重新解析
### Requirement: OpenAI 流式编码器
系统 SHALL 实现 OpenAIStreamEncoder将 CanonicalStreamEvent 编码为 OpenAI SSE chunk。
Encoder SHALL 维护状态:
- bufferedStart: 缓冲的 ContentBlockStartEvent
- toolCallIndexMap: tool_use_id → OpenAI tool_calls 数组索引映射
#### Scenario: ContentBlockStart 缓冲策略
- **WHEN** 收到 ContentBlockStartEvent
- **THEN** SHALL NOT 立即输出,缓冲等待首次 ContentBlockDeltaEvent
#### Scenario: ContentBlockDelta 合并输出
- **WHEN** 收到 ContentBlockDeltaEvent 且有缓冲的 StartEvent
- **THEN** SHALL 合并 Start 信息(如 tool id/name与 delta 数据一起输出
- **WHEN** 无缓冲 StartEvent
- **THEN** SHALL 仅输出 delta 数据
#### Scenario: MessageStopEvent 输出 [DONE]
- **WHEN** 收到 MessageStopEvent
- **THEN** SHALL 输出 `data: [DONE]`
#### Scenario: PingEvent 和 ErrorEvent 处理
- **WHEN** 收到 PingEvent 或 ErrorEvent
- **THEN** SHALL 不输出OpenAI 无流式错误/心跳事件)
### Requirement: OpenAI 错误编码
系统 SHALL 实现 OpenAI 协议的错误编码。
#### Scenario: 错误响应格式
- **WHEN** 调用 encodeError(conversionError)
- **THEN** SHALL 返回 `{error: {message, type, param: null, code}}`
- **THEN** ErrorCode SHALL 映射为 OpenAI 错误类型(如 INVALID_INPUT → "invalid_request_error"
### Requirement: OpenAI 扩展层接口编解码
系统 SHALL 实现 OpenAI 协议的扩展层接口编解码。
#### Scenario: /models 列表接口
- **WHEN** 解码 OpenAI models 响应
- **THEN** SHALL 映射为 CanonicalModelListdata[].id → models[].id, created, owned_by
- **WHEN** 编码 CanonicalModelList 为 OpenAI 格式
- **THEN** SHALL 输出 `{object: "list", data: [...]}`
#### Scenario: /embeddings 接口
- **WHEN** 解码/编码 embedding 请求和响应
- **THEN** SHALL 使用 CanonicalEmbeddingRequest/Response 做字段映射
#### Scenario: /rerank 接口
- **WHEN** 解码/编码 rerank 请求和响应
- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射
### Requirement: 模型详情路径识别
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的模型详情路径。
#### Scenario: 含斜杠的统一模型 ID 路径
- **WHEN** 路径为 `/models/openai/gpt-4`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
#### Scenario: 含多段斜杠的统一模型 ID 路径
- **WHEN** 路径为 `/models/azure/accounts/org-123/models/gpt-4`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
#### Scenario: 模型列表路径不受影响
- **WHEN** 路径为 `/models`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
### Requirement: 提取统一模型 ID
OpenAI 适配器 SHALL 从路径中提取统一模型 ID。
#### Scenario: 标准路径提取
- **WHEN** 调用 `ExtractUnifiedModelID("/models/openai/gpt-4")`
- **THEN** SHALL 返回 `"openai/gpt-4"`
#### Scenario: 复杂路径提取
- **WHEN** 调用 `ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")`
- **THEN** SHALL 返回 `"azure/accounts/org/models/gpt-4"`
#### Scenario: 非模型详情路径
- **WHEN** 调用 `ExtractUnifiedModelID("/models")`
- **THEN** SHALL 返回错误
### Requirement: 从请求体提取 model
OpenAI 适配器 SHALL 按 InterfaceType 从请求体中提取 model 值。
#### Scenario: Chat 请求提取 model
- **WHEN** 调用 `ExtractModelName(body, InterfaceTypeChat)`body 为 `{"model":"openai/gpt-4","messages":[...]}`
- **THEN** SHALL 返回 `"openai/gpt-4"`
#### Scenario: Embedding 请求提取 model
- **WHEN** 调用 `ExtractModelName(body, InterfaceTypeEmbeddings)`body 为 `{"model":"openai/text-embedding-3","input":"text"}`
- **THEN** SHALL 返回 `"openai/text-embedding-3"`
#### Scenario: 无 model 字段
- **WHEN** 调用 `ExtractModelName(body, ifaceType)`body 中不含 model 字段
- **THEN** SHALL 返回错误
### Requirement: 最小化改写请求体 model 字段
OpenAI 适配器 SHALL 按 InterfaceType 用 `json.RawMessage` 最小化改写请求体中的 model 字段,其余字段保持原始 bytes。
#### Scenario: 请求体 model 改写(统一 ID → 上游名)
- **WHEN** 调用 `RewriteRequestModelName(body, "gpt-4", InterfaceTypeChat)`body 为 `{"model":"openai/gpt-4","messages":[...],"some_param":"value"}`
- **THEN** SHALL 返回 `{"model":"gpt-4","messages":[...],"some_param":"value"}`
- **THEN** 除 model 外的字段 SHALL 保持原始 bytes 不变
#### Scenario: 不同 InterfaceType 的请求改写
- **WHEN** 调用 `RewriteRequestModelName(body, "gpt-4", InterfaceTypeEmbeddings)`
- **THEN** SHALL 按 Embedding 接口的请求体 model 字段位置进行改写
- **WHEN** 调用 `RewriteRequestModelName(body, "gpt-4", InterfaceTypeRerank)`
- **THEN** SHALL 按 Rerank 接口的请求体 model 字段位置进行改写
### Requirement: 最小化改写响应体 model 字段
OpenAI 适配器 SHALL 按 InterfaceType 用 `json.RawMessage` 最小化改写响应体中的 model 字段,其余字段保持原始 bytes。请求体和响应体的 model 字段位置可能不同,各自独立实现。
#### Scenario: 响应体 model 改写(上游名 → 统一 ID
- **WHEN** 调用 `RewriteResponseModelName(body, "openai/gpt-4", InterfaceTypeChat)`body 为上游 Chat 响应
- **THEN** SHALL 将 model 字段替换为 `"openai/gpt-4"`,其余字段原样保留
#### Scenario: 不同 InterfaceType 的响应改写
- **WHEN** 调用 `RewriteResponseModelName(body, "openai/gpt-4", InterfaceTypeEmbeddings)`
- **THEN** SHALL 按 Embedding 接口的响应体 model 字段位置进行改写