OpenHarness源码研究-3-codex配置到输出对话
OpenHarness源码研究-3-codex配置到输出对话契机第2篇末尾提到asyncio.run启动了REPL循环。但一次对话是怎么发生的先要回答三个问题用哪个模型→ Provider Registry 怎么管理42个供应商怎么跟模型通信→ 不同API格式的差异如何抹平凭什么四种后端共用一个引擎→ Protocol 的策略模式正好以DeepSeek为例串起来——它的API天然兼容OpenAI格式而且国内开发者用得最多。运行DeepSeek例子# 申请 DeepSeek API Keyplatform.deepseek.com # 方式一直接命令行 oh -p 你是谁 --api-format openai \ --base-url https://api.deepseek.com/v1 \ --model deepseek-chat \ -k 你的DEEPSEEK_API_KEY # 方式二创建持久化 Profile oh provider add deepseek \ --label DeepSeek \ --provider deepseek \ --api-format openai \ --auth-source openai_api_key \ --model deepseek-chat \ --base-url https://api.deepseek.com/v1 oh provider use deepseek oh -p 用Python写冒泡排序ProviderRegistry-模型的通讯录在api/registry.py中每个供应商是一个声明式的数据结构# api/registry.py 第157-169行 ProviderSpec( namedeepseek, keywords(deepseek,), env_keyDEEPSEEK_API_KEY, display_nameDeepSeek, backend_typeopenai_compat, # ← 最关键决定了走哪个Client default_base_urlhttps://api.deepseek.com/v1, detect_by_base_keyworddeepseek, )backend_type只有三种值对应三种Client实现BACKEND_TYPECLIENT覆盖谁anthropicAnthropicApiClientClaude API、Claude订阅openai_compatOpenAICompatibleClientDeepSeek、Qwen、GPT、Kimi、GLM…copilotCopilotClientGitHub Copilot另外还有一个特殊的CodexApiClientCodex订阅它不走backend_type而是直接按provider openai_codex判断。注册了 ProviderSpec ≠ 实现了专门的Client。DeepSeek 用的就是通用的OpenAICompatibleClient零额外代码。自动检测三级优先级1. api_key 前缀 → sk-or-v1-xxx → OpenRouter 2. base_url 关键字 → api.deepseek.com → DeepSeek 3. model名关键字 → deepseek-chat → DeepSeek所以用户不需要手动指定--provider输个--model deepseek-chat --base-url ...就能自动推断。统一的Protocol-四种Client的共同契约整个适配层只有一个接口# api/client.py 第79-83行 class SupportsStreamingMessages(Protocol): async def stream_message(self, request: ApiMessageRequest) - AsyncIterator[ApiStreamEvent]: Yield streamed events for the request.不是抽象类不是继承——是Protocol结构化子类型。任何实现了stream_message这个方法的对象都能被引擎使用。在build_runtime()中根据配置选择具体实现# ui/runtime.py 第113-160行 def _resolve_api_client_from_settings(settings) - SupportsStreamingMessages: if settings.api_format copilot: return CopilotClient(modelcopilot_model) if settings.provider openai_codex: return CodexApiClient(auth_token..., base_url...) if settings.api_format openai: return OpenAICompatibleClient(api_key..., base_url...) return AnthropicApiClient(api_key..., base_url...) # 默认这是策略模式但没有继承、没有抽象类、没有注册表。QueryEngine 不关心后端是谁# engine/query_engine.py 第21-22行 class QueryEngine: def __init__(self, *, api_client: SupportsStreamingMessages, ...):为什么不用 ABC如果用class BaseClient(ABC)所有 Client 必须显式继承。但 CodexApiClient 用的是 httpx 裸 HTTPCopilotClient 内部复用 AnthropicClient——强制继承只会制造不必要的耦合。Protocol 是如果你长得像鸭子那你就是鸭子。输入输出也完全统一ApiMessageRequest: model messages system_prompt max_tokens tools ApiStreamEvent: ApiTextDeltaEvent | ApiMessageCompleteEvent | ApiRetryEventQueryEngine 看到的是stream_message(request) → events。差异全部封装在 Client 内部。OpenAI兼容客户端-两种API之间的翻译官这是覆盖范围最广的 Client。引擎内部说的是 Anthropic Messages API 格式但 DeepSeek/Qwen/GPT 说的是 OpenAI Chat Completions 格式。OpenAICompatibleClient负责翻译。消息格式转换api/openai_client.py第78-123行Anthropic引擎内部→ OpenAI发给DeepSeek system: 你是助手 → {role: system, content: 你是助手} user: 帮我写代码 → {role: user, content: 帮我写代码} assistant: tool_use block → {role: assistant, tool_calls: [{ function: {name: ..., arguments: ...}}]} user: tool_result block → {role: tool, tool_call_id: xxx, content: ...} ← 每个结果一条独立消息最关键的差异是 tool_resultAnthropic 里它是 user 消息 content 数组中的一个 blockOpenAI 里它是一条独立的role: tool消息。搞错了模型会直接忽略工具结果。Tool Schema 转换# Anthropic 格式 {name: read_file, input_schema: {...}} # ↓ 变为 ↓ # OpenAI 格式 {type: function, function: {name: read_file, parameters: {...}}}input_schema→parameters改名外加{type: function, function: {...}}包裹。流式响应的增量拼接— OpenAI 的流式响应是零散的 delta需要手动累加tool_calls和reasoning_content不像 Anthropic SDK 已经帮你拼好了。这让_stream_once()从 AnthropicClient 的 40 行膨胀到 110 行。Thinking 模型兼容 —DeepSeek 有 thinking 模型。OpenAI 兼容客户端对它做了特殊处理# api/openai_client.py 第163-168行 reasoning getattr(msg, _reasoning, None) if reasoning: openai_msg[reasoning_content] reasoning # 回放推理内容 elif tool_uses: openai_msg[reasoning_content] # 即使为空也必须带这个字段思考模型在调用工具时即使没有推理内容也必须返回空reasoning_content否则 API 拒绝请求。这是踩坑踩出来的。Token 限制字段兼容# api/openai_client.py 第40-53行 _MAX_COMPLETION_TOKEN_MODEL_PREFIXES (gpt-5, o1, o3, o4) # GPT-5/o系列 → max_completion_tokens # 其他模型包括DeepSeek→ max_tokens四种Client横向对比ANTHROPICAPICLIENTOPENAICOMPATIBLECLIENTCODEXAPICLIENTCOPILOTCLIENT底层库anthropicSDKopenaiSDKhttpx裸HTTPanthropicSDK格式转换不需要引擎母语消息Tool双向转换转为Codex input/output格式不需要认证API Key / OAuth TokenAPI KeyJWT Bearer TokenOAuth设备码重试指数退避抖动Retry-After指数退避指数退避Timeout继承Anthropic代码量~260行~390行~390行~260行AnthropicApiClient引擎的消息格式本身就是 Anthropic 格式的不需要任何转换。但 OAuth 模式有两个额外操作# api/client.py 第207-228行 if self._claude_oauth: params[system] f{attribution}\n{params[system]} # 注入归因头 params[betas] claude_oauth_betas() # 开启OAuth beta params[metadata] {user_id: json.dumps({...})} # 设备/会话元数据绑 Claude Code 订阅时必须的参数——告诉 Anthropic这是个合法订阅用户。还有 token 刷新每次stream_message前检查 token 是否过期过期就重建整个AsyncAnthropic实例避免请求中途失效的竞态条件。OpenAICompatibleClient承担最重的翻译工作消息格式 Tool Schema 流式增量拼接 thinking 模型 token 字段。覆盖了 40 个注册 Provider 中的绝大多数。CodexApiClient不依赖任何 SDKhttpx裸 HTTP 直连 ChatGPT 后端。自己解析 JWT 拿 account_id自己写 SSE 解析器按行解析data:前缀自己组装chatgpt-account-id等特殊 header。因为 Codex 用的是/codex/responses端点不是标准/v1/chat/completions——没有官方 SDK只能裸调。CopilotClient底层复用AnthropicApiClient只是认证换成 GitHub OAuth 设备码流。内部持有一个配置好的 AnthropicApiClient 实例把 Copilot token 适配成 Anthropic 格式即可。重试机制的统一模式四者重试逻辑殊途同归最多 3 次、指数退避、429/5xx/网络错误重试、401/403 不重试。AnthropicClient 额外尊重服务端的Retry-After响应头并给退避时间加 25% 随机抖动——防止大量并发客户端在同一瞬间同时重试所谓的thundering herd问题。为什么不用langchain*如果要新增供应商比如智谱 GLM在registry.py加一条ProviderSpecAPI 是 OpenAI 兼容的 →不需要写任何新代码API 格式特殊 → 实现一个stream_message方法即可新增行为不修改现有代码完美符合开闭原则。对比 langchain你必须继承BaseLLM实现_generate、_stream、_llm_type等一串抽象方法还附赠了你可能不需要的 prompt 模板和 output parser。OpenHarness 的选择用标准库的 Protocol 替代第三方框架的抽象类。减少依赖、提高透明度、降低调试难度。*总结Provider Registry 以声明式数据结构管理 42 个供应商三级自动检测backend_type决定走哪个 ClientOpenAICompatibleClient 承担了最重的翻译工作覆盖绝大多数供应商包括 DeepSeekSupportsStreamingMessagesProtocol 是整个适配层的唯一契约——策略模式 鸭子类型不需要继承四种 Client 共享统一的重试模式3 次、指数退避、错误分类各有特殊处理认证刷新只发生在 OAuth 场景API Key 模式保持简单写到最后