OpenCode调用CodeX实现文生图功能
最近在给 OpenCode 客户端接一个文生图能力:让用户在对话里直接让 AI 生成图片,图片落到本地工作区,还能在对话里预览。一开始我以为这就是一次图片 API 调用,把代码完整梳理了一遍才发现,实际链路要长得多——四个进程接力,模型自己并不直接生图。这里做个记录。
需求
需求本身不复杂:
- 在 OpenCode 对话里,用户说一句话就能生图;
- 生成的图片要保存到本地工作区,可管理、可复用;
- 对话里能直接看到图。
真正绕的地方在于:OpenCode 是一个通用 AI 客户端,生图能力不在它自己身上,而在 Codex(OpenAI 的官方 CLI)里。怎么把两边安全地接起来,才是主要工作。
一开始的误解
刚看这套代码时,我以为链路是:OpenCode → 某个生图 API → 返回图片,一次请求结束。
实际上完全不是。真实链路是四个进程接力:
OpenCode 客户端
│ 标准插件协议(MCP),通过子进程 stdin/stdout 通信
▼
codex-image-mcp(插件入口,只做校验和转发)
│ 本机 Unix socket(私有协议,文件权限 0600)
▼
codex-image-gateway(核心中转:防重复、状态机、安全校验、落盘)
│ Codex 官方协议
▼
codex app-server(官方 Codex CLI 的后台模式)
│ 让 Codex agent 调用内置工具 image_gen.imagegen
▼
OpenAI 图片模型 → 生成 PNG

搞清楚这张图,后面的一切都好理解了:模型不直接生图,真正生图的是 Codex 里那个 agent 的内置工具 image_gen.imagegen。我们做的,是把 OpenCode 里发起的请求一路安全地送到这个工具手上,再把图片一路安全地拿回来。
完整流程
整体走一遍:
- OpenCode 的配置文件里注册了一个本地插件
narrative-image,启动时拉起codex-image-mcp子进程。同时配了一份 skill 说明书,告诉模型什么时候、怎么调用这个工具。 - 用户说”生成一张 xxx 的图”,模型调用工具
narrative.image.generate,必填提示词、运行编号、防重复键,还要显式确认dataEgressConfirmed(同意把提示词发到外部服务)。 - MCP 进程校验参数,生成请求 ID,通过本机 socket 发给 gateway。
- gateway 用”防重复键 + 请求内容指纹”去重,同一个请求不会重复生图;请求状态写入日志文件,崩溃重启后也能恢复。
- gateway 启动时先做一次能力检查:Codex 登录了吗?账号支持生图吗?任何一项不满足就直接拒绝。
- gateway 开一个 Codex 会话(只读沙箱、不询问确认),下达指令”用 image_gen.imagegen 生成一次图片,内容是:<提示词>“,默认最多等 180 秒,超时主动中断。
- 拿到图片后做安全检查:必须是 PNG、不超过 25MB、和 Codex 落盘的文件逐字节一致,然后原子写入工作区的
images/目录,登记进文件清单project.json。 - MCP 把结果分两部分返回:结构化的文字信息(图片 ID、相对路径、哈希值)+ 一份临时的小图预览(不超过 5MB)。
- OpenCode 把图片转成对话附件,显示在工具卡片里。

为什么拆这么多进程
看完代码我的理解是:每一层只干一件事,边界清晰,出问题好排查,测试时也好替换。
codex-image-mcp:OpenCode 唯一能看到的面。把生图包装成标准 MCP 工具,本身不生图。入口在narrative/tools/codex-image-mcp/src/server.ts。codex-image-gateway:核心中转。防重复、状态机、驱动 Codex、图片安全落盘都在这。代码在narrative/packages/codex-image-gateway/。codex-app-server-adapter:负责启动 codex 进程、收发消息,协议类型由官方 Codex CLI 0.144.5 自动生成。代码在narrative/packages/codex-app-server-adapter/src/client.ts。codex app-server:官方 Codex 进程,凭证和登录态都在它手里,我们的代码完全不碰。
关键代码
挑三段最能说明问题的。
第一段是能力检查,gateway 启动时先问 Codex”登录了吗、账号能生图吗”,不行就直接拒绝服务(capability-gate.ts:4-16):
const account = await client.request("account/read", { refreshToken: false })
if (!account.account || typeof account.account !== "object") {
throw new CodexRuntimeError("CODEX_AUTH_REQUIRED", "Codex sign-in is required")
}
const capabilities = await client.request("modelProvider/capabilities/read", {})
if (capabilities.namespaceTools !== true || capabilities.imageGeneration !== true) {
throw new CodexRuntimeError(
"CODEX_IMAGE_CAPABILITY_UNAVAILABLE",
"The current Codex account cannot generate images",
)
}
第二段是发起生图任务,把请求翻译成 Codex 的一次 Turn,超时 180 秒主动中断(app-server-backend.ts:122-140):
const turn = await this.client.request("turn/start", {
threadId,
model: this.model,
input: [{ type: "text", text: imageInstruction(request), text_elements: [] }],
})
timeout = setTimeout(() => {
void this.client.request("turn/interrupt", { threadId, turnId }).catch(() => undefined)
fail(new CodexRuntimeError("CODEX_TIMEOUT", "Codex image generation timed out"))
}, this.options.generationTimeoutMs ?? 180_000)
第三段是图片落盘,收到图之后的一连串检查(artifact-importer.ts:30-50):
const inline = decodeBase64(item.result)
const bytes = item.savedPath ? await this.readGeneratedImage(item.savedPath) : inline
if (item.savedPath && !bytes.equals(inline)) {
throw new CodexRuntimeError("ASSET_IMPORT_FAILED", "Codex saved image does not match the typed image result")
}
requirePng(bytes) // 必须是 PNG
const sha256 = createHash("sha256").update(bytes).digest("hex")
const artifactId = `image_${sha256.slice(0, 24)}` // 用内容哈希当图片 ID
await writeAtomic(temporary, target, bytes) // 先写临时文件再改名

设计上值得记录的几点
凭证不过手。 登录态完全由官方 codex 进程自己管理(读 ~/.codex),我们的代码只负责启动它,不读、不复制、不记录任何凭证。这一开始就定死了,后面省掉很多麻烦。
防重复是硬约束。 靠”防重复键 + 内容指纹”去重;进程崩溃重启后,未完成的请求标记为”待人工核对”,绝不自动重复提交。生图是要花钱的,重复提交的代价比留着不处理大得多。
隐私边界分两层。 写给 AI 模型和日志的,只有 ID、相对路径、哈希这类安全信息;图片本体只作为临时预览返回,不进任何日志和清单文件。返回前还有一次独立的二次校验。
超时层层设防。 普通请求 30 秒、生图任务 180 秒、socket 调用 190 秒、插件侧建议 300 秒,超时逐级主动取消,不会出现”谁在等谁”搞不清楚的情况。
经验
- 两个系统对接的需求,先把完整链路图画出来再谈代码。一开始没画图时,我以为瓶颈在 API 调用,画完图才发现真正的工作量全在中转层的校验和状态管理上。
- 进程拆得细不是过度设计。每一层只做一件事,测试时就能用假 gateway、假模型逐层替换,这套代码的端到端测试就是这么跑的。
- 凡是要出本机的数据,显式确认 + 最小化返回,比事后补救便宜得多。