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

14 KiB
Raw Blame History

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 字段位置进行改写