## 主要变更 **核心修改**: - 路由定义:/: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 规范文件
360 lines
14 KiB
Markdown
360 lines
14 KiB
Markdown
# 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.system(String)
|
||
- **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.arguments(JSON 字符串)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** stop(String 或 Array)SHALL 规范化为 parameters.stop_sequences(Array)
|
||
- **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 映射为 CanonicalModelList(data[].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 字段位置进行改写
|