# OpenAI 协议适配清单 > 依据 [conversion_design.md](./conversion_design.md) 附录 D 模板编撰,覆盖 OpenAI API 的全部对接细节。 --- ## 目录 1. [协议基本信息](#1-协议基本信息) 2. [接口识别](#2-接口识别) 3. [请求头构建](#3-请求头构建) 4. [核心层 — Chat 请求编解码](#4-核心层--chat-请求编解码) 5. [核心层 — Chat 响应编解码](#5-核心层--chat-响应编解码) 6. [核心层 — 流式编解码](#6-核心层--流式编解码) 7. [扩展层接口](#7-扩展层接口) 8. [错误编码](#8-错误编码) 9. [自检清单](#9-自检清单) --- ## 1. 协议基本信息 | 项目 | 说明 | | -------- | ----------------------------------- | | 协议名称 | `"openai"` | | 协议版本 | 无固定版本头,API 持续演进 | | Base URL | `https://api.openai.com` | | 认证方式 | `Authorization: Bearer ` | --- ## 2. 接口识别 ### 2.1 URL 路径模式 | URL 路径 | InterfaceType | | ------------------------ | ------------- | | `/v1/chat/completions` | CHAT | | `/v1/models` | MODELS | | `/v1/models/{model}` | MODEL_INFO | | `/v1/embeddings` | EMBEDDINGS | | `/v1/rerank` | RERANK | ### 2.2 detectInterfaceType ``` OpenAI.detectInterfaceType(nativePath): if nativePath == "/v1/chat/completions": return CHAT if nativePath == "/v1/models": return MODELS if nativePath matches "^/v1/models/[^/]+$": return MODEL_INFO if nativePath == "/v1/embeddings": return EMBEDDINGS if nativePath == "/v1/rerank": return RERANK return PASSTHROUGH ``` **说明**:`detectInterfaceType` 由 OpenAI Adapter 实现,根据 OpenAI 协议的 URL 路径约定识别接口类型。 ### 2.3 接口能力矩阵 ``` OpenAI.supportsInterface(type): CHAT: return true MODELS: return true MODEL_INFO: return true EMBEDDINGS: return true RERANK: return true AUDIO: return true IMAGES: return true default: return false ``` ### 2.4 URL 映射表 ``` OpenAI.buildUrl(nativePath, interfaceType): switch interfaceType: case CHAT: return "/v1/chat/completions" case MODELS: return "/v1/models" case MODEL_INFO: return "/v1/models/{modelId}" case EMBEDDINGS: return "/v1/embeddings" case RERANK: return "/v1/rerank" default: return nativePath ``` --- ## 3. 请求头构建 ### 3.1 buildHeaders ``` OpenAI.buildHeaders(provider): result = {} result["Authorization"] = "Bearer " + provider.api_key if provider.adapter_config["organization"]: result["OpenAI-Organization"] = provider.adapter_config["organization"] result["Content-Type"] = "application/json" return result ``` ### 3.2 adapter_config 契约 | Key | 类型 | 必填 | 默认值 | 说明 | | ---------------- | ------ | ---- | ------ | ------------------------------------------------------ | | `organization` | String | 否 | — | OpenAI 组织标识,映射为 `OpenAI-Organization` Header | --- ## 4. 核心层 — Chat 请求编解码 ### 4.1 Decoder(OpenAI → Canonical) #### 系统消息 OpenAI 支持两种系统指令角色:`system` 和 `developer`(o1 及更新模型推荐使用 `developer`)。两者均提取为 `canonical.system`。 ``` decodeSystemPrompt(messages): systemMsgs = messages.filter(m => m.role == "system" || m.role == "developer") remaining = messages.filter(m => m.role != "system" && m.role != "developer") if systemMsgs.length == 0: return {system: None, messages: remaining} return {system: systemMsgs.map(m => extractText(m.content)).join("\n\n"), messages: remaining} ``` 从 `messages` 数组中提取 `role="system"` 和 `role="developer"` 的消息,合并为 `canonical.system`(String),剩余消息作为 `canonical.messages`。 #### 消息角色映射 | OpenAI role | Canonical role | 说明 | | ------------- | --------------------------- | ------------------------------------------------------ | | `system` | 提取为 `canonical.system` | 不进入 messages 数组 | | `developer` | 提取为 `canonical.system` | 不进入 messages 数组;o1+ 推荐使用 | | `user` | `user` | 直接映射 | | `assistant` | `assistant` | 需处理 tool_calls 结构差异 | | `tool` | `tool` | tool_call_id → tool_use_id | | `function` | `tool` | **已废弃**,转为 tool 角色(见下方废弃字段处理) | #### 内容块解码 ``` decodeUserContent(content): if content is String: return [{type: "text", text: content}] return content.map(part => { switch part.type: "text" → {type: "text", text: part.text} "image_url" → {type: "image", source: {url: part.image_url.url, detail: part.image_url.detail}} "input_audio" → {type: "audio", source: {data: part.input_audio.data, format: part.input_audio.format}} "file" → {type: "file", source: {file_data: part.file.file_data, file_id: part.file.file_id, filename: part.file.filename}} }) decodeMessage(msg): switch msg.role: case "user": return {role: "user", content: decodeUserContent(msg.content)} case "assistant": blocks = [] if msg.content: if msg.content is String: blocks.append({type: "text", text: msg.content}) else: blocks.append(...msg.content.filter(p => p.type == "text").map(p => ({type: "text", text: p.text}))) for refusal in msg.content.filter(p => p.type == "refusal"): blocks.append({type: "text", text: refusal.refusal}) if msg.refusal: blocks.append({type: "text", text: msg.refusal}) if msg.tool_calls: for tc in msg.tool_calls: switch tc.type: "function" → blocks.append({type: "tool_use", id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments)}) "custom" → blocks.append({type: "tool_use", id: tc.id, name: tc.custom.name, input: tc.custom.input}) if msg.function_call: // 已废弃,兼容处理 blocks.append({type: "tool_use", id: generateId(), name: msg.function_call.name, input: JSON.parse(msg.function_call.arguments)}) return {role: "assistant", content: blocks} case "tool": return {role: "tool", content: [{ type: "tool_result", tool_use_id: msg.tool_call_id, content: msg.content is String ? msg.content : extractText(msg.content), is_error: false}]} case "function": // 已废弃,兼容处理 return {role: "tool", content: [{ type: "tool_result", tool_use_id: msg.name, content: msg.content, is_error: false}]} ``` **关键差异**: - OpenAI 将 `tool_calls` 放在 message 顶层,Canonical 放在 `content` 数组中作为 `ToolUseBlock` - OpenAI 用 `tool_call_id` 标识工具结果,Canonical 用 `tool_use_id` - `refusal` 编码为 text block - `developer` 角色与 `system` 角色同语义,均提取为 canonical.system - 自定义工具(`type: "custom"`)的 `input` 为字符串,Function 工具的 `arguments` 为 JSON 字符串 #### 工具定义 OpenAI 有两类工具: **Function 工具**(`type: "function"`): | OpenAI | Canonical | 说明 | | -------------------------------- | ------------------------ | --------------------------- | | `tools[].type: "function"` | — | OpenAI 多一层 function 包装 | | `tools[].function.name` | `tools[].name` | 直接映射 | | `tools[].function.description` | `tools[].description` | 直接映射 | | `tools[].function.parameters` | `tools[].input_schema` | 字段名不同 | | `tools[].function.strict` | — | 协议特有,忽略 | **Custom 工具**(`type: "custom"`):无 `input_schema`,使用自定义格式(text/grammar)。不映射为 CanonicalTool,跨协议时丢弃。 ``` decodeTools(tools): result = [] for tool in (tools ?? []): if tool.type == "function": result.append(CanonicalTool { name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters }) // type == "custom": 跨协议时丢弃 return result.length > 0 ? result : None ``` #### 工具选择 OpenAI `tool_choice` 有多种形态: | OpenAI tool_choice | Canonical ToolChoice | 说明 | | --------------------------------------------------------- | ------------------------ | ------------------------------------------------------- | | `"auto"` | `{type: "auto"}` | 直接映射 | | `"none"` | `{type: "none"}` | 直接映射 | | `"required"` | `{type: "any"}` | 语义等价 | | `{type: "function", function: {name}}` | `{type: "tool", name}` | 命名工具 | | `{type: "custom", custom: {name}}` | `{type: "tool", name}` | 自定义工具 | | `{type: "allowed_tools", allowed_tools: {mode, tools}}` | — | 协议特有,降级为 mode 映射(auto→auto, required→any) | ``` decodeToolChoice(tool_choice): if tool_choice is String: switch tool_choice: "auto" → {type: "auto"} "none" → {type: "none"} "required" → {type: "any"} elif tool_choice.type == "function": return {type: "tool", name: tool_choice.function.name} elif tool_choice.type == "custom": return {type: "tool", name: tool_choice.custom.name} elif tool_choice.type == "allowed_tools": mode = tool_choice.allowed_tools.mode // "auto" or "required" return mode == "required" ? {type: "any"} : {type: "auto"} ``` #### 参数映射 | OpenAI | Canonical | 说明 | | -------------------------- | ------------------------------------- | --------------------------- | | `max_completion_tokens` | `parameters.max_tokens` | 优先使用;o-series 模型专用 | | `max_tokens` | `parameters.max_tokens` | 已废弃,作为回退 | | `temperature` | `parameters.temperature` | 直接映射 | | `top_p` | `parameters.top_p` | 直接映射 | | `frequency_penalty` | `parameters.frequency_penalty` | 直接映射 | | `presence_penalty` | `parameters.presence_penalty` | 直接映射 | | `stop` (String or Array) | `parameters.stop_sequences` (Array) | Decoder 规范化为 Array | | `stream` | `stream` | 直接映射 | ``` decodeParameters(raw): return RequestParameters { max_tokens: raw.max_completion_tokens ?? raw.max_tokens, temperature: raw.temperature, top_p: raw.top_p, frequency_penalty: raw.frequency_penalty, presence_penalty: raw.presence_penalty, stop_sequences: normalizeStop(raw.stop) } normalizeStop(stop): if stop is String: return [stop] if stop is Array: return stop return None ``` #### 公共字段 | OpenAI | Canonical | 提取规则 | | ----------------------- | --------------------- | -------------------- | | `user` | `user_id` | 顶层字段,直接提取 | | `response_format` | `output_format` | 按类型解码 | | `parallel_tool_calls` | `parallel_tool_use` | 布尔值直接映射 | | `reasoning_effort` | `thinking` | 映射为 thinking 配置 | ``` decodeOutputFormat(format): if format is None: return None switch format.type: "json_object" → {type: "json_object"} "json_schema" → {type: "json_schema", json_schema: format.json_schema} "text" → null // 默认格式,无需设置 decodeThinking(reasoning_effort): if reasoning_effort is None: return None if reasoning_effort == "none": return ThinkingConfig {type: "disabled"} effort = reasoning_effort == "minimal" ? "low" : reasoning_effort return ThinkingConfig {type: "enabled", effort: effort} ``` **`reasoning_effort` 映射说明**: - `"none"` → `thinking.type = "disabled"`(不执行推理) - `"minimal"` → `thinking.effort = "low"`(Canonical 无 minimal 级别,降级为 low) - `"low"` / `"medium"` / `"high"` / `"xhigh"` → 直接映射 #### 废弃字段兼容 | 废弃字段 | 替代字段 | Decoder 处理 | | ----------------- | --------------- | -------------------------------------------------- | | `functions` | `tools` | 转换为 `tools` 格式(`type: "function"` 包装) | | `function_call` | `tool_choice` | 转换为 `tool_choice` 格式 | ``` decodeDeprecatedFields(raw): // functions → tools(仅当 tools 未设置时) if raw.tools is None && raw.functions: raw.tools = raw.functions.map(f => ({ type: "function", function: {name: f.name, description: f.description, parameters: f.parameters}})) // function_call → tool_choice(仅当 tool_choice 未设置时) if raw.tool_choice is None && raw.function_call: if raw.function_call == "none": raw.tool_choice = "none" elif raw.function_call == "auto": raw.tool_choice = "auto" else: raw.tool_choice = {type: "function", function: {name: raw.function_call.name}} ``` #### 协议特有字段 | 字段 | 处理方式 | | -------------------------------- | ------------------------ | | `seed` | 忽略(无跨协议等价语义) | | `logprobs` | 忽略 | | `top_logprobs` | 忽略 | | `logit_bias` | 忽略 | | `n` | 忽略(仅支持单选择) | | `service_tier` | 忽略 | | `store` | 忽略 | | `metadata` | 忽略 | | `modalities` | 忽略(多模态扩展时启用) | | `audio` | 忽略(多模态扩展时启用) | | `prediction` | 忽略 | | `stream_options` | 忽略 | | `safety_identifier` | 忽略 | | `prompt_cache_key` | 忽略 | | `prompt_cache_retention` | 忽略 | | `verbosity` | 忽略 | | `web_search_options` | 忽略 | | `tools[].function.strict` | 忽略 | | `tools[].custom` (custom 工具) | 跨协议时丢弃 | #### 协议约束 - `messages` 中 `tool` 角色必须紧接在对应的 `assistant`(含 tool_calls)之后 - `tool` 消息的 `tool_call_id` 必须与 assistant 消息中的 `tool_calls[].id` 匹配 - `stream_options.include_usage` 可选,OpenAI 特有 - `stop` 参数在 o3/o4-mini 等最新推理模型上不可用 ### 4.2 Encoder(Canonical → OpenAI) #### 模型名称 使用 `provider.model_name` 覆盖 `canonical.model`。 #### 系统消息注入 将 `canonical.system` 编码为 `messages[0].role="system"` 的消息,置于 messages 数组头部。 ``` encodeSystemPrompt(canonical): messages = [] if canonical.system is String: messages.append({role: "system", content: canonical.system}) elif canonical.system is Array: text = canonical.system.map(s => s.text).join("\n\n") messages.append({role: "system", content: text}) return messages + encodeMessages(canonical.messages) ``` #### 消息编码 ``` encodeUserContent(blocks): if blocks.length == 1 && blocks[0].type == "text": return blocks[0].text return blocks.map(b => { switch b.type: "text" → {type: "text", text: b.text} "image" → {type: "image_url", image_url: {url: b.source.url, detail: b.source.detail}} "audio" → {type: "input_audio", input_audio: {data: b.source.data, format: b.source.format}} "file" → {type: "file", file: {file_data: b.source.file_data, file_id: b.source.file_id, filename: b.source.filename}} }) encodeMessage(msg): switch msg.role: case "user": return {role: "user", content: encodeUserContent(msg.content)} case "assistant": message = {} textParts = msg.content.filter(b => b.type == "text") toolUses = msg.content.filter(b => b.type == "tool_use") if textParts.length > 0: message.content = textParts.map(b => b.text).join("") elif toolUses.length > 0: message.content = null else: message.content = "" if toolUses.length > 0: message.tool_calls = toolUses.map(tu => ({ id: tu.id, type: "function", function: {name: tu.name, arguments: JSON.stringify(tu.input)}})) return {role: "assistant", ...message} case "tool": results = msg.content.filter(b => b.type == "tool_result") if results.length > 0: return {role: "tool", tool_call_id: results[0].tool_use_id, content: results[0].content} ``` #### 角色约束处理 OpenAI 要求 assistant 和 user 角色严格交替。当 Canonical 消息序列中存在连续同角色消息时,需合并为单条消息。 #### 工具编码 ``` encodeTools(canonical): if canonical.tools: result.tools = canonical.tools.map(t => ({ type: "function", function: {name: t.name, description: t.description, parameters: t.input_schema}})) if canonical.tool_choice: result.tool_choice = encodeToolChoice(canonical.tool_choice) encodeToolChoice(choice): switch choice.type: "auto" → "auto" "none" → "none" "any" → "required" "tool" → {type: "function", function: {name: choice.name}} ``` #### 公共字段编码 ``` encodeOutputFormat(format): if format is None: return None switch format.type: "json_object" → {type: "json_object"} "json_schema" → {type: "json_schema", json_schema: format.json_schema} encodeRequest(canonical, provider): result = { model: provider.model_name, messages: encodeSystemPrompt(canonical) + canonical.messages.flatMap(encodeMessage), stream: canonical.stream } // 参数 if canonical.parameters.max_tokens: result.max_completion_tokens = canonical.parameters.max_tokens if canonical.parameters.temperature is not None: result.temperature = canonical.parameters.temperature if canonical.parameters.top_p is not None: result.top_p = canonical.parameters.top_p if canonical.parameters.frequency_penalty is not None: result.frequency_penalty = canonical.parameters.frequency_penalty if canonical.parameters.presence_penalty is not None: result.presence_penalty = canonical.parameters.presence_penalty if canonical.parameters.stop_sequences: result.stop = canonical.parameters.stop_sequences // 工具 if canonical.tools: result.tools = canonical.tools.map(t => ({ type: "function", function: {name: t.name, description: t.description, parameters: t.input_schema}})) if canonical.tool_choice: result.tool_choice = encodeToolChoice(canonical.tool_choice) // 公共字段 if canonical.user_id: result.user = canonical.user_id if canonical.output_format: result.response_format = encodeOutputFormat(canonical.output_format) if canonical.parallel_tool_use != null: result.parallel_tool_calls = canonical.parallel_tool_use if canonical.thinking: if canonical.thinking.type == "disabled": result.reasoning_effort = "none" elif canonical.thinking.effort: result.reasoning_effort = canonical.thinking.effort else: result.reasoning_effort = "medium" return result ``` **编码说明**: - 使用 `max_completion_tokens`(非废弃的 `max_tokens`)输出 token 上限 - `frequency_penalty` 和 `presence_penalty` 仅在非 null 时输出 - `thinking` 映射为 `reasoning_effort`:disabled → "none",有 effort 值则直接映射,否则默认 "medium" #### 降级处理 对照架构文档 §8.4 三级降级策略,确认每个不支持字段的处理: | Canonical 字段 | OpenAI 不支持时 | 降级策略 | | --------------------------------- | -------------------------------------------------- | ---------------------------------- | | `thinking.budget_tokens` | OpenAI 使用 `reasoning_effort` 而非 token 级控制 | 替代方案:估算映射为 effort 近似值 | | `stop_reason: "content_filter"` | `finish_reason: "content_filter"` | 自动映射(OpenAI 支持此值) | | `stop_reason: "stop_sequence"` | OpenAI 无独立值 | 自动映射为 `"stop"` | | `parameters.top_k` | OpenAI 不支持 `top_k` | 丢弃 | --- ## 5. 核心层 — Chat 响应编解码 逐字段对照 §4.7 CanonicalResponse 确认映射关系。 ### 5.1 响应结构 ``` OpenAI 响应顶层结构: { id: String, object: "chat.completion", created: Number, model: String, choices: [{ index: 0, message: { role: "assistant", content: String | null, refusal: String | null, tool_calls: [{ id: String, type: "function", function: { name: String, arguments: String } }] | null, annotations: [{ type: "url_citation", url_citation: { start_index, end_index, title, url } }] | null, audio: { id, data, expires_at, transcript } | null }, finish_reason: String, logprobs: { content, refusal } | null }], usage: { prompt_tokens: Number, completion_tokens: Number, total_tokens: Number, prompt_tokens_details: { cached_tokens, audio_tokens }, completion_tokens_details: { reasoning_tokens, audio_tokens, accepted_prediction_tokens, rejected_prediction_tokens } }, service_tier: String | null, system_fingerprint: String | null } ``` **兼容性说明**:部分 OpenAI 兼容提供商(如 DeepSeek)在 response 中返回非标准的 `reasoning_content` 字段。Decoder 会检测并处理此字段,将其解码为 ThinkingBlock。 ### 5.2 Decoder(OpenAI → Canonical) ``` decodeResponse(openaiResp): choice = openaiResp.choices[0] blocks = [] if choice.message.content: blocks.append({type: "text", text: choice.message.content}) if choice.message.refusal: blocks.append({type: "text", text: choice.message.refusal}) // reasoning_content: 非标准字段,来自兼容提供商 if choice.message.reasoning_content: blocks.append({type: "thinking", thinking: choice.message.reasoning_content}) if choice.message.tool_calls: for tc in choice.message.tool_calls: switch tc.type: "function" → blocks.append({type: "tool_use", id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments)}) "custom" → blocks.append({type: "tool_use", id: tc.id, name: tc.custom.name, input: tc.custom.input}) return CanonicalResponse { id: openaiResp.id, model: openaiResp.model, content: blocks, stop_reason: mapFinishReason(choice.finish_reason), usage: decodeUsage(openaiResp.usage) } ``` **内容块解码**: - `content` → TextBlock - `refusal` → TextBlock - `reasoning_content` → ThinkingBlock(非标准字段,来自兼容提供商) - `tool_calls[].type: "function"` → ToolUseBlock(从 message 顶层提取到 content 数组) - `tool_calls[].type: "custom"` → ToolUseBlock(input 为字符串) **停止原因映射**: | OpenAI finish_reason | Canonical stop_reason | 说明 | | -------------------- | --------------------- | ---------------------------- | | `"stop"` | `"end_turn"` | 自然结束或匹配 stop sequence | | `"length"` | `"max_tokens"` | 达到 token 上限 | | `"tool_calls"` | `"tool_use"` | 模型调用工具 | | `"content_filter"` | `"content_filter"` | 内容过滤 | | `"function_call"` | `"tool_use"` | 已废弃,等同于 tool_calls | | 其他 | `"end_turn"` | 兜底 | **Token 用量映射**: | OpenAI usage | Canonical Usage | | ---------------------------------------------- | -------------------------------- | | `prompt_tokens` | `input_tokens` | | `completion_tokens` | `output_tokens` | | `prompt_tokens_details.cached_tokens` | `cache_read_tokens` | | — | `cache_creation_tokens` (null) | | `completion_tokens_details.reasoning_tokens` | `reasoning_tokens` | ``` decodeUsage(usage): if usage is None: return CanonicalUsage {input_tokens: 0, output_tokens: 0} return CanonicalUsage { input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, cache_read_tokens: usage.prompt_tokens_details?.cached_tokens, cache_creation_tokens: null, reasoning_tokens: usage.completion_tokens_details?.reasoning_tokens } ``` **协议特有内容**: | 字段 | 处理方式 | | ---------------------- | ---------------------------------------------- | | `refusal` | 解码为 text block | | `reasoning_content` | 解码为 ThinkingBlock(非标准,来自兼容提供商) | | `annotations` | 忽略(协议特有,不晋升为公共字段) | | `audio` | 忽略(多模态扩展时启用) | | `logprobs` | 忽略 | | `service_tier` | 忽略 | | `system_fingerprint` | 忽略 | | `created` | 忽略 | ### 5.3 Encoder(Canonical → OpenAI) ``` encodeResponse(canonical): textParts = canonical.content.filter(b => b.type == "text") thinkingParts = canonical.content.filter(b => b.type == "thinking") toolUses = canonical.content.filter(b => b.type == "tool_use") message = {role: "assistant"} if textParts.length > 0: message.content = textParts.map(b => b.text).join("") elif toolUses.length > 0: message.content = null else: message.content = "" // reasoning_content: 非标准字段,输出给兼容提供商 if thinkingParts.length > 0: message.reasoning_content = thinkingParts.map(b => b.thinking).join("") if toolUses.length > 0: message.tool_calls = toolUses.map(tu => ({ id: tu.id, type: "function", function: {name: tu.name, arguments: JSON.stringify(tu.input)}})) return { id: canonical.id, object: "chat.completion", model: canonical.model, choices: [{ index: 0, message: message, finish_reason: mapCanonicalToFinishReason(canonical.stop_reason) }], usage: encodeUsage(canonical.usage) } encodeUsage(usage): return { prompt_tokens: usage.input_tokens, completion_tokens: usage.output_tokens, total_tokens: usage.input_tokens + usage.output_tokens, prompt_tokens_details: usage.cache_read_tokens ? {cached_tokens: usage.cache_read_tokens} : null, completion_tokens_details: usage.reasoning_tokens ? {reasoning_tokens: usage.reasoning_tokens} : null } ``` **内容块编码**: - TextBlock → `message.content` - ToolUseBlock → `message.tool_calls`(从 content 数组提取到 message 顶层) - ThinkingBlock → `reasoning_content`(非标准字段,兼容提供商使用) **停止原因映射**: | Canonical stop_reason | OpenAI finish_reason | | --------------------- | -------------------- | | `"end_turn"` | `"stop"` | | `"max_tokens"` | `"length"` | | `"tool_use"` | `"tool_calls"` | | `"content_filter"` | `"content_filter"` | | `"stop_sequence"` | `"stop"` | | `"refusal"` | `"stop"` | | 其他 | `"stop"` | **降级处理**: | Canonical 字段 | OpenAI 不支持时 | 降级策略 | | -------------------------------- | --------------- | ----------------------------- | | `stop_reason: "stop_sequence"` | OpenAI 无独立值 | 映射为 `"stop"`(自动映射) | | `stop_reason: "refusal"` | OpenAI 无独立值 | 映射为 `"stop"`(自动映射) | | `cache_creation_tokens` | OpenAI 无此字段 | 丢弃 | --- ## 6. 核心层 — 流式编解码 ### 6.1 SSE 格式 OpenAI 使用无命名的 SSE delta chunk: ``` data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","model":"gpt-4", "choices":[{"index":0,"delta":{...},"finish_reason":null}]} data: [DONE] ``` **delta 结构**: ``` delta: { role?: "assistant" | "user" | "system" | "developer" | "tool", content?: String, tool_calls?: [{index: Number, id?: String, function?: {name?: String, arguments?: String}, type?: "function"}], refusal?: String, function_call?: {name?: String, arguments?: String} // 已废弃 } ``` **兼容性说明**:部分兼容提供商在 delta 中返回非标准的 `reasoning_content` 字段。StreamDecoder 会检测并处理。 ### 6.2 StreamDecoder(OpenAI SSE → Canonical 事件) | OpenAI chunk | Canonical 事件 | 说明 | | ------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------- | | 首个 chunk (id/model) | MessageStartEvent | 从顶层字段提取 id、model | | `delta.content` 首次出现 | ContentBlockStart(text) + ContentBlockDelta(text_delta) | 新 text block 开始 | | `delta.content` 后续 | ContentBlockDelta(text_delta) | 追加文本 | | `delta.tool_calls[i]` 首次出现 | ContentBlockStart(tool_use) | 新 tool block,提取 id、name | | `delta.tool_calls[i].function.arguments` | ContentBlockDelta(input_json_delta) | 增量 JSON 参数 | | `delta.reasoning_content` 首次 | ContentBlockStart(thinking) + ContentBlockDelta(thinking_delta) | 新 thinking block(非标准,来自兼容提供商) | | `delta.reasoning_content` 后续 | ContentBlockDelta(thinking_delta) | 追加思考内容 | | `delta.refusal` 首次 | ContentBlockStart(text) + ContentBlockDelta(text_delta) | 新 text block | | `finish_reason` 非空 | ContentBlockStop × N + MessageDeltaEvent + MessageStopEvent | 关闭所有 open blocks | | usage chunk(choices=[]) | MessageDeltaEvent(usage) | `stream_options.include_usage` 触发的用量 chunk | | `[DONE]` | flush() | 触发 decoder flush | **Decoder 伪代码**: ``` StreamDecoder.processChunk(rawChunk): events = [] // 解析 SSE data if rawChunk == "[DONE]": // 关闭所有 open blocks for idx in openBlocks: events.append(ContentBlockStopEvent {index: idx}) if messageStarted: events.append(MessageStopEvent {}) return events data = JSON.parse(rawChunk) // 首个 chunk: MessageStart if !messageStarted: events.append(MessageStartEvent {message: {id: data.id, model: data.model, usage: null}}) messageStarted = true for choice in data.choices: delta = choice.delta // role 出现时不产生事件(仅用于首个 chunk 标记) // text content if delta.content != null: if !openBlocks.has(textBlockIndex): events.append(ContentBlockStartEvent {index: textBlockIndex, content_block: {type: "text", text: ""}}) openBlocks.add(textBlockIndex) currentBlockType[textBlockIndex] = "text" events.append(ContentBlockDeltaEvent {index: textBlockIndex, delta: {type: "text_delta", text: delta.content}}) // reasoning_content (非标准,来自兼容提供商) if delta.reasoning_content != null: if !openBlocks.has(thinkingBlockIndex): events.append(ContentBlockStartEvent {index: thinkingBlockIndex, content_block: {type: "thinking", thinking: ""}}) openBlocks.add(thinkingBlockIndex) currentBlockType[thinkingBlockIndex] = "thinking" events.append(ContentBlockDeltaEvent {index: thinkingBlockIndex, delta: {type: "thinking_delta", thinking: delta.reasoning_content}}) // refusal if delta.refusal != null: if !openBlocks.has(refusalBlockIndex): events.append(ContentBlockStartEvent {index: refusalBlockIndex, content_block: {type: "text", text: ""}}) openBlocks.add(refusalBlockIndex) events.append(ContentBlockDeltaEvent {index: refusalBlockIndex, delta: {type: "text_delta", text: delta.refusal}}) // tool calls if delta.tool_calls: for tc in delta.tool_calls: idx = tc.index if tc.id != null: // 新 tool call block toolCallIdMap[idx] = tc.id toolCallNameMap[idx] = tc.function?.name toolCallArguments[idx] = "" blockIndex = allocateBlockIndex(idx) events.append(ContentBlockStartEvent { index: blockIndex, content_block: {type: "tool_use", id: tc.id, name: tc.function?.name, input: {}}}) openBlocks.add(blockIndex) currentBlockType[blockIndex] = "tool_use" currentBlockId[blockIndex] = idx if tc.function?.arguments: toolCallArguments[currentBlockId[toolUseBlockIndex]] += tc.function.arguments events.append(ContentBlockDeltaEvent { index: toolUseBlockIndex, delta: {type: "input_json_delta", partial_json: tc.function.arguments}}) // finish_reason if choice.finish_reason != null: for idx in openBlocks: events.append(ContentBlockStopEvent {index: idx}) openBlocks.clear() events.append(MessageDeltaEvent {delta: {stop_reason: mapFinishReason(choice.finish_reason)}, usage: null}) events.append(MessageStopEvent {}) // usage chunk (choices 为空) if data.choices.length == 0 && data.usage: accumulatedUsage = decodeUsage(data.usage) events.append(MessageDeltaEvent {delta: {stop_reason: null}, usage: accumulatedUsage}) return events ``` ### 6.3 StreamDecoder 状态机 ``` StreamDecoderState { messageStarted: Boolean openBlocks: Set currentBlockType: Map currentBlockId: Map toolCallIdMap: Map // OpenAI tool_calls 数组索引 → id toolCallNameMap: Map // OpenAI tool_calls 数组索引 → name toolCallArguments: Map // OpenAI tool_calls 数组索引 → 累积参数 textBlockStarted: Boolean // 追踪 text block 生命周期 thinkingBlockStarted: Boolean // 追踪 thinking block 生命周期(非标准) utf8Remainder: Option // UTF-8 跨 chunk 安全 accumulatedUsage: Option } ``` **关键处理**: - **工具调用索引映射**:OpenAI `tool_calls[i]` 的 `i` 不一定是连续的,需要用 Map 维护索引到 id/name 的映射 - **参数累积**:`tool_calls[i].function.arguments` 是增量 JSON 片段,需要累积直到 block 结束 - **UTF-8 安全**:跨 chunk 截断的 UTF-8 字节需要用 `utf8Remainder` 缓冲 - **`reasoning_content`**:非标准字段,来自兼容提供商(如 DeepSeek),处理方式与 `content` 类似 - **usage chunk**:当 `stream_options.include_usage` 启用时,最后一个 chunk 的 `choices` 为空数组,仅含 `usage` ### 6.4 StreamEncoder(Canonical → OpenAI SSE) | Canonical 事件 | OpenAI chunk | 说明 | | ----------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------- | | MessageStartEvent | `{id, model, object: "chat.completion.chunk", choices:[{delta:{role:"assistant"}, index:0}]}` | 首个 chunk | | ContentBlockStart(text) | 缓冲,不输出 | 等待首次 delta 时合并输出 | | ContentBlockDelta(text_delta) | `{choices:[{delta:{content:"..."}}]}` | 首次输出时合并 block_start 信息 | | ContentBlockStart(tool_use) | 缓冲,不输出 | 等待首次 delta 时合并输出 | | ContentBlockDelta(input_json_delta) | `{choices:[{delta:{tool_calls:[{index, id?, function:{name?, arguments}}]}}]}` | 首次含 id 和 name,后续仅含 arguments | | ContentBlockStart(thinking) | 缓冲,不输出 | 等待首次 delta | | ContentBlockDelta(thinking_delta) | `{choices:[{delta:{reasoning_content:"..."}}]}` | 非标准字段(兼容提供商使用) | | ContentBlockStop | 不输出 | 静默 | | MessageDeltaEvent | `{choices:[{delta:{}, finish_reason:"..."}]}` | 包含 stop_reason 映射 | | MessageDeltaEvent(usage only) | `{choices:[], usage: {...}}` | 用量信息 chunk | | MessageStopEvent | `data: [DONE]` | 流结束 | | PingEvent | 丢弃 | 不输出 | | ErrorEvent | 丢弃 | 不输出(OpenAI 无流式错误事件) | **Encoder 伪代码**: ``` StreamEncoderState { bufferedStart: Option // 缓冲的 block start 事件 toolCallIndexMap: Map // tool_use_id → OpenAI tool_calls 数组索引 nextToolCallIndex: Integer // 下一个可用索引 } StreamEncoder.encodeEvent(event): switch event.type: case "message_start": return [{id: event.message.id, model: event.message.model, object: "chat.completion.chunk", created: now(), choices: [{index: 0, delta: {role: "assistant"}, finish_reason: null}]}] case "content_block_start": bufferedStart = event // 缓冲,不立即输出 if event.content_block.type == "tool_use": idx = nextToolCallIndex++ toolCallIndexMap[event.content_block.id] = idx return [] case "content_block_delta": chunks = [] switch event.delta.type: "text_delta": delta = {content: event.delta.text} if bufferedStart: // 首次 delta,合并 start 信息(OpenAI 不需要额外的 start 信息) bufferedStart = null chunks.append({choices: [{index: 0, delta: delta, finish_reason: null}]}) "input_json_delta": tcIdx = toolCallIndexMap[currentBlockId[event.index]] delta = {} if bufferedStart: // 首次 delta,含 id 和 name start = bufferedStart.content_block delta.tool_calls = [{index: tcIdx, id: start.id, function: {name: start.name, arguments: event.delta.partial_json}, type: "function"}] bufferedStart = null else: delta.tool_calls = [{index: tcIdx, function: {arguments: event.delta.partial_json}}] chunks.append({choices: [{index: 0, delta: delta, finish_reason: null}]}) "thinking_delta": delta = {reasoning_content: event.delta.thinking} if bufferedStart: bufferedStart = null chunks.append({choices: [{index: 0, delta: delta, finish_reason: null}]}) return chunks case "content_block_stop": return [] case "message_delta": chunks = [] if event.delta.stop_reason: finishReason = mapCanonicalToFinishReason(event.delta.stop_reason) chunks.append({choices: [{index: 0, delta: {}, finish_reason: finishReason}]}) if event.usage: chunks.append({choices: [], usage: encodeUsage(event.usage)}) return chunks case "message_stop": return ["[DONE]"] ``` **缓冲策略**: - `ContentBlockStart` 不立即输出,等待首次 `ContentBlockDelta` 合并 - 合并首次 delta 时,将 start 信息(如 tool id/name)一起编码 --- ## 7. 扩展层接口 ### 7.1 /models & /models/ **列表接口** `GET /v1/models`: | 项目 | 说明 | | ------------ | ----------------- | | 接口是否存在 | 是 | | 请求格式 | GET 请求,无 body | 响应 Decoder(OpenAI → Canonical): ``` decodeModelsResponse(openaiResp): return CanonicalModelList { models: openaiResp.data.map(m => CanonicalModel { id: m.id, name: m.id, created: m.created, owned_by: m.owned_by })} ``` 响应 Encoder(Canonical → OpenAI): ``` encodeModelsResponse(canonical): return {object: "list", data: canonical.models.map(m => ({id: m.id, object: "model", created: m.created ?? 0, owned_by: m.owned_by ?? "unknown"}))} ``` **详情接口** `GET /v1/models/{model}`: | 项目 | 说明 | | ------------ | ----------------- | | 接口是否存在 | 是 | | 请求格式 | GET 请求,无 body | 响应 Decoder(OpenAI → Canonical): ``` decodeModelInfoResponse(openaiResp): return CanonicalModelInfo { id: openaiResp.id, name: openaiResp.id, created: openaiResp.created, owned_by: openaiResp.owned_by } ``` 响应 Encoder(Canonical → OpenAI): ``` encodeModelInfoResponse(canonical): return {id: canonical.id, object: "model", created: canonical.created ?? 0, owned_by: canonical.owned_by ?? "unknown"} ``` **字段映射**(列表和详情共用): | OpenAI | Canonical | 说明 | | -------------------------- | --------------------- | ----------- | | `data[].id` | `models[].id` | 直接映射 | | `data[].object: "model"` | — | 固定值 | | `data[].created` | `models[].created` | Unix 时间戳 | | `data[].owned_by` | `models[].owned_by` | 直接映射 | ### 7.2 /embeddings | 项目 | 说明 | | ------------ | ----------------------- | | 接口是否存在 | 是 | | URL 路径 | `POST /v1/embeddings` | **请求 Decoder**(OpenAI → Canonical): ``` decodeEmbeddingRequest(raw): return CanonicalEmbeddingRequest { model: raw.model, input: raw.input, encoding_format: raw.encoding_format, dimensions: raw.dimensions } ``` **请求 Encoder**(Canonical → OpenAI): ``` encodeEmbeddingRequest(canonical, provider): result = {model: provider.model_name, input: canonical.input} if canonical.encoding_format: result.encoding_format = canonical.encoding_format if canonical.dimensions: result.dimensions = canonical.dimensions return result ``` **响应 Decoder**(OpenAI → Canonical): ``` decodeEmbeddingResponse(openaiResp): return CanonicalEmbeddingResponse { data: openaiResp.data, model: openaiResp.model, usage: openaiResp.usage } ``` **响应 Encoder**(Canonical → OpenAI): ``` encodeEmbeddingResponse(canonical): return {object: "list", data: canonical.data, model: canonical.model, usage: canonical.usage} ``` ### 7.3 /rerank | 项目 | 说明 | | ------------ | ------------------- | | 接口是否存在 | 是 | | URL 路径 | `POST /v1/rerank` | **请求/响应编解码**:按 CanonicalRerankRequest / CanonicalRerankResponse 格式映射。 --- ## 8. 错误编码 ### 8.1 错误响应格式 ```json { "error": { "message": "Error message", "type": "invalid_request_error", "param": null, "code": null } } ``` ### 8.2 encodeError ``` OpenAI.encodeError(error): return { error: { message: error.message, type: mapErrorCode(error.code), param: error.details?.param ?? null, code: error.code } } mapErrorCode(code): switch code: INVALID_INPUT → "invalid_request_error" MISSING_REQUIRED_FIELD → "invalid_request_error" INCOMPATIBLE_FEATURE → "invalid_request_error" TOOL_CALL_PARSE_ERROR → "invalid_request_error" JSON_PARSE_ERROR → "invalid_request_error" RATE_LIMIT → "rate_limit_error" AUTHENTICATION → "authentication_error" default → "server_error" ``` ### 8.3 常用 HTTP 状态码 | HTTP Status | 说明 | | ----------- | -------------- | | 400 | 请求格式错误 | | 401 | 认证失败 | | 403 | 无权限 | | 404 | 接口不存在 | | 429 | 速率限制 | | 500 | 服务器内部错误 | | 503 | 服务不可用 | --- ## 9. 自检清单 | 章节 | 检查项 | | ---- | -------------------------------------------------------------------------------------------- | | §2 | [x] `detectInterfaceType(nativePath)` 已实现,所有已知路径已覆盖 | | §2 | [x] 所有 InterfaceType 的 `supportsInterface` 返回值已确定 | | §2 | [x] 所有 InterfaceType 的 `buildUrl` 映射已确定 | | §3 | [x]`buildHeaders(provider)` 已实现,adapter_config 契约已文档化 | | §4 | [x] Chat 请求的 Decoder 和 Encoder 已实现(逐字段对照 §4.1/§4.2) | | §4 | [x] 系统消息提取包含 `system` 和 `developer` 两种角色 | | §4 | [x] 角色映射和消息顺序约束已处理(assistant/user 交替合并) | | §4 | [x] 工具调用(tool_calls / tool_use / tool_result)的编解码已处理(含 custom 类型) | | §4 | [x]`frequency_penalty` 和 `presence_penalty` 已映射到 Canonical(非忽略) | | §4 | [x]`max_completion_tokens` 和 `max_tokens` 的优先级已处理 | | §4 | [x]`reasoning_effort` 到 `thinking` 的映射已处理(含 "none" 和 "minimal") | | §4 | [x] 废弃字段(functions / function_call)的兼容处理已实现 | | §4 | [x] 协议特有字段已识别并确定处理方式(logprobs/n/seed/modalities/web_search_options 等忽略) | | §5 | [x] Chat 响应的 Decoder 和 Encoder 已实现(逐字段对照 §4.7) | | §5 | [x] stop_reason / finish_reason 映射表已确认 | | §5 | [x] usage 字段映射已确认(prompt_tokens ↔ input_tokens) | | §5 | [x]`reasoning_content`(非标准)的编解码已处理 | | §5 | [x]`annotations` 等协议特有响应字段已识别并确定处理方式 | | §6 | [x] 流式 StreamDecoder 和 StreamEncoder 已实现(对照 §4.8) | | §6 | [x] 流式 `reasoning_content`(非标准)的处理已覆盖 | | §6 | [x] usage chunk(choices 为空)的处理已覆盖 | | §7 | [x] 扩展层接口的编解码已实现(/models、/models/{model}、/embeddings、/rerank) | | §8 | [x]`encodeError` 已实现 |