grok-gateway 如何把 Codex 接到 SuperGrok

从 catalog、code mode 到 Responses 线格式改写

grok-gateway 做的事情很窄:让 Codex Desktop / CLI 继续说 OpenAI Responses,让请求落到 SuperGrok 的 cli-chat-proxy,并且尽量像官方 grok-build 那样发。

它不是 Chat Completions 翻译器。Codex 已经走 POST /v1/responses;Grok 上游也能吃 Responses。真正要做的是:Codex 的协议超集,砍成 grok-build 能过的子集;再把上游 SSE 改回 Codex 认得的形状。

这篇文章按实现顺序写:先说该读哪两份源码,再讲 catalog 为什么决定了后半段所有改写,然后是 code mode 的上下文注入,最后是请求出站和响应回程。

先读 Codex,再读 grok-build

下游和上游各有一份官方实现。gateway 里几乎每一个奇怪开关,都能在这两份代码里找到出处。本地对照目录是 ~/codex~/grok-build

Codex:catalog 才是能力合同

Codex 对自定义 provider 会打 GET /v1/models。普通 OpenAI 客户端读 data[];Codex 只读 models[]。类型在 codex-rs/protocol/src/openai_models.rsModelInfo

这份 catalog 不是「有哪些模型名」那么简单。tool_modeuse_responses_litesupports_search_toolmulti_agent_version 会改 Codex 组出来的请求:

  • 工具面是 Direct(exec_command / write_stdin 平铺),还是 code mode(嵌进 JS exec + wait
  • instructions / tools 是顶层字段,还是塞进 input[] 里的 additional_tools(Responses Lite)
  • 有没有本机 tool_search,MCP 要不要延迟暴露
  • 新线程走 multi-agent v1 还是 v2

codex-rs/core/src/client.rsbuild_responses_requestmodel_info.use_responses_lite 分叉;codex-rs/core/src/tools/mod.rsrequested_tool_mode 明确写了:catalog 的 tool_mode 覆盖 本地 feature flag。

内置一等公民模型在 codex-rs/models-manager/models.jsongpt-5.6-sol / terra / luna 以及更早的 gpt-5.5 家族。models-manager/src/manager.rs 拉远端 catalog 时,ChatGPT 登录会把远端列表当唯一源;否则把远端 merge 进这份内置表。这一点后面会变成一个坑。

code mode 的工具合同在 codex-rs/core/src/tools/code_mode/codex-rs/code-mode-protocol/src/description.rsexec 是一段跑在 V8 isolate 里的 JS,嵌套工具挂在全局 tools 上;waitcell_id 续跑;apply_patch 是 freeform,真正的语法在 Lark grammar 里。

grok-build:上游只认 grok-shell 那条线

SuperGrok 的 coding agent 是 xai-org/grok-build。gateway 要假装自己是它。

关键文件:

  • crates/codegen/xai-grok-sampler/src/client.rs:请求头、apply_response_defaults、SSE
  • crates/codegen/xai-grok-shell/src/agent/config.rsinject_url_derived_headers
  • crates/codegen/xai-grok-sampling-types/src/conversation/responses.rs:Responses 默认值和 reasoning 回放

它对 cli-chat-proxy.grok.com 的约定可以压成几条:

  • 客户端身份是 grok-shellUser-Agent 形如 grok-shell/1.0.5 (linux; x86_64),另外还有 X-XAI-Token-Authx-grok-client-identifierx-grok-client-version(proxy 用它做 version gating)
  • sampling 请求再带 x-grok-conv-idx-grok-req-idx-grok-model-overridex-grok-session-id
  • 未指定时强制 store: false,注释写得很直白:默认 true 会破坏 ZDR
  • include 里恒有 reasoning.encrypted_content,多轮必须把顶层 reasoning item 原样回放,而且要删掉 output-only 的 status
  • prompt_cache_keyx-grok-conv-id 同值,都是会话 UUID
  • reasoning.summary 恒为 concise。Grok 没有 OpenAI 那套 auto / detailed / none 分档

xAI 的 Responses 没有 Codex 那些扩展:additional_toolsnamespacecustom / freeform、format(Lark grammar)、client_metadata。这些东西原样转发就是 422。

catalog 决定 Codex 怎么说话

各家 LLM 和 harness 现在往往有更深的定制化训练,以及更私有的线协议——Codex 的 Responses Lite 就是例子。第三方模型很难完整、正确地理解另一套 harness 提供的工具面。所以本 gateway 的模型 catalog 不能全盘照抄官方 GPT-5.6 的特性开关;该关的要关。有时还要改提示词和工具描述,用 Grok 更能看懂的方式把 tool 用法讲清楚。

catalog 先决定 Codex 组出来的请求长什么样:要不要 code mode、要不要 Lite、要不要本机 tool_search。后面「code mode 上下文注入」则是在协议被压扁之后,把官方模型靠训练和 Lark grammar 才懂的合同,改写成 Grok 能读的说明。两边是一件事的两半。

实现在 internal/models/catalog.go。上游 GET /models 被收成双 payload:

{
  "object": "list",
  "data": [ { "id": "grok-4.6", "owned_by": "xai" } ],
  "models": [ { "slug": "grok-4.6", "tool_mode": "code_mode_only" } ]
}

data 给 Claude Code / 普通 OpenAI 客户端;models 给 Codex。两边的 id/slug 一致,只发布 grok-*grok-4.6-build 这类上游变体会归一成 grok-4.6

放出哪些 slug,等于告诉 Codex:请按这份能力合同组请求。gateway 对所有 grok-* 用同一套,不模仿官方 sol / terra / luna 的分化。

打开的能力

tool_mode: code_mode_only

这是最重要的开关。官方 GPT-5.6 也是 code_mode_only。Codex 会把 exec_commandapply_patch、MCP、插件收进一个 JS exec,再用并列的 wait 等异步 cell。

code_mode 还允许 Direct 工具面;code_mode_only 则把它们从顶层藏掉。Grok 更吃「一个脚本里编排工具」这条路,而且 code mode 和 Lite 无关——官方测试会对 Lite true / false 各跑一遍 exec

不写 tool_mode 时,Codex 当 Direct。自定义 provider 上 Direct 意味着几十上百个 JSON function 平铺,schema 又肥又碎,Grok 容易乱调。

apply_patch_tool_type: freeformshell_type: shell_command

apply_patch_tool_type Codex 才会注册 apply_patch。code mode 下它变成 exec 的 nested 工具。shell 用 shell_command,和官方 5.6 一致。

multi_agent_version: v1

官方 sol / terra 是 v2,luna 是 v1。v2 是 Sol/Terra 训过的 collaboration;v1 是父进程编排 spawn_agent。Grok 没吃过 v2 那套提示,catalog 钉死 v1。本地 features.multi_agent_v2 仍可能覆盖,那是 Codex 自己的事。

窗口 50 万、default_reasoning_level: highdefault_reasoning_summary: concise

官方 5.6 窗口是 272k / 872k,默认 effort 偏低(sol 甚至是 low)。Grok 4.6 本身就有 50 万上下文,catalog 按这个报;effort 默认 high,让它多想一会儿。

reasoning.summary 不是思考深度。effort 才是想多久。Grok 没有 auto/detailed 摘要档,grok-build 出站永远 concise,所以 catalog 也发 concise,出站再强制改写一遍,避免 Codex 默认的 auto 或用户配的 detailed 漏上去。

系统提示用仓库里的 sol_instructions.md。Grok 没在官方 Sol 的工具面上训练过,需要同一份 playbook:怎么用 exec、怎么 apply_patch、怎么更新用户。

故意关掉的能力

不写 use_responses_lite

Lite 是给 OpenAI 托管后端准备的组包:不发顶层 tools / instructions,改成 input[] 里的 additional_tools,再加 client_metadata。打开条件就是 catalog 里这个布尔值,不是 slug 叫 gpt-5.6-sol

对 Grok 开 Lite 没有好处。xAI 的 ModelInput 没有 additional_tools 变体,会 422。更硬的原因是搜索:

Codex 有两条网页搜索路。hosted web_search 是 Responses 顶层工具,模型发 web_search_call,由 xAI 服务端执行——这是 gateway 现在走的路。本机 web.run 要 provider 打开 supports_standalone_web_search,请求打到 {base_url}/alpha/search,gateway 没有这条路由。

codex-rs/core/src/tools/spec_plan.rs 里,use_responses_lite == truehosted_model_tool_specs 直接 return Vec::new()。Lite 不接受 hosted Responses 工具。结果是:

  • hosted web_search 被 Codex 砍掉,config.toml 加不回来
  • web.run 自定义 grok provider 默认也不出现
  • 搜索工具整个消失,不是调用时报错

所以 catalog 保持缺省 false。gateway 仍能翻译 Lite 入站,只是当防护网:旧会话或官方 slug 万一还带着 additional_tools,先 hoist 再发给上游。

不写 supports_search_tool

这个字段经常被看成「能不能搜网页」。它不是。

它是本机 tool_search:用 BM25 检索延迟加载的 MCP / 插件目录。官方 GPT-5.6 打开,是因为工具面太肥,首轮不能带全量 schema。

gateway 已经是 code_mode_only。MCP 不会出现在顶层 tools[],而是写进 exec 的 description(实测可以到一百多个 nested)。这套延迟发现是给官方 Sol 训练过的路径;Grok 更容易出现「工具其实在,但模型不去搜」。常用 MCP 直接出现在 exec / ALL_TOOLS 更稳。

它也不会把 hosted web_search defer 掉。网页搜索和工具目录检索是两件事。

不写 prefer_websockets

官方 JSON 有这个键,当前 ModelInfo 还没有,serde 会忽略。gateway 故意不抄。一旦 Codex 把类型挂上,抄了就会去走未实现的 WebSocket 传输。

support_verbosity: false,图片 detail=original 关掉

text.verbosity 是 GPT-5 的最终回答长短,Grok 没有对应旋钮。图片 original 细节 xAI 也不吃,保持零值即可。

截断策略官方是 {tokens, 10000},gateway 发 {bytes, 10000}。Grok 侧按字节截更可预期。

两个小坑

1. catalog 里不要放 Codex 的一等公民模型

曾经想过把 gpt-5.6-sol 伪装进 models[],让 Codex 以为自己还在用官方模型。不要这样做。

这些 slug 写在 Codex 内置的 models.json 里,是一等公民。用户如果还留着 ChatGPT 登录(uses_codex_backend / requires_openai_auth),官方身份会走 chatgpt.com/backend-api 那条 OpenAI OAuth 路径,而不是自定义 provider 的 base_url

catalog 里一旦出现 gpt-5.6-* / gpt-5.5 / gpt-5.4

  • 它们会进模型选择器
  • 点选之后请求泄漏到 OpenAI,gateway 根本看不见
  • 自定义 provider 的 tool_mode 也罩不住这条路

所以 publishableID 只放行 grok- 前缀。请求里如果还带着官方 GPT slug,responses.ResolveModel 会折到 xai.default_model(或 model_aliases)。接入后应在 Codex 里选 grok-4.6 / grok-4.5。slug 不在 models[] 里就没有 tool_mode,Codex 会退回 Direct。

2. 必须给 gpt-5.6-luna 设别名

主对话选了 grok-4.6,并不表示每条请求都是这个名字。Codex 有一批内部短任务写死了 luna:

  • 对话标题总结(侧边栏那一行 title)默认用 gpt-5.6-luna
  • 自定义 provider 的 memory extraction 默认模型也是 luna(DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL
  • API key 路径的 approval review 同样偏向 luna

这些请求打到 gateway 时,model 仍是 gpt-5.6-luna。早期 Responses 路径只在 model 为空时填默认值,于是上游 404:The model gpt-5.6-luna does not exist

现在非 grok-* 都会进 ResolveModel,不设别名也会落到 default_model。但标题总结这种短请求不该去打 4.6。显式映射更干净:

xai:
  default_model: grok-4.6
  model_aliases:
    gpt-5.6-luna: grok-4.5

别名只作用于打到 gateway 的请求。catalog 仍然不要发布 luna,否则又回到坑 1。

code mode 下 gateway 补的上下文

catalog 打开 code_mode_only 之后,Codex 发给 gateway 的工具面大致是:

  • 顶层 custom exec:入参是一段 JS,不是 JSON object of tool arguments
  • 顶层 JSON function waitcell_id / yield_time_ms / max_tokens / terminate
  • apply_patchexec_command、MCP 出现在 exec 的 description 里,运行时通过 await tools.some_tool(...) 调用

官方模型能吃这套,因为:

  1. 它们训过 code mode
  2. exec / apply_patch 是 custom/freeform,Lark grammar 会进模型上下文
  3. CodeModeExecuteHandler 只认 ToolPayload::Custom,要的是裸 JS / 裸 patch 文本

xAI 只接受 JSON functionadaptCustomAsFunction 会把 custom 压成:

{
  "type": "function",
  "name": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "input": { "type": "string" }
    },
    "required": ["input"]
  }
}

