opencodex Sidecar 原生化研究:让 Web Search 与 Vision 代理在 Codex UI 中呈现原生体验
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
导读
opencodex 作为通用 Provider 代理,通过 "sidecar"(旁路代理)机制让路由到非 OpenAI 模型(Claude、Gemini、Grok、DeepSeek、Ollama 等)的请求也能获得 ChatGPT 前向后端才能提供的 Web 搜索与图像理解能力。本篇文章以仓库内devlog/_fin/260630_native-sidecar-parity/研究文档为核心,深入剖析"原生 sidecar 对齐"(native sidecar parity)这一技术命题:当 sidecar 替路由模型执行了真实的 Web 搜索、或替纯文本模型描述了输入图像时,如何让 Codex 的 UI 呈现与原生模型一致的活动轨迹,而不是只把 sidecar 结果当作一段普通文本塞回路由模型。读完本文,你将掌握web_search_call输出项的完整线协议、opencodex 侧 sidecar 拦截/执行/回注的三段式链路、以及"为何 Web 搜索能做原生对齐而 Vision 不能"的底层协议原因。
说明:本文核心内容继承自 00_research.md,并引用同一目录下的 10_phase1_websearch-native-ui.md、20_phase1_verification.md、30_phase2_websearch-fidelity.md、40_phase3_websearch-sources.md 以及仓库源码作为佐证。
一、研究起点:sidecar 输出为什么在 Codex UI 里"不原生"
1.1 核心问题
研究文档提出的原始问题是:
Can opencodex sidecars make routed providers look native in Codex UI, instead of only feeding sidecar output back to the routed model as plain text?
即:opencodex 的 sidecar 能否让被路由的 Provider 在 Codex UI 中看起来"像原生的一样",而不是仅仅把 sidecar 输出以纯文本形式回注给路由模型?
要理解这个问题,需要先厘清两条路径的差异:
- 原生路径:Codex(codex-rs 客户端)直连 OpenAI/ChatGPT 后端,模型输出中包含结构化事件(如
web_search_call),TUI/App 据此渲染出"Searched the web"活动单元格、Sources 徽标等原生 UI。 - 代理路径:opencodex 拦截合成
web_search工具调用 → 通过 ChatGPT forward/responses后端真正执行搜索 → 把结果作为tool_result文本回注给路由模型 → 最终以普通 assistant 文本输出给 Codex。
在代理路径下,Codex 的 UI 只看到"一段包含搜索结果的回答文本",完全没有原生活动轨迹:没有搜索单元格、没有 Sources 徽标、没有进行中的搜索状态。这正是 00_research.md 想解决的"原生对齐"(parity)问题。
1.2 研究结论概览
研究结论可以浓缩为两句话:
- Web 搜索的 parity 是可行的、直接的:因为 codex-rs 原生定义并渲染
ResponseItem::WebSearchCall(线协议web_search_call),opencodex 只需保留 sidecar 的真实搜索执行,并在最终 assistant 文本之前发射对应的web_search_call输出项即可,无需修改 Codex 客户端。 - Vision 的 parity 与 Web 搜索性质不同:Codex 原生并不存在"模型读取了图片"这样的输出项。用户附加的图片会作为用户消息的一部分渲染;本地
view_image工具执行时 codex-rs 自己就会发出Viewed Image活动项。若伪造ImageView项在语义上是错误的(它要求真实的本地路径、代表一次工具执行),因此研究建议"视觉 sidecar 的 UI 保持现状"。
二、Web 搜索 sidecar 的现状:三段式链路
在深入协议细节前,先看 opencodex 目前如何实现 Web 搜索 sidecar(对应 00_research.md 的 "Current opencodex behavior")。其链路横跨 src/web-search/ 目录下多个文件:
| 环节 | 文件 | 职责 |
|---|---|---|
| 拦截 | src/web-search/loop.ts | 拦截路由模型发出的合成web_search工具调用 |
| 执行 | src/web-search/executor.ts | 通过 ChatGPT forward/responses后端运行真实的托管web_search |
| 解析 | src/web-search/parse.ts | 从 sidecar 的 SSE 流中提取最终文本与 URL 引用(sources) |
| 回注 | src/web-search/loop.ts | 将结果注入回路由模型作为tool_result |
| 合成工具 | src/web-search/synthetic-tool.ts | 定义合成web_search工具的声明(WEB_SEARCH_TOOL_NAME) |
2.1 拦截:scanEventsForWebSearch
loop.ts中的runWithWebSearch是主循环入口,它把路由模型置于一个小型 agentic 循环中:每次上游迭代被流式读取并整体缓冲,若模型调用了web_search,则由托管 sidecar 执行,把答案作为tool_result注入,然后继续循环(受maxSearches限制)。
关键的拦截函数是scanEventsForWebSearch(events)(src/web-search/loop.ts)。它把一次非流式回合的 adapter 事件拆成两部分:
- calls:需要被拦截的
web_search工具调用(含id与归一化后的queries[]); - passthrough:其余所有需要透传给 Codex 的事件(文本、thinking、真实工具调用、done)。
代码中有一个值得注意的细节:parseQueries(argsBuf)会把模型传来的参数归一化为规范的queries[],同时接受原生复数queries: string[]与单数query: string两种形态,非字符串/空条目被丢弃,畸形 JSON 得到[](下游按空查询调用处理)。这一点在后续 phase 2/3 中会成为"原生复数语义"的基础。
2.2 执行:runWebSearch 与多后端
executor.ts中的runWebSearch是 OpenAI(ChatGPT forward)路径的执行器,其要点包括:
- 复用前向 OAuth 头:前向 adapter 自身没有密钥,因此复用已选中的前向请求头(
FORWARD_HEADERS集合),见selectedForwardHeaders的拷贝逻辑; - 保持托管工具配置原样重放:把
hostedTool(来自 ChatGPT 后端的web_search工具定义)原样放进请求体tools,并设置tool_choice: "auto"; - 最小推理开销:以
reasoning: { effort: settings.reasoning }运行迷你模型,model与reasoning均来自SidecarSettings; - 两个硬性约束:ChatGPT(codex)后端拒绝
max_output_tokens("Unsupported parameter"),且要求store: false——请求体必须保持最小化; - 永不抛错(never throws)契约:任何失败都返回
{ text, sources, error }形态的SidecarOutcome,由调用方降级为工具结果,而不是中断整个回合。
值得一提的还有请求体中的instructions动态拼接:BASE_INSTRUCTION指示搜索助手"使用 web_search 工具查询、以Sources:段落逐行列出来源";当路由模型为纯文本模型(settings.describeImages)时追加IMAGE_INSTRUCTION,要求搜索助手把图片结果用文字描述并附上 URL——这正是研究文档中"image-web-search gap"的解决方案(src/web-search/executor.ts)。
此外,从WebSearchLoopDeps可以看到,搜索后端已从最初的 OpenAI 单一后端扩展为多后端架构:
backend: "openai"(默认,ChatGPT forward);backend: "anthropic"(使用存储的 OAuth Provider 运行web_search_20250305);backend: "xai"(Grok 的x_search,经xaiSearchOptions配置);backend: "gemini"(Antigravity CCA grounding);backend: "exa"(非 LLM 通道,读取exaApiKey)。
每个非 OpenAI 后端都遵循 fail-closed 原则:若对应 sidecar 依赖(如xaiSidecar、anthropicSidecar)未解析,则直接返回错误结果,而不是意外回落到使用前向头部的 OpenAI 执行器(源码注释明确标注该回退是"credential-sensitive",必须避免)。
2.3 解析:parseSidecarSSE
parse.ts的parseSidecarSSE从 sidecar 的流式 Responses SSE 中解析答案与引用,其健壮性体现在多处:
- 事件形态容错:优先采用权威的
response.completed中的output[];其次用response.output_text.done的完整文本;再退而累积response.output_text.delta。 - 引用多渠道收集:从
response.output_text.annotation.added流式事件、done 块的annotations[]、最终output[]三个渠道收集url_citation并去重。 Sources:尾部段落提取:托管web_search通常不输出结构化url_citation注解,而是在回答末尾以 MarkdownSources:段落列出来源。extractTrailingSources只提取尾部的 Sources 段落(避免把正文中偶然出现的 URL 误判为引用),支持- title: url、- title (url)、- title、- <url>、编号列表、Markdown 前缀标题、URL 在下一行的多行条目等形态,并在提取后把该段落从答案文本中剥离,避免 tool_result 渲染时重复打印来源。- 安全边界:响应体字节上限(
MAX_SIDECAR_RESPONSE_BYTES = 64KiB)、流式线字节上限(MAX_SIDECAR_STREAM_BYTES = 64KiB × 16)、解码文本字符上限(MAX_SIDECAR_DECODED_CHARS = 64KiB),任一超限即截断并标记错误,防止多图/超长回合撑爆主模型上下文。
三、codex-rs 消费端的原生 Web 搜索轨迹
研究文档明确指出,codex-rs已经原生理解Web 搜索活动,这是整个 parity 方案可行性的基石。相关消费链路(在 codex-rs 仓库,非本仓库源码,仅作协议依据):
| codex-rs 位置 | 内容 |
|---|---|
protocol/src/models.rs | ResponseItem::WebSearchCall,线类型web_search_call,字段id、status、action |
protocol/src/models.rs | WebSearchAction::{Search, OpenPage, FindInPage, Other} |
core/src/event_mapping.rs | 把ResponseItem::WebSearchCall映射为TurnItem::WebSearch |
core/src/session/turn.rs | response.output_item.added→ item started;response.output_item.done→ item completed |
tui/src/history_cell/search.rs | 渲染 Web 搜索历史单元格 |
因为 opencodex 已经在讲 codex-rs 消费的 Responses SSE 语言(见 src/bridge.ts),所以"发射web_search_call输出项"是一条 opencodex 侧的纯 bridge 改动:保留 sidecar 搜索执行,在最终 assistant 文本之前发射web_search_call输出项,codex-rs 无需客户端改动即可解析并渲染。
3.1 最小事件形态
研究文档给出了两种极简事件形态。
开始(Start):
{ "type": "response.output_item.added", "output_index": 0, "item": { "type": "web_search_call", "id": "ws_sidecar_...", "status": "in_progress" } }完成(Done):
{ "type": "response.output_item.done", "output_index": 0, "item": { "type": "web_search_call", "id": "ws_sidecar_...", "status": "completed", "action": { "type": "search", "query": "..." } } }两个事件的关键点:
added(in_progress)与done(completed)必须共用同一个id,codex-rs 才能把一次搜索的生命周期拼合为同一个单元格;done事件必须携带action: { type: "search", query },这是 codex-rsWebSearchAction::Search的合法形态;- 事件通过 SSE 线协议传输,
event.item会被直接反序列化为ResponseItem::WebSearchCall。
3.2 可行性风险评估
研究文档列出了三个主要风险,全部聚焦于时序与真实性:
- 避免把 sidecar 搜索与路由模型的原生能力混为一谈——UI 上呈现的"搜索"必须确实由 sidecar 执行过;
- 保持
output_index单调递增与终端response.completed的排序合法——新增的输出项会占用一个索引,后续消息项索引不能错乱; - 不得泄漏隐藏的 sidecar 提示文本——例如 2.2 节中注入的
instructions、forcedAnswerNudge等开发者角色提示,绝不能出现在最终输出里。
四、Web 搜索原生化的落地:三个 Phase 的演进
研究文档之后,同一目录下的三个 Phase 文档完整记录了从"研究结论"到"实现落地"的演进。这正好可以作为"源码级纵深"的实证链条,也印证了研究文档中"可行、风险主要在时序与真实性"的判断。
4.1 Phase 1:原生web_search_callUI(PABCD 计划)
Phase 1(10_phase1_websearch-native-ui.md)的改动范围被严格限定:
IN 范围:
src/types.ts— 在AdapterEvent联合类型中新增web_search_call变体;src/bridge.ts— 在流式bridgeToResponsesSSE与非流式buildResponseJSON中把新事件发射为自包含的web_search_call输出项;src/web-search/loop.ts— 记录已执行的搜索并预置新事件;tests/bridge.test.ts+tests/web-search.test.ts— 覆盖测试。
OUT 范围:不改 adapter、不改 sidecar 执行器、不改parse.ts;不改 Vision sidecar;此阶段不把sources作为引用/注解转发(原生单元格只需要action.query,注解留待后续阶段)。
Phase 1 还定下了一个关键排序决策:先发射所有搜索活动项,再发射最终答案项——这与原生流程一致(先搜索、后作答),且因为循环在执行finalEvents之前已经完成所有搜索,实现上也最简单。
验证记录(20_phase1_verification.md)确认:
bun x tsc --noEmit通过;bun test tests/bridge.test.ts tests/web-search.test.ts tests/sidecar-abort.test.ts→ 29 通过、0 失败(含 4 个新测试);- 独立审查者对照 codex-rs 确认:SSE 将
event.item直接反序列化为ResponseItem::WebSearchCall;id字段名是id而非call_id(尽管skip_serializing,反序列化仍读取它);action: { type: "search", query }是合法形态;同时发出 added 与 done 匹配原生生命周期。 - 细节修正:使用
event.id而非新生成的 uuid;只在实际runWebSearch分支记录;增加循环级测试。
4.2 Phase 2:原生保真度(3 个 PABCD 循环)
Phase 2(30_phase2_websearch-fidelity.md)针对 5 个代理侦察发现的三个原生对齐缺口,做了三轮回合:
循环 1 — 强制应答反映搜索结果:当forceAnswer为真且至少执行过一次真实搜索时,向强制应答轮次的请求注入一条瞬态 developer 角色提示(nudge),指示模型基于已收集的 Web 结果作答并引用来源。该提示只加到iterParsed(迭代局部),绝不污染持久化的messages。这是对研究文档"避免泄漏隐藏提示"风险的一个正面处理:提示是给路由模型看的,不进入任何输出线。
循环 2 — 实时 in_progress → completed 生命周期:原实现把所有迭代缓冲后统一重放[...searchEvents, ...finalEvents],导致added(in_progress)与done(completed)背靠背发射,"Searching the web" 状态在真实的数秒 sidecar 调用期间从未显示。改造方案:
- 把单一
web_search_call事件拆为两个生命周期事件——web_search_call_begin { id }(在runWebSearch之前发射,bridge 输出in_progress)与web_search_call_end { id, query, status }(在返回后发射,bridge 输出completed/failed与action.search); - 重构
runWithWebSearch,使 SSE body 由 async generator 驱动,搜索单元格的开始/结束与真实 sidecar 时序交错地实时发射。
这里还保留了一个硬契约(来自既有测试):只有第一次模型调用被急切执行(其 fetch/parse 错误仍返回jsonError,如 502/499);第 2+ 次迭代的失败(已处于 200 流内)以流内error事件呈现。Sidecar 搜索失败不致命:通过recordSidecarOutcome记录后,循环仍返回 200 SSE 且模型作答。
循环 3 — 原生复数查询语义:原生action.search.queries(复数)此前不受支持。改造后:
synthetic-tool.ts的合成web_search工具同时接受query(string)或queries(string[]);scanEventsForWebSearch把模型参数解析为规范queries[];runSearchCall对一次批量调用逐条执行 sidecar 搜索(各自计入maxSearches/failedQueries预算),但注入一个assistant toolCall(参数{ queries })与一个聚合 toolResult,保证函数调用配对合法;只发射一个开始与结束单元格,结束单元格携带全部queries,Codex 原生显示Searched <first> ...;format-result.ts新增聚合器,把多条 (query, outcome) 渲染为一个 tool_result 字符串(散文路径为带标签块;结构化路径为单个{ results: [...] }JSON),单查询路径保持向后兼容。
4.3 Phase 3:sources/citations 到达 GUI
Phase 3(40_phase3_websearch-sources.md)解决了"Codex 桌面 App 在搜索结果后渲染 Sources 徽标与行内引用"的问题。
背景:代理已经从 sidecar 解析出url_citation为outcome.sources(url+title),但只通过formatWebSearchResults喂给了 toolResult 文本;最终 assistant 消息始终发射output_text.annotations: [],GUI 永远收不到引用。
决策:归一化为 App 线协议形态——output_text.annotations[]携带url_citation条目。codex-rs(TUI)目前忽略 annotations,因此这是纯增量改动,TUI 不受影响;桌面 App 读取 annotations 绘制 Sources 徽标。
标准线形态:
{ "type": "output_text", "text": "...answer...", "annotations": [ { "type": "url_citation", "url": "https://...", "title": "Node.js Releases", "start_index": 0, "end_index": 0 } ] }改动映射:
src/types.ts:新增OcxUrlCitation { url; title? },并让搜索结束事件携带sources?: OcxUrlCitation[];src/web-search/loop.tsrunSearchCall:跨批量查询去重outcome.sources并挂到web_search_call_end事件;src/bridge.ts(流式):累积来自web_search_call_end的pendingWebSources,在下一条 assistant 消息关闭时以其output_text.annotations发射url_citation[],随后清空——保证引用精确绑定到搜索后的第一条消息;src/bridge.tsbuildResponseJSON(非流式):同样的累积,在flushText()中挂接。
OUT 范围:行内字符范围引用(start/end 索引指向文本)不做,统一发射 0/0(App 只从 url/title 绘制徽标);不改 toolResult 文本格式(模型仍然在文本中收到来源)。
4.4 当前源码形态印证
研究文档所描述的"当前行为"在仓库源码中得到了完整印证。以 src/web-search/loop.ts 为例,可以观察到:
runSearchCall中真实搜索分支在runWebSearch前发射{ type: "web_search_call_begin", id: call.id },在聚合结果后发射{ type: "web_search_call_end", id, queries, status: anySuccess ? "completed" : "failed", ...(sources.length > 0 ? { sources } : {}) }——正是 Phase 2 拆分后的实时生命周期形态;status只在真实命中 sidecar的搜索上发射(空查询/超限/重复占位不发射单元格),与 Phase 1 的"记录只针对真实runWebSearch分支"决策一致;- 搜索结果会去重后携带
sources集合,供 bridge 在后续 assistant 消息上挂接url_citation注解——Phase 3 的落点。
相关测试可进一步查看 tests/web-search/web-search.test.ts、tests/web-search/web-search-progress-stream.test.ts 与 tests/adapters/bridge.test.ts。
五、Vision / image-read sidecar:为什么不能做同样的对齐
5.1 当前行为:请求预处理,而非输出项桥接
研究文档明确指出,Vision sidecar不是Responses 输出项桥接,而是请求预处理:
src/server.ts在主 Provider 请求之前规划(plan)Vision sidecar;- src/vision/index.ts 仅在路由模型被分类为纯文本(
provider.noVisionModels)且请求携带图片部分时激活; - src/vision/describe.ts 把每张图片发送给 ChatGPT 前向视觉模型;
describeImagesInPlace(...)在路由模型看到请求之前,把图片内容部分替换为文本描述。
从源码看(src/vision/index.ts),这个预处理还包含一整套工程细节:
- 有界 LRU 描述缓存:默认最多 256 条目、1 MiB 字节(
VISION_DESCRIPTION_CACHE_MAX_BYTES),以图片内容哈希 + 上下文哈希 + 后端/模型/推理设置组合为键;data:base64 内联图片被视为可持久缓存(persistent = true),可配合enforceAppOwnedMemoryBudget做内存回收; - 有界并发:
VISION_CONCURRENCY = 3,多图回合不会串行累加每张图的延迟; - 输出钳制:每张图片描述硬上限
DESC_MAX_CHARS = 2000字符,用户文本上下文上限CONTEXT_MAX_CHARS = 800字符,防止多图回合撑爆主模型上下文; - 降级语义:失败或超过
maxDescriptionsPerTurn时,替换文本为[An image was attached but could not be processed: ...]或[Image content — described by a vision model because you cannot see images directly: ...]形式的标记,保证纯文本模型仍然能看到"这里有一张图"。
5.2 两条原生图片路径
研究文档区分了 codex-rs 中两种不同的原生图片能力:
1. 用户附加图片(user-attached images)
ContentItem::InputImage与UserInput::Image以图片 URL 形式携带;- TUI/App 把它们作为用户消息的一部分渲染(
remote_image_urls); - 对普通视觉输入,不存在单独的"模型读取图片"输出项——原生视觉模型只是收到图片。
2. 本地view_image工具
- codex-rs 暴露
view_image工具; core/src/tools/handlers/view_image.rs加载本地文件,发射TurnItem::ImageView,随后返回包含input_image内容项的function_call_output;protocol/src/items.rs把TurnItem::ImageView映射为EventMsg::ViewImageToolCall;tui/src/history_cell/patches.rs渲染Viewed Image。
5.3 可行性分析:不对称的结论
研究文档给出了与 Web 搜索不同的可行性结论:
对于view_image工具结果:
- 当本地
view_image工具实际运行时,codex-rs已经产生了原生式 UI; - opencodex 会收到携带
input_image的后续function_call_output; - 对于纯文本路由模型,Vision sidecar 描述该图片并替换为文本;
- 无需额外原生 UI 事件——新增一个反而会重复已有的
Viewed Image活动。
对于用户附加图片:
- Codex 已经在用户消息中显示附件;
- 原生 OpenAI 视觉不产生单独的"读取图片"活动项;
- sidecar 可以可选地发射一个诊断/仅代理状态,但codex-rs 原生不存在与
web_search_call对应的"Vision sidecar 描述了这张输入图片"的 Responses 输出项; - 伪造
ImageView在语义上是错误的,除非图片确实来自真实的本地view_image工具——因为ImageViewItem要求本地路径并代表一次工具执行。
5.4 建议:先做 Web 搜索,Vision 保持现状
研究文档的最终建议非常明确:
- 优先实现原生式
web_search_call重发射(即 4.1–4.3 节的落地路径); - Vision sidecar UI 暂时保持现状:
view_image已有原生 UI;- 用户附件已作为用户消息图片渲染;
- sidecar 描述是内部预处理步骤,不是原生 Codex 事件;
- 若确实需要可见性,以后添加一个透明的 opencodex 诊断/状态事件,而不是假装模型或 Codex 运行了本地图片查看工具。
六、底线结论
研究文档的 Bottom line 可以概括为一张不对称的结论表:
| 维度 | Web 搜索 sidecar | Vision / image-read sidecar |
|---|---|---|
| codex-rs 原生输出项 | 有:web_search_call(ResponseItem::WebSearchCall) | 无"图片已读取"输出项 |
本地view_imageUI | — | 已由 codex-rs 在工具实际执行时发出 |
| 用户附件渲染 | — | 已作为用户消息图片渲染 |
| parity 可行性 | 直接可行(opencodex 侧 bridge 改动) | 不可行/不必要;伪造事件语义错误 |
| 落地形态 | 发射web_search_calladded/done 对 → 原生 "Searched the web" 单元格 + Sources 徽标 | 请求预处理;如需可见性,走透明诊断事件 |
一句话总结:web_searchparity 之所以"直截了当",是因为 codex-rs 原生拥有web_search_call输出项,opencodex 只需在正确时序发射即可;Vision sidecar parity 之所以"性质不同",是因为原生 Codex 不把"图片被读取"暴露为模型输出项,而本地view_image工具的真实 UI 已经由 codex-rs 自身负责——任何试图伪造的ImageView都是语义错误。
附:进一步阅读指引
- 研究文档:00_research.md
- 落地计划与验证:10_phase1_websearch-native-ui.md、20_phase1_verification.md、30_phase2_websearch-fidelity.md、40_phase3_websearch-sources.md
- 核心源码:src/web-search/loop.ts、src/web-search/executor.ts、src/web-search/parse.ts、src/web-search/synthetic-tool.ts、src/vision/index.ts、src/bridge.ts
- 相关测试:tests/web-search/web-search.test.ts、tests/web-search/web-search-progress-stream.test.ts、tests/web-search/web-search-passthrough-bridge.test.ts、tests/adapters/bridge.test.ts
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考