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.rs 的 ModelInfo。
这份 catalog 不是「有哪些模型名」那么简单。tool_mode、use_responses_lite、supports_search_tool、multi_agent_version 会改 Codex 组出来的请求:
- 工具面是 Direct(
exec_command/write_stdin平铺),还是 code mode(嵌进 JSexec+wait) - instructions / tools 是顶层字段,还是塞进
input[]里的additional_tools(Responses Lite) - 有没有本机
tool_search,MCP 要不要延迟暴露 - 新线程走 multi-agent v1 还是 v2
codex-rs/core/src/client.rs 的 build_responses_request 按 model_info.use_responses_lite 分叉;codex-rs/core/src/tools/mod.rs 的 requested_tool_mode 明确写了:catalog 的 tool_mode 覆盖 本地 feature flag。
内置一等公民模型在 codex-rs/models-manager/models.json:gpt-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.rs:exec 是一段跑在 V8 isolate 里的 JS,嵌套工具挂在全局 tools 上;wait 按 cell_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、SSEcrates/codegen/xai-grok-shell/src/agent/config.rs:inject_url_derived_headerscrates/codegen/xai-grok-sampling-types/src/conversation/responses.rs:Responses 默认值和 reasoning 回放
它对 cli-chat-proxy.grok.com 的约定可以压成几条:
- 客户端身份是
grok-shell。User-Agent形如grok-shell/1.0.5 (linux; x86_64),另外还有X-XAI-Token-Auth、x-grok-client-identifier、x-grok-client-version(proxy 用它做 version gating) - sampling 请求再带
x-grok-conv-id、x-grok-req-id、x-grok-model-override、x-grok-session-id - 未指定时强制
store: false,注释写得很直白:默认 true 会破坏 ZDR include里恒有reasoning.encrypted_content,多轮必须把顶层reasoningitem 原样回放,而且要删掉 output-only 的statusprompt_cache_key和x-grok-conv-id同值,都是会话 UUIDreasoning.summary恒为concise。Grok 没有 OpenAI 那套 auto / detailed / none 分档
xAI 的 Responses 没有 Codex 那些扩展:additional_tools、namespace、custom / 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_command、apply_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: freeform、shell_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: high、default_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 == true 时 hosted_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
wait:cell_id/yield_time_ms/max_tokens/terminate apply_patch、exec_command、MCP 出现在exec的 description 里,运行时通过await tools.some_tool(...)调用
官方模型能吃这套,因为:
- 它们训过 code mode
exec/apply_patch是 custom/freeform,Lark grammar 会进模型上下文CodeModeExecuteHandler只认ToolPayload::Custom,要的是裸 JS / 裸 patch 文本
xAI 只接受 JSON function。adaptCustomAsFunction 会把 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.rs 按 u64 / 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
模型名
ResolveModel:model_aliases 优先;否则非 grok-* 落到 default_model。体字段 model 和请求头 x-grok-model-override 都用映射后的名字。回包里的 response.model 保持上游真名(常见 grok-4.6-build),不伪装成 gpt-5.6-luna。
grok-build 默认值
- 客户端没写
store就填false;store: 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},文法抄进 descriptionwait的 schema 收成 integerweb_search去掉 OpenAI 专有的external_web_access- 丢掉
client_metadata(grok-build 不发;里面的tool_namespaces_info到不了上游) - 丢掉
stream_options、safety_identifier、prompt_cache_retention
历史回放
下一轮 input 里的 custom_tool_call 要先改成 function_call,input 放进 arguments.input。custom_tool_call_output 改成 function_call_output。reasoning item 删除 status,显式 null 的 content / 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.go 和 namespace.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 的策略其实就三句话:
- catalog 只发布
grok-*,用code_mode_only+multi_agentv1 换 Codex 的工具面;Lite 和tool_search关掉,把 hostedweb_search留给 xAI。 - 不要把 GPT 一等公民放进 catalog;内部 luna 短请求用
model_aliases接住。 - 出站变成 grok-build 能吃的 JSON function +
store:false+ encrypted reasoning;回程把 custom / namespace / wait integer 还给 Codex。
Lite 翻译代码留下,是兼容不是目标协议。对 Grok 打开它,只是让 Codex 先打成 OpenAI 后端的形状,gateway 再拆回去,还顺手把搜索弄丢。
相关实现:internal/models/catalog.go、internal/responses/codex_tools.go、internal/responses/namespace.go、internal/responses/request.go、internal/xai/client.go。更细的字段表在仓库文档 docs/codex-models-catalog.md、docs/codex-responses-lite.md、docs/grok-build-reference.md。