format 字段被丢掉。如果不把 grammar 和用法补回去,Grok 看到的只是「有一个叫 exec 的函数,参数是 input 字符串」,然后就开始发明调用方式。

补丁写在 internal/responses/codex_tools.go

exec:告诉模型这是 JS 编排器

gateway 往 exec 的 description 追加一段 wrapper,并在 instructions 末尾再钉一段 reminder。给 Grok 看的不是「有一个叫 exec 的函数」,而是怎么编排、怎么把结果捞出来。

嵌套工具的返回值留在 JS isolate 里,不会自动变成这次 exec 的输出。必须调用 text(...)notify(...);只 await tools.exec_command(...) 然后结束,Codex 拿到的是空结果,模型会以为命令没跑。正确形态是 gateway 直接写进 description 的那段:

const r = await tools.exec_command({cmd:"rg -n foo"});
text(r.output || ("exit="+r.exit_code+" (empty)"));

连带几条硬约束:

  • arguments.input 必须是原始 JavaScript,不要 markdown fence,也不要再包一层工具参数对象
  • 嵌套工具走 await tools.some_tool(...),不要把它们当成并列的顶层 function_call
  • 嵌套调用都要 await;未 await 的 Promise 会被丢掉
  • 不要用 > 重定向命令 stdout,isolate 里读不到
  • 可选首行 pragma:// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}

instructions 里的 reminder 会把同一条例再讲一遍。Grok 很容易写完 nested call 就收工,这段是实测下来最有效的约束。

apply_patch:语法、实例、不要用 JS 模板字符串

官方模型从 Lark grammar 学会 patch 语言。压成 JSON function 之后 format 被丢掉,Grok 只能从 description 学。gateway 会把原文法 hoist 回去,再补一份人能读的 apply_patch format

语法是一份剥离过的、面向文件的 diff。整份 patch 必须以 *** Begin Patch 开头、*** End Patch 结尾;每个文件操作是 Add / Delete / Update 之一,hunk 用 @@ 引入,行首 + / - / 空格分别表示新增、删除、上下文:

Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE

路径必须相对、不能绝对。新建文件的每一行也要带 +。默认在改动前后各留 3 行上下文;不够独特时,用函数名或类名当 @@ 头。gateway 给 Grok 看的完整实例是:

*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch

exec 里调用时,不要用 JS 模板字符串拼 patch。反引号模板里的 } 会被当成 exec 调用的结束,patch 截断,文件内容残缺。要用字符串拼接或 Array.join

const patch = [
  "*** Begin Patch",
  "*** Add File: hello.txt",
  "+Hello world",
  "*** End Patch"
].join("\n");
await tools.apply_patch(patch);

apply_patch 自己若仍作为顶层 custom 出现,也会被压成 {input: string},description 同样带上格式说明。回程必须再改回 custom_tool_call,并从 arguments 抽出 input;否则 Codex 的 CodeModeExecuteHandler 不认。

wait:出站改 schema,回程改数字

Codex 发布的 wait schema 把 yield_time_ms / max_tokens 标成 JSON Schema number,但 wait_handler.rsu64 / usize 反序列化。Grok 看到 number 就爱回 10000.0,serde_json 直接拒。

gateway 出站只改 wait:把这两个字段的 schema number 改成 integer(xAI 的 ArgumentType::Integer)。客户端保存的原始 tools 不动,回给 Codex 的生命周期事件里仍是它原来的 schema。

即便如此,上游仍可能吐出 float 或数字字符串。回程再把 wait 的 arguments 收成整数。只动 wait,不动别的 function。

请求改写,再把响应改回去

Codex 和 Claude Code 最终都进 responses.Prepare。下面只谈 Codex 这条。原则是:入站当超集收,出站当 grok-build 子集发。

出站:Codex Responses 到 grok-build

模型名

ResolveModelmodel_aliases 优先;否则非 grok-* 落到 default_model。体字段 model 和请求头 x-grok-model-override 都用映射后的名字。回包里的 response.model 保持上游真名(常见 grok-4.6-build),不伪装成 gpt-5.6-luna

grok-build 默认值

  • 客户端没写 store 就填 falsestore: true 直接 400
  • 非 composer 模型把 reasoning.encrypted_content 并进 include
  • reasoning 对象就把 summary 改成 concise
  • prompt_cache_key 映射成稳定 UUID,和 x-grok-conv-id 同值;客户端原始 key 不上传游
  • 不支持 previous_response_id / background / stop

Lite 与工具面

  • input[] hoist 所有 additional_tools,合并进顶层 tools,再删掉这些 item。历史里不会留下 additional_tools,所以每轮都会重新 prepend 一份
  • namespace 展开成扁平 function;重名时加 namespace__name 前缀
  • custom / exec / apply_patch 改成 function,参数 {input: string},文法抄进 description
  • wait 的 schema 收成 integer
  • web_search 去掉 OpenAI 专有的 external_web_access
  • 丢掉 client_metadata(grok-build 不发;里面的 tool_namespaces_info 到不了上游)
  • 丢掉 stream_optionssafety_identifierprompt_cache_retention

历史回放

下一轮 input 里的 custom_tool_call 要先改成 function_callinput 放进 arguments.inputcustom_tool_call_output 改成 function_call_output。reasoning item 删除 status,显式 nullcontent / encrypted_content 也删,否则 xAI 422。

请求头

internal/xai/client.go 按 grok-build 的 inject_url_derived_headers + GrokRequestHeaders 打:

X-XAI-Token-Auth: xai-grok-cli
x-authenticateresponse: authenticate-response
x-grok-client-identifier: grok-shell
x-grok-client-mode: interactive
User-Agent: grok-shell/<cli_version> (windows; x86_64)
x-grok-model-override: grok-4.6
x-grok-conv-id: <uuid>
x-grok-req-id: <uuid>
x-grok-session-id: <uuid>
Accept: text/event-stream

上游永远 SSE。客户端如果要非流式 JSON,由 gateway 收齐再返回。

回程:Grok SSE 到 Codex

ResponseRewriter 做对称的还原,见 internal/responses/response_rewrite.gonamespace.go

  • 扁平 function 名还原成 Codex 的 name + namespace
  • 原本是 custom 的 function_call 改回 custom_tool_call,从 arguments 抽出 input(兼容模型把 patch 放在 patch / code / 唯一字符串字段里,以及外层 markdown fence)
  • wait 参数收成 JSON integer,避免 10000.0 把 Codex 打崩
  • 生命周期事件里的 response.tools 换回客户端原始的 namespace / custom 定义
  • prompt_cache_key 换回客户端原来的值
  • 上游 ping 不是合法 Responses 事件,改写成 SSE 注释 : ping

必须还原 custom_tool_call。code mode 的执行器不吃 function_call 形态的 exec / apply_patch。

收束

对照两份源码之后,gateway 的策略其实就三句话:

  1. catalog 只发布 grok-*,用 code_mode_only + multi_agent v1 换 Codex 的工具面;Lite 和 tool_search 关掉,把 hosted web_search 留给 xAI。
  2. 不要把 GPT 一等公民放进 catalog;内部 luna 短请求用 model_aliases 接住。
  3. 出站变成 grok-build 能吃的 JSON function + store:false + encrypted reasoning;回程把 custom / namespace / wait integer 还给 Codex。

Lite 翻译代码留下,是兼容不是目标协议。对 Grok 打开它,只是让 Codex 先打成 OpenAI 后端的形状,gateway 再拆回去,还顺手把搜索弄丢。

相关实现:internal/models/catalog.gointernal/responses/codex_tools.gointernal/responses/namespace.gointernal/responses/request.gointernal/xai/client.go。更细的字段表在仓库文档 docs/codex-models-catalog.mddocs/codex-responses-lite.mddocs/grok-build-reference.md