diff --git a/docs/src/content/docs/en/configuration/routing.md b/docs/src/content/docs/en/configuration/routing.md
index 607570d..30c29e7 100644
--- a/docs/src/content/docs/en/configuration/routing.md
+++ b/docs/src/content/docs/en/configuration/routing.md
@@ -7,15 +7,69 @@ lead: Choose the model for a request, then automatically retry or switch to fall
## How Routing Works
-CCR first decides which model the request should use, then forwards the request upstream. The current implementation follows this order:
+CCR first decides which model the request should use, then forwards the request upstream. The current implementation has two layers:
-1. If the incoming `model` is already a known `provider/model` selector, CCR uses it directly.
-2. If a custom router script is configured, the model returned by that script takes priority over UI routing rules.
-3. Routing rules are evaluated from top to bottom. The first matching rule applies its request rewrite.
-4. If no rule matches, CCR uses the default route. If no default is configured, it keeps the original request model.
+1. **Request preprocessing**: when the built-in Claude Code route matches, CCR processes the Claude Code Agent / Task / Workflow tool descriptions, removes the first billing system text block from Claude Code subagent requests, and extracts any `...` model tag.
+2. **Model decision**: if a custom router script returns a model, that model wins. Otherwise CCR tries, in order, a Claude Code subagent tag, a known `provider/model` inline selector, built-in agent routes, UI routing rules, and finally the default route.
The core shape of a rule is **Condition + Request action**. The condition decides whether the rule matches; the request action changes request fields. The most common action is setting `request.body.model` to a provider model or Fusion model.
+## Built-In Claude Code Routing
+
+The Routing page shows built-in **Claude Code** and **Codex** routes. Built-in routes are not normal routing rules: they cannot be moved, edited, or deleted. You can only enable or disable them. The info icon next to the name explains what each built-in route does.
+
+The built-in Claude Code route detects requests from Claude Code and routes main requests to the Claude Code Agent Config model or the default route model:
+
+| Item | Behavior |
+| --- | --- |
+| Match condition | Request header `user-agent` contains `claude` |
+| Required setup | An enabled Claude Code config exists in **Agent Config** |
+| Target model | The Claude Code config model first; if unset, the Routing page default model |
+| Request action | Set `request.body.model` to the target model |
+| Log reason | Main requests usually show `builtin:claude-code` |
+
+This built-in route handles the default model for Claude Code **main requests**. Claude Code Subagent, Task, and Workflow-created agents can still choose different models through the tag mechanism below.
+
+## Claude Code Subagent / Workflow Auto-Routing
+
+Claude Code Agent / Task / Workflow can spawn additional model requests. CCR uses tag injection to let those spawned requests choose a more appropriate CCR model:
+
+```text
+provider/model
+```
+
+The full flow is:
+
+1. A Claude Code main request matches the built-in route, so CCR inspects the current tool list.
+2. If at least one model has a **Description**, CCR injects the available models and descriptions into the `Agent` / `Task` tool description and `prompt` field description.
+3. If the tool list includes `Workflow`, CCR appends a Workflow-specific instruction: whenever the workflow creates an `Agent` / `Task`, each spawned agent prompt must start with the same model tag.
+4. When Claude Code calls `Agent` / `Task`, or when a Workflow creates an agent, the prompt starts with `provider/model`.
+5. When the spawned request reaches CCR, CCR extracts and removes the tag from the system prompt or the first two user messages, then routes that request to the tagged model.
+
+Subagent / Workflow auto-routing therefore does not use headers such as `x-claude-code-agent-id` as the model selector. Those headers can help with observation, but the actual model selection comes from the prompt tag.
+
+### Pairing It With The Models Page
+
+The **Description** field on the Models page is both the enablement switch and the selection guide for this mechanism. If no model has a Description, CCR does not inject Agent / Task / Workflow routing instructions, so it does not write an empty model list into tool descriptions.
+
+Recommended setup:
+
+1. Add usable models under **Providers**, and verify that the model IDs can be requested.
+2. Open **Models** and fill Description for the models you want Subagents to choose automatically. Describe task fit, speed, cost, and limits.
+3. Enable a Claude Code config under **Agent Config**, and choose the main model. This model handles the main Claude Code conversation.
+4. Confirm that the built-in **Claude Code** route is enabled on the **Routing** page.
+5. Use Agent, Task, or Workflow in Claude Code. When Claude Code spawns an agent, it can choose a CCR model from the descriptions and write the tag.
+
+Write descriptions around tasks instead of only naming the provider. For example:
+
+| Model purpose | Description example |
+| --- | --- |
+| Fast low-cost model | Good for code search, file triage, summaries, small edits, and low-cost parallel Subagents. |
+| Strong reasoning model | Good for complex architecture analysis, large refactor planning, cross-file reasoning, and high-risk code review. |
+| Long-context model | Good for reading large logs, long documents, repository-scale context gathering, and Workflow summaries. |
+
+After saving, CCR formats those descriptions as “Configured CCR gateway models” in the injected Claude Code instructions. When Claude Code picks a model, request logs should show `builtin:claude-code-subagent`, and the tagged model becomes the final `resolved model`.
+
## What Fallback Does
Fallback is the failure strategy after a model or upstream request fails. It does not pick the first model; it decides whether CCR should keep trying after the current target fails.
diff --git a/docs/src/content/docs/zh/configuration/routing.md b/docs/src/content/docs/zh/configuration/routing.md
index 9718356..eeb1e3c 100644
--- a/docs/src/content/docs/zh/configuration/routing.md
+++ b/docs/src/content/docs/zh/configuration/routing.md
@@ -7,15 +7,69 @@ lead: 设置请求如何选择模型,并在失败时通过 Fallback 自动重
## 路由如何生效
-CCR 的路由会先决定本次请求使用哪个模型,然后再把请求交给上游。当前代码中的主要顺序是:
+CCR 的路由会先决定本次请求使用哪个模型,然后再把请求交给上游。当前实现可以分成两层:
-1. 如果请求里的 `model` 已经是已知的 `供应商/模型` 形式,CCR 会直接使用这个模型。
-2. 如果配置了自定义路由脚本,脚本返回的模型优先级高于界面里的路由规则。
-3. 路由规则按列表顺序匹配,第一条命中的规则会执行对应的请求改写。
-4. 没有规则命中时,使用默认路由;如果没有默认路由,就保留请求原本的模型。
+1. **请求预处理**:Claude Code 内置路由命中时,CCR 会先处理 Claude Code 的 Agent / Task / Workflow 工具说明、移除 Claude Code subagent 请求里第一条 billing system 文本块,并提取 `...` 模型标签。
+2. **模型决策**:如果配置了自定义路由脚本且脚本返回模型,这个模型优先。否则 CCR 会按顺序尝试 Claude Code subagent 标签、已知的 `供应商/模型` 直连模型、内置 Agent 路由、界面路由规则和默认路由。
路由规则的核心是 **条件 + 请求动作**。条件判断请求是否命中,请求动作修改请求字段。最常用的动作是把 `request.body.model` 设置为目标模型或 Fusion 模型。
+## Claude Code 内置路由
+
+路由页面会显示内置的 **Claude Code** 和 **Codex** 路由。内置路由不是普通规则,不能上移、下移、编辑或删除,只能启用或关闭。名称旁的信息图标会说明该内置路由的功能。
+
+Claude Code 内置路由的作用是识别 Claude Code 发来的请求,并把主请求路由到 Claude Code Agent 配置或默认路由中的模型:
+
+| 项目 | 行为 |
+| --- | --- |
+| 命中条件 | 请求 Header 的 `user-agent` 包含 `claude` |
+| 启用前提 | **Agent配置** 中存在已启用的 Claude Code 配置 |
+| 目标模型 | 优先使用 Claude Code 配置里的模型;如果未设置,使用路由页的默认模型 |
+| 请求动作 | 设置 `request.body.model` 为目标模型 |
+| 日志原因 | 普通主请求通常显示为 `builtin:claude-code` |
+
+这个内置路由解决的是 Claude Code **主请求** 的默认模型选择。Claude Code 创建的 Subagent、Task 或 Workflow 内部 Agent 可以继续用下面的标签机制自动选择不同模型。
+
+## Claude Code Subagent / Workflow 自动路由
+
+Claude Code 的 Agent / Task / Workflow 可以派生新的模型请求。CCR 使用标签注入来让这些派生请求选择更合适的 CCR 模型:
+
+```text
+供应商/模型
+```
+
+完整流程如下:
+
+1. Claude Code 主请求命中内置路由后,CCR 会检查当前工具列表。
+2. 如果至少有一个模型配置了 **Description**,CCR 会把可用模型及其说明注入到 `Agent` / `Task` 工具说明和 `prompt` 字段说明里。
+3. 如果工具列表里有 `Workflow`,CCR 会给 Workflow 工具说明追加要求:workflow 内部创建 `Agent` / `Task` 时,每个派生 Agent 的 prompt 第一行都要带同样的模型标签。
+4. Claude Code 调用 `Agent` / `Task`,或 Workflow 内部创建 Agent 时,prompt 第一行会携带 `供应商/模型`。
+5. 派生请求进入 CCR 后,CCR 从 system 或前两条 user message 中提取并删除这个标签,然后把该请求路由到标签里的模型。
+
+因此,Subagent / Workflow 的自动路由不是靠 `x-claude-code-agent-id` 之类的 Header 决定模型,而是靠 prompt 标签。Header 只能作为观测线索,真正的模型选择来自标签。
+
+### 与模型页配合
+
+模型页里的 **Description** 是这套机制的开关和选择依据。没有任何模型 Description 时,CCR 不会注入 Agent / Task / Workflow 路由提示词,避免把空模型列表写进工具说明。
+
+推荐配置步骤:
+
+1. 在 **供应商** 中添加可用模型,确认模型 ID 可以真实请求。
+2. 打开 **模型** 页面,为希望 Subagent 自动选择的模型填写 Description。说明要写清模型适合的任务、速度、成本和限制。
+3. 在 **Agent配置** 中启用 Claude Code 配置,并设置主模型。这个模型负责 Claude Code 主会话。
+4. 在 **路由** 页面确认 **Claude Code** 内置路由已启用。
+5. 在 Claude Code 中使用 Agent、Task 或 Workflow。需要派生 Agent 时,Claude Code 会根据模型 Description 选择一个 CCR 模型并写入标签。
+
+Description 建议写成任务导向,而不是只写模型厂商名。例如:
+
+| 模型用途 | Description 示例 |
+| --- | --- |
+| 快速便宜模型 | 适合代码搜索、文件梳理、摘要、简单修改和低成本并行 Subagent。 |
+| 强推理模型 | 适合复杂架构分析、大规模重构计划、跨文件推理和高风险代码审查。 |
+| 长上下文模型 | 适合读取大量日志、长文档、仓库级上下文整理和 Workflow 汇总。 |
+
+保存后,CCR 会把这些 Description 组织成 “Configured CCR gateway models” 注入给 Claude Code。Claude Code 选择模型后,CCR 会在派生请求上看到 `builtin:claude-code-subagent`,并把标签里的模型作为最终 `resolved model`。
+
## Fallback 是什么
Fallback 是请求失败后的降级策略。它不负责第一次选模型,而是在当前模型或上游失败时决定是否继续尝试。
diff --git a/src/main/codex-app-launch.ts b/src/main/codex-app-launch.ts
index e75a8db..2a70b69 100644
--- a/src/main/codex-app-launch.ts
+++ b/src/main/codex-app-launch.ts
@@ -41,6 +41,12 @@ export type CodexAppLaunchResult = {
userDataDir: string;
};
+export type CodexCompatibleAppModelCatalogWriteResult = {
+ changed: boolean;
+ file: string;
+ userDataDir: string;
+};
+
const codexAppSpec: CodexCompatibleAppSpec = {
bundledCliNames: ["codex", "Codex", "OpenAI Codex"],
defaultCliCommand: "codex",
@@ -137,18 +143,36 @@ export function refreshCodexCompatibleAppProfileFiles(
configDir: string,
profile: ProfileConfig,
config?: AppConfig
-): { modelCatalogFile: string; userDataDir: string } {
+): { modelCatalogChanged: boolean; modelCatalogFile: string; userDataDir: string } {
const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec;
- const configFile = resolveCodexConfigFile(configDir, profile);
if (spec.kind === "zcode" && config?.APIKEY) {
writeZcodeGatewayConfig(config, profile, config.APIKEY, { backup: false });
}
+ const modelCatalog = writeCodexCompatibleAppModelCatalog(configDir, profile, config);
+ return {
+ modelCatalogChanged: modelCatalog.changed,
+ modelCatalogFile: modelCatalog.file,
+ userDataDir: modelCatalog.userDataDir
+ };
+}
+
+export function writeCodexCompatibleAppModelCatalog(
+ configDir: string,
+ profile: ProfileConfig,
+ config?: AppConfig
+): CodexCompatibleAppModelCatalogWriteResult {
+ const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec;
+ const configFile = resolveCodexConfigFile(configDir, profile);
const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile);
const userDataDir = codexElectronUserDataDir(codexHome, profile, spec);
mkdirSync(userDataDir, { recursive: true });
- const modelCatalogFile = codexAppModelCatalogFile(userDataDir, spec);
- writeFileSync(modelCatalogFile, codexModelCatalogJson(config, profile.model), "utf8");
- return { modelCatalogFile, userDataDir };
+ const file = codexAppModelCatalogFile(userDataDir, spec);
+ const content = codexModelCatalogJson(config, profile.model);
+ const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
+ if (previous !== content) {
+ writeFileSync(file, content, "utf8");
+ }
+ return { changed: previous !== content, file, userDataDir };
}
function launchCodexCompatibleAppProfile(
diff --git a/src/main/codex-model-catalog.ts b/src/main/codex-model-catalog.ts
index fc2185e..1c87b84 100644
--- a/src/main/codex-model-catalog.ts
+++ b/src/main/codex-model-catalog.ts
@@ -46,13 +46,13 @@ export type CodexModelCatalogItem = {
web_search_tool_type: string;
};
-export function buildCodexModelCatalog(config?: Partial>, selectedModel?: string): CodexModelCatalog {
+export function buildCodexModelCatalog(config?: Partial>, selectedModel?: string): CodexModelCatalog {
return {
models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config))
};
}
-export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string[] {
+export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string[] {
const ids: string[] = [];
pushUniqueModel(ids, normalizeModelSelector(selectedModel));
@@ -98,11 +98,11 @@ export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string {
+export function codexModelCatalogJson(config?: Partial>, selectedModel?: string): string {
return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`;
}
-export function codexModelCatalogBase64(config?: Partial>, selectedModel?: string): string {
+export function codexModelCatalogBase64(config?: Partial>, selectedModel?: string): string {
const catalog = buildCodexModelCatalog(config, selectedModel);
return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64");
}
@@ -110,7 +110,7 @@ export function codexModelCatalogBase64(config?: Partial>
+ config?: Partial>
): CodexModelCatalogItem {
const profile = codexModelCapabilityProfile(model, config);
const contextWindow = codexModelContextWindow(model, profile.catalogEntry);
@@ -159,7 +159,7 @@ type CodexCapabilityProfile = {
function codexModelCapabilityProfile(
model: string,
- config?: Partial>
+ config?: Partial>
): CodexCapabilityProfile {
const selector = parseModelSelector(model);
const provider = selector?.provider ? findConfiguredProvider(config, selector.provider) : undefined;
@@ -171,7 +171,7 @@ function codexModelCapabilityProfile(
const supportsReasoning = readCatalogCapability(capabilities, "reasoning");
const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry);
const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling");
- const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry)
+ const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config)
? "freeform"
: null;
const supportsSearchTool =
@@ -275,6 +275,22 @@ function catalogModelLooksLikeGpt(model: string, entry: ModelCatalogEntry | unde
].some((value) => typeof value === "string" && value.toLowerCase().includes("gpt"));
}
+function codexPatchBridgeApplies(
+ model: string,
+ entry: ModelCatalogEntry | undefined,
+ config?: Partial>
+): boolean {
+ const codexRule = config?.Router?.builtInRules?.codex;
+ if (!codexRule || codexRule.enabled === false) {
+ return false;
+ }
+ return !catalogModelLooksLikeGpt(modelNameForPatchBridge(model), entry);
+}
+
+function modelNameForPatchBridge(model: string): string {
+ return parseModelSelector(model)?.model ?? model;
+}
+
function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined {
if (typeof value !== "string") {
return undefined;
diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts
index 32087bd..db256fc 100644
--- a/src/main/profile-service.ts
+++ b/src/main/profile-service.ts
@@ -5,6 +5,7 @@ import path from "node:path";
import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "../shared/app";
import { replacePersistedApiKeys } from "./api-key-store";
import { botGatewayProfileEnv } from "./bot-gateway-env";
+import { writeCodexCompatibleAppModelCatalog } from "./codex-app-launch";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { codexModelCatalogJson } from "./codex-model-catalog";
import { CONFIGDIR } from "./constants";
@@ -151,6 +152,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
const configFormat = normalizeCodexConfigFormat(profile.configFormat);
const modelCatalogFile = codexModelCatalogFile(configFile);
const modelCatalogResult = writeFileWithBackup(modelCatalogFile, codexModelCatalogJson(config, model));
+ const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, { ...profile, model }, config);
const showAllSessions = profile.agent === "zcode" ? false : Boolean(profile.showAllSessions);
const nextConfig = buildCodexConfigToml(source, {
baseUrl: endpoint,
@@ -178,9 +180,14 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
providerId
})
: undefined;
- const changed = writeResult.changed || modelCatalogResult.changed || Boolean(separateProfileResult?.changed) || Boolean(middlewareResult?.changed);
+ const changed = writeResult.changed ||
+ modelCatalogResult.changed ||
+ appModelCatalogResult.changed ||
+ Boolean(separateProfileResult?.changed) ||
+ Boolean(middlewareResult?.changed);
const extras = [
modelCatalogFile ? `catalog ${modelCatalogFile}` : "",
+ appModelCatalogResult.file ? `app catalog ${appModelCatalogResult.file}` : "",
separateProfileResult?.file ? `profile ${separateProfileResult.file}` : "",
middlewareResult?.file ? `middleware ${middlewareResult.file}` : ""
].filter(Boolean);
diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx
index 5298dbc..6fcdbf2 100644
--- a/src/renderer/pages/home/App.tsx
+++ b/src/renderer/pages/home/App.tsx
@@ -2891,12 +2891,18 @@ function App() {
moveRule: moveRoutingRule,
providers: draftConfig.Providers,
removeRule: setRoutingDeleteIndex,
- updateBuiltInRule: (agent, enabled) => updateConfig((config) => {
+ updateBuiltInRule: (agent, patch) => updateConfig((config) => {
config.Router.builtInRules = normalizeRouterBuiltInRules(config.Router.builtInRules);
if (agent === "claude-code") {
- config.Router.builtInRules["claude-code"] = { enabled };
+ config.Router.builtInRules["claude-code"] = {
+ ...config.Router.builtInRules["claude-code"],
+ ...patch
+ };
} else {
- config.Router.builtInRules.codex = { enabled };
+ config.Router.builtInRules.codex = {
+ ...config.Router.builtInRules.codex,
+ ...patch
+ };
}
return config;
}),
diff --git a/src/renderer/pages/home/components/routing.tsx b/src/renderer/pages/home/components/routing.tsx
index 18ceba4..1d702f8 100644
--- a/src/renderer/pages/home/components/routing.tsx
+++ b/src/renderer/pages/home/components/routing.tsx
@@ -6,6 +6,7 @@ import {
disclosureSpringTransition, Field, formatRouterRuleCondition, formatRouterRuleTarget, GatewayProviderConfig, Input,
Info, motion, normalizeRouterFallbackConfig, Pencil, Plus, Route, RouterFallbackConfig,
RouterBuiltInAgentRuleId, RouterFallbackMode, routerConditionSourceOptions, routerFallbackModeOptions, RouterRule, routerRewriteOperationOptions, routerRuleOperatorOptions,
+ RouterBuiltInAgentRuleConfig,
RouteTargetControl, routingRuleRowMatchesQuery, Search, SelectControl, Toggle, translateOptions,
Trash2, uniqueStrings, useAppText, useMemo, useState, X
} from "../shared";
@@ -27,7 +28,7 @@ export function RoutingView({
moveRule: (index: number, direction: -1 | 1) => void;
providers: GatewayProviderConfig[];
removeRule: (index: number) => void;
- updateBuiltInRule: (agent: RouterBuiltInAgentRuleId, enabled: boolean) => void;
+ updateBuiltInRule: (agent: RouterBuiltInAgentRuleId, patch: Partial) => void;
updateFallback: (fallback: RouterFallbackConfig) => void;
updateRule: (index: number, patch: Partial) => void;
}) {
@@ -133,7 +134,7 @@ export function RoutingView({
disabled={row.readonly || row.toggleDisabled}
onChange={(enabled) => {
if (row.builtInAgent) {
- updateBuiltInRule(row.builtInAgent, enabled);
+ updateBuiltInRule(row.builtInAgent, { enabled });
} else if (row.index !== undefined) {
updateRule(row.index, { enabled });
}
diff --git a/src/renderer/pages/home/shared/index.tsx b/src/renderer/pages/home/shared/index.tsx
index c3f2d04..bf40b7f 100644
--- a/src/renderer/pages/home/shared/index.tsx
+++ b/src/renderer/pages/home/shared/index.tsx
@@ -18,4 +18,4 @@ export * from "./routing";
export * from "./virtual-models";
export * from "./extensions";
export * from "./providers";
-export type { RouterBuiltInAgentRuleId, RouterBuiltInRulesConfig } from "../../../../shared/app";
+export type { RouterBuiltInAgentRuleConfig, RouterBuiltInAgentRuleId, RouterBuiltInRulesConfig } from "../../../../shared/app";
diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts
index b2026ee..8496526 100644
--- a/src/server/gateway/service.ts
+++ b/src/server/gateway/service.ts
@@ -3,7 +3,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createRequire } from "node:module";
import { networkInterfaces } from "node:os";
-import { Readable } from "node:stream";
+import { Readable, Transform } from "node:stream";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "node:path";
import type {
@@ -191,6 +191,7 @@ const clientClosedRequestStatusCode = 499;
const clientDisconnectMessage = "Client connection closed before response completed.";
const localObservabilityHeaderNames = new Set([
"x-ccr-claude-app-model-rewrite",
+ "x-ccr-codex-patch-bridge",
"x-ccr-claude-model-discovery",
"x-ccr-cursor-openai-compat",
"x-ccr-logical-provider",
@@ -205,10 +206,42 @@ const pendingRawTraceMaxAgeMs = 5 * 60 * 1000;
const apiKeyLimitCounterRetentionWindows = 2;
const gatewayRuntimeMarkerFile = "gateway-runtime.json";
const rawTraceSyncHeader = "x-ccr-raw-trace-token";
+const virtualApplyPatchToolName = "virtual_apply_patch";
let warnedMissingCursorOpenAICompatContext = false;
const rawTraceSyncPath = "/__ccr/raw-trace-sync";
const gatewayEntryOverrideEnv = "CCR_GATEWAY_ENTRY";
const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"];
+const codexPatchBridgeInstructionText = [
+ "When modifying files, call virtual_apply_patch.",
+ "Do not use exec_command or write_stdin to edit files, including shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar shell-based edits.",
+ "Use exec_command only for reading files, listing/searching, running builds/tests, starting servers, and other commands that are not manual file edits."
+].join(" ");
+const codexPatchBridgeShellToolGuidance = [
+ "When virtual_apply_patch is available, do not use this tool to edit files.",
+ "Do not write files with shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar commands.",
+ "Use virtual_apply_patch for manual file changes."
+].join(" ");
+const virtualApplyPatchLarkGrammar = [
+ "start: begin_patch hunk+ end_patch",
+ "begin_patch: \"*** Begin Patch\" LF",
+ "end_patch: \"*** End Patch\" LF?",
+ "",
+ "hunk: add_hunk | delete_hunk | update_hunk",
+ "add_hunk: \"*** Add File: \" filename LF add_line+",
+ "delete_hunk: \"*** Delete File: \" filename LF",
+ "update_hunk: \"*** Update File: \" filename LF change_move? change?",
+ "",
+ "filename: /(.+)/",
+ "add_line: \"+\" /(.*)/ LF -> line",
+ "",
+ "change_move: \"*** Move to: \" filename LF",
+ "change: (change_context | change_line)+ eof_line?",
+ "change_context: (\"@@\" | \"@@ \" /(.+)/) LF",
+ "change_line: (\"+\" | \"-\" | \" \") /(.*)/ LF",
+ "eof_line: \"*** End of File\" LF",
+ "",
+ "%import common.LF"
+].join("\n");
const apiKeyLimitCounters = new Map();
const providerCredentialCooldowns = new Map();
const providerCredentialCooldownMs = 60_000;
@@ -549,6 +582,7 @@ class GatewayService {
let bodyToForward: Buffer | undefined = cursorCompatPreparation?.body ?? requestBody;
let routeFallback = this.config.Router.fallback;
let routedModel: string | undefined;
+ let codexApplyPatchBridgeActive = false;
const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward);
if (claudeModelRewrite) {
headers["x-ccr-claude-model-discovery"] = claudeModelRewrite.diagnostic;
@@ -659,6 +693,39 @@ class GatewayService {
}
bodyToForward = serialized;
}
+ if (method === "POST" && requestProtocolForPath(path) === "openai_responses" && isCodexUserAgent(request.headers)) {
+ const body = parseJsonObject(bodyToForward ?? requestBody);
+ const routed = await this.plugin.routeRequest({
+ body,
+ headers: headers as Record,
+ method,
+ url: request.url ?? path
+ });
+ const serialized = Buffer.from(`${JSON.stringify(routed.body)}\n`, "utf8");
+ headers["content-type"] = "application/json";
+ headers["x-ccr-route-reason"] = routed.decision.reason;
+ routeFallback = routed.decision.fallback ?? routeFallback;
+ if (routed.decision.model) {
+ headers["x-ccr-routed-model"] = routed.decision.model;
+ routedModel = routed.decision.model;
+ }
+ bodyToForward = serialized;
+ }
+
+ const codexApplyPatchBridgeRequest = prepareCodexApplyPatchBridgeRequest({
+ body: bodyToForward,
+ config: this.config,
+ headers: request.headers,
+ method,
+ path,
+ routedModel
+ });
+ if (codexApplyPatchBridgeRequest) {
+ bodyToForward = codexApplyPatchBridgeRequest.body;
+ codexApplyPatchBridgeActive = true;
+ headers["x-ccr-codex-patch-bridge"] = codexApplyPatchBridgeRequest.diagnostic;
+ headers["content-type"] = "application/json";
+ }
const providerCapabilityRouting = applyProviderCapabilityRouting({
body: bodyToForward,
@@ -725,6 +792,9 @@ class GatewayService {
this.config
);
const upstreamResponse = upstreamResult.response;
+ if (codexApplyPatchBridgeActive) {
+ responseHeaders.delete("content-length");
+ }
recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders);
response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders)));
if (!upstreamResponse.body) {
@@ -749,7 +819,9 @@ class GatewayService {
}
const upstreamBody = Readable.fromWeb(upstreamResponse.body as unknown as import("node:stream/web").ReadableStream);
- const responseBody = upstreamBody;
+ const responseBody = codexApplyPatchBridgeActive
+ ? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders)
+ : upstreamBody;
const sampler = createBodySampler();
const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined);
let streamDetectedError: string | undefined;
@@ -1813,6 +1885,433 @@ function applyProviderCapabilityRouting(input: {
};
}
+export function prepareCodexApplyPatchBridgeRequest(input: {
+ body?: Buffer;
+ config: AppConfig;
+ headers: IncomingHttpHeaders;
+ method: string;
+ path: string;
+ routedModel?: string;
+}): { body: Buffer; diagnostic: string } | undefined {
+ if (!codexApplyPatchBridgeEnabled(input.config, input.headers, input.method, input.path)) {
+ return undefined;
+ }
+ const parsedBody = parseJsonObjectSafe(input.body);
+ if (!parsedBody) {
+ return undefined;
+ }
+ const model = input.routedModel || stringValue(parsedBody.model);
+ if (!codexPatchBridgeModelEligible(model)) {
+ return undefined;
+ }
+ const transformed = transformCodexApplyPatchBridgeRequestBody(parsedBody);
+ if (!transformed.changed) {
+ return undefined;
+ }
+ return {
+ body: Buffer.from(`${JSON.stringify(transformed.body)}\n`, "utf8"),
+ diagnostic: `${model ?? "unknown"}:${transformed.changedParts.join(",")}`
+ };
+}
+
+export function transformCodexApplyPatchBridgeRequestBody(body: Record): {
+ body: Record;
+ changed: boolean;
+ changedParts: string[];
+} {
+ const next = { ...body };
+ const changedParts: string[] = [];
+ const tools = transformCodexApplyPatchBridgeTools(body.tools);
+ if (tools.changed) {
+ next.tools = tools.value;
+ changedParts.push("tools");
+ const instructions = transformCodexApplyPatchBridgeInstructions(body.instructions);
+ if (instructions.changed) {
+ next.instructions = instructions.value;
+ changedParts.push("instructions");
+ }
+ const input = transformCodexApplyPatchBridgeInput(body.input);
+ if (input.changed) {
+ next.input = input.value;
+ changedParts.push("input");
+ }
+ }
+ return {
+ body: next,
+ changed: changedParts.length > 0,
+ changedParts
+ };
+}
+
+function transformCodexApplyPatchBridgeTools(value: unknown): { value: unknown; changed: boolean } {
+ if (!Array.isArray(value)) {
+ return { value, changed: false };
+ }
+ const hasApplyPatchTool = value.some((tool) => isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch");
+ if (!hasApplyPatchTool) {
+ return { value, changed: false };
+ }
+ let changed = false;
+ const tools = value.map((tool) => {
+ if (isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch") {
+ changed = true;
+ return virtualApplyPatchToolSpec();
+ }
+ const shellTool = transformCodexPatchBridgeShellTool(tool);
+ if (shellTool.changed) {
+ changed = true;
+ return shellTool.value;
+ }
+ return tool;
+ });
+ return { value: tools, changed };
+}
+
+function transformCodexApplyPatchBridgeInstructions(value: unknown): { value: unknown; changed: boolean } {
+ const text = rawStringValue(value);
+ if (text === undefined) {
+ return value === undefined
+ ? { value: codexPatchBridgeInstructionText, changed: true }
+ : { value, changed: false };
+ }
+ if (text.includes(codexPatchBridgeInstructionText)) {
+ return { value, changed: false };
+ }
+ return {
+ value: `${text.trimEnd()}\n\n${codexPatchBridgeInstructionText}`,
+ changed: true
+ };
+}
+
+function transformCodexPatchBridgeShellTool(value: unknown): { value: unknown; changed: boolean } {
+ if (!isRecord(value) || value.type !== "function") {
+ return { value, changed: false };
+ }
+ const name = stringValue(value.name);
+ if (name !== "exec_command" && name !== "write_stdin") {
+ return { value, changed: false };
+ }
+ let changed = false;
+ const next: Record = { ...value };
+ const description = rawStringValue(value.description) ?? "";
+ if (!description.includes(codexPatchBridgeShellToolGuidance)) {
+ next.description = description
+ ? `${description} ${codexPatchBridgeShellToolGuidance}`
+ : codexPatchBridgeShellToolGuidance;
+ changed = true;
+ }
+ if (name === "exec_command") {
+ const parameters = transformCodexPatchBridgeExecCommandParameters(value.parameters);
+ if (parameters.changed) {
+ next.parameters = parameters.value;
+ changed = true;
+ }
+ }
+ return { value: changed ? next : value, changed };
+}
+
+function transformCodexPatchBridgeExecCommandParameters(value: unknown): { value: unknown; changed: boolean } {
+ if (!isRecord(value) || !isRecord(value.properties) || !isRecord(value.properties.cmd)) {
+ return { value, changed: false };
+ }
+ const cmd = value.properties.cmd;
+ const description = rawStringValue(cmd.description) ?? "";
+ if (description.includes(codexPatchBridgeShellToolGuidance)) {
+ return { value, changed: false };
+ }
+ return {
+ value: {
+ ...value,
+ properties: {
+ ...value.properties,
+ cmd: {
+ ...cmd,
+ description: description
+ ? `${description} ${codexPatchBridgeShellToolGuidance}`
+ : codexPatchBridgeShellToolGuidance
+ }
+ }
+ },
+ changed: true
+ };
+}
+
+function transformCodexApplyPatchBridgeInput(value: unknown): { value: unknown; changed: boolean } {
+ if (!Array.isArray(value)) {
+ return { value, changed: false };
+ }
+ const applyPatchCallIds = new Set();
+ for (const item of value) {
+ if (isRecord(item) && item.type === "custom_tool_call" && item.name === "apply_patch") {
+ const callId = stringValue(item.call_id);
+ if (callId) {
+ applyPatchCallIds.add(callId);
+ }
+ }
+ }
+ let changed = false;
+ const items = value.map((item) => {
+ const transformed = transformCodexApplyPatchBridgeInputItem(item, applyPatchCallIds);
+ changed ||= transformed.changed;
+ return transformed.value;
+ });
+ return { value: items, changed };
+}
+
+function transformCodexApplyPatchBridgeInputItem(value: unknown, applyPatchCallIds: Set): { value: unknown; changed: boolean } {
+ if (!isRecord(value)) {
+ return { value, changed: false };
+ }
+ if (value.type === "custom_tool_call" && value.name === "apply_patch") {
+ const { input: patchInput, name: _name, type: _type, ...rest } = value;
+ return {
+ value: {
+ ...rest,
+ type: "function_call",
+ name: virtualApplyPatchToolName,
+ arguments: JSON.stringify({ patch: rawStringValue(patchInput) ?? "" })
+ },
+ changed: true
+ };
+ }
+ if (
+ value.type === "custom_tool_call_output" &&
+ (applyPatchCallIds.has(stringValue(value.call_id) ?? "") || value.name === "apply_patch")
+ ) {
+ const { name: _name, type: _type, ...rest } = value;
+ return {
+ value: {
+ ...rest,
+ type: "function_call_output"
+ },
+ changed: true
+ };
+ }
+ return { value, changed: false };
+}
+
+function virtualApplyPatchToolSpec(): Record {
+ return {
+ type: "function",
+ name: virtualApplyPatchToolName,
+ description: [
+ "Edit files by returning exactly one complete apply_patch patch.",
+ "The patch field must be raw patch grammar text starting with *** Begin Patch and ending with *** End Patch.",
+ "Do not wrap the patch in JSON, markdown fences, shell commands, cat, sed, perl, or python.",
+ "The patch field must match this Lark grammar:",
+ virtualApplyPatchLarkGrammar
+ ].join("\n\n"),
+ strict: true,
+ parameters: {
+ type: "object",
+ additionalProperties: false,
+ required: ["patch"],
+ properties: {
+ patch: {
+ type: "string",
+ description: [
+ "Raw apply_patch grammar text matching this Lark grammar:",
+ virtualApplyPatchLarkGrammar
+ ].join("\n\n")
+ }
+ }
+ }
+ };
+}
+
+function codexApplyPatchBridgeEnabled(config: AppConfig, headers: IncomingHttpHeaders, method: string, path: string): boolean {
+ const codexRule = config.Router.builtInRules?.codex;
+ return (method || "GET").toUpperCase() === "POST" &&
+ requestProtocolForPath(path) === "openai_responses" &&
+ isCodexUserAgent(headers) &&
+ codexRule?.enabled !== false;
+}
+
+function isCodexUserAgent(headers: IncomingHttpHeaders): boolean {
+ return readHeader(headers["user-agent"])?.toLowerCase().includes("codex") ?? false;
+}
+
+function codexPatchBridgeModelEligible(model: string | undefined): boolean {
+ const modelName = modelNameForPatchBridge(model);
+ return Boolean(modelName) && !modelName.toLowerCase().includes("gpt");
+}
+
+function modelNameForPatchBridge(model: string | undefined): string {
+ const normalized = normalizeRouteSelector(model) ?? "";
+ const slashIndex = normalized.lastIndexOf("/");
+ return slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
+}
+
+function codexApplyPatchBridgeResponseStream(input: Readable, headers: Headers): Readable {
+ const contentType = headers.get("content-type")?.toLowerCase() ?? "";
+ if (contentType.includes("text/event-stream")) {
+ return input.pipe(new Transform({
+ transform(chunk, _encoding, callback) {
+ transformSseChunk(this, chunk);
+ callback();
+ },
+ flush(callback) {
+ flushSseTransform(this);
+ callback();
+ }
+ }));
+ }
+ if (contentType.includes("application/json")) {
+ const chunks: Buffer[] = [];
+ return input.pipe(new Transform({
+ transform(chunk, _encoding, callback) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ callback();
+ },
+ flush(callback) {
+ const raw = Buffer.concat(chunks).toString("utf8");
+ try {
+ const parsed = JSON.parse(raw);
+ const transformed = transformCodexApplyPatchBridgeResponseValue(parsed);
+ this.push(Buffer.from(`${JSON.stringify(transformed.value)}\n`, "utf8"));
+ } catch {
+ this.push(Buffer.from(raw, "utf8"));
+ }
+ callback();
+ }
+ }));
+ }
+ return input;
+}
+
+export function transformCodexApplyPatchBridgeResponseValue(value: unknown): { value: unknown; changed: boolean } {
+ if (!isRecord(value)) {
+ return { value, changed: false };
+ }
+ let changed = false;
+ const next = { ...value };
+ if (isRecord(value.item)) {
+ const item = transformVirtualApplyPatchFunctionCall(value.item, value.type === "response.output_item.added");
+ if (item.changed) {
+ next.item = item.value;
+ changed = true;
+ }
+ }
+ if (Array.isArray(value.output)) {
+ const output = transformCodexApplyPatchBridgeResponseItems(value.output);
+ if (output.changed) {
+ next.output = output.value;
+ changed = true;
+ }
+ }
+ if (isRecord(value.response) && Array.isArray(value.response.output)) {
+ const output = transformCodexApplyPatchBridgeResponseItems(value.response.output);
+ if (output.changed) {
+ next.response = {
+ ...value.response,
+ output: output.value
+ };
+ changed = true;
+ }
+ }
+ const item = transformVirtualApplyPatchFunctionCall(next, false);
+ if (item.changed) {
+ return item;
+ }
+ return { value: next, changed };
+}
+
+function transformCodexApplyPatchBridgeResponseItems(items: unknown[]): { value: unknown[]; changed: boolean } {
+ let changed = false;
+ const value = items.map((item) => {
+ const transformed = isRecord(item)
+ ? transformVirtualApplyPatchFunctionCall(item, false)
+ : { value: item, changed: false };
+ changed ||= transformed.changed;
+ return transformed.value;
+ });
+ return { value, changed };
+}
+
+function transformVirtualApplyPatchFunctionCall(item: Record, allowEmptyInput: boolean): { value: unknown; changed: boolean } {
+ if (item.type !== "function_call" || item.name !== virtualApplyPatchToolName) {
+ return { value: item, changed: false };
+ }
+ const patch = patchInputFromVirtualApplyPatchArguments(item.arguments);
+ if (patch === undefined && !allowEmptyInput) {
+ return { value: item, changed: false };
+ }
+ const { arguments: _arguments, name: _name, type: _type, ...rest } = item;
+ return {
+ value: {
+ ...rest,
+ type: "custom_tool_call",
+ name: "apply_patch",
+ input: patch ?? ""
+ },
+ changed: true
+ };
+}
+
+function patchInputFromVirtualApplyPatchArguments(value: unknown): string | undefined {
+ if (isRecord(value)) {
+ return rawStringValue(value.patch);
+ }
+ const text = rawStringValue(value);
+ if (text === undefined) {
+ return undefined;
+ }
+ try {
+ const parsed = JSON.parse(text);
+ return isRecord(parsed) ? rawStringValue(parsed.patch) : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function transformSseChunk(stream: Transform, chunk: Buffer | string): void {
+ const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string };
+ state.__ccrCodexPatchBridgeSsePending = (state.__ccrCodexPatchBridgeSsePending ?? "") + chunk.toString();
+ while (state.__ccrCodexPatchBridgeSsePending) {
+ const match = /\r?\n\r?\n/.exec(state.__ccrCodexPatchBridgeSsePending);
+ if (!match || match.index === undefined) {
+ break;
+ }
+ const block = state.__ccrCodexPatchBridgeSsePending.slice(0, match.index);
+ const delimiter = match[0];
+ state.__ccrCodexPatchBridgeSsePending = state.__ccrCodexPatchBridgeSsePending.slice(match.index + delimiter.length);
+ stream.push(transformCodexApplyPatchBridgeSseEvent(block) + delimiter);
+ }
+}
+
+function flushSseTransform(stream: Transform): void {
+ const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string };
+ if (state.__ccrCodexPatchBridgeSsePending) {
+ stream.push(transformCodexApplyPatchBridgeSseEvent(state.__ccrCodexPatchBridgeSsePending));
+ state.__ccrCodexPatchBridgeSsePending = "";
+ }
+}
+
+export function transformCodexApplyPatchBridgeSseEvent(block: string): string {
+ const lines = block.split(/\r?\n/g);
+ const data = lines
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => line.slice(5).replace(/^ /, ""))
+ .join("\n");
+ if (!data || data === "[DONE]") {
+ return block;
+ }
+ try {
+ const parsed = JSON.parse(data);
+ const transformed = transformCodexApplyPatchBridgeResponseValue(parsed);
+ if (!transformed.changed) {
+ return block;
+ }
+ const event = stringValue((transformed.value as Record).type) || stringValue(parsed.type);
+ return [
+ event ? `event: ${event}` : undefined,
+ `data: ${JSON.stringify(transformed.value)}`
+ ].filter(Boolean).join("\n");
+ } catch {
+ return block;
+ }
+}
+
function requestProtocolForPath(path: string): GatewayProviderProtocol | undefined {
const normalized = path.toLowerCase();
if (normalized === "/v1/messages" || normalized === "/messages" || normalized.endsWith("/v1/messages")) {
@@ -3689,6 +4188,10 @@ function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
+function rawStringValue(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
function stringListValue(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) : [];
}
diff --git a/tests/main/codex-app-model-catalog.test.mjs b/tests/main/codex-app-model-catalog.test.mjs
new file mode 100644
index 0000000..fae302d
--- /dev/null
+++ b/tests/main/codex-app-model-catalog.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { writeCodexCompatibleAppModelCatalog } from "../../src/main/codex-app-launch.ts";
+
+test("Codex App model catalog write includes patch bridge capabilities", () => {
+ const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-app-catalog-"));
+ try {
+ const config = {
+ Providers: [
+ { name: "DeepSeek", type: "openai_chat_completions", models: ["deepseek-v4-flash"] }
+ ],
+ Router: {
+ builtInRules: {
+ "claude-code": { enabled: true },
+ codex: { enabled: true }
+ },
+ fallback: { mode: "off", models: [], retryCount: 1 },
+ rules: []
+ }
+ };
+ const profile = {
+ agent: "codex",
+ enabled: true,
+ id: "codex-main",
+ model: "DeepSeek/deepseek-v4-flash",
+ name: "Codex Main",
+ providerId: "openai-codex",
+ scope: "ccr",
+ surface: "app"
+ };
+
+ const result = writeCodexCompatibleAppModelCatalog(configDir, profile, config);
+ assert.equal(result.changed, true);
+ assert.equal(path.basename(result.file), "ccr-codex-model-catalog.json");
+ assert.equal(
+ result.userDataDir,
+ path.join(configDir, "profiles", "codex-main", "codex", ".claude-code-router", "codex-app-user-data", "codex-main")
+ );
+
+ const catalog = JSON.parse(readFileSync(result.file, "utf8"));
+ const model = catalog.models.find((item) => item.slug === "DeepSeek/deepseek-v4-flash");
+ assert.ok(model);
+ assert.equal(model.apply_patch_tool_type, "freeform");
+
+ const second = writeCodexCompatibleAppModelCatalog(configDir, profile, config);
+ assert.equal(second.changed, false);
+ assert.equal(second.file, result.file);
+ } finally {
+ rmSync(configDir, { force: true, recursive: true });
+ }
+});
diff --git a/tests/main/codex-model-catalog.test.mjs b/tests/main/codex-model-catalog.test.mjs
index 5585183..263c2b5 100644
--- a/tests/main/codex-model-catalog.test.mjs
+++ b/tests/main/codex-model-catalog.test.mjs
@@ -104,6 +104,42 @@ test("codex catalog keeps freeform apply_patch when provider advertises Response
assert.equal(model.apply_patch_tool_type, "freeform");
});
+test("codex catalog enables apply_patch bridge for non-GPT models when Codex built-in route enables it", () => {
+ const model = catalogModelFor({
+ Providers: [
+ { name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
+ ],
+ Router: {
+ builtInRules: {
+ "claude-code": { enabled: true },
+ codex: { enabled: true }
+ },
+ fallback: { mode: "off", models: [], retryCount: 1 },
+ rules: []
+ }
+ }, "openrouter/google/gemini-2.5-pro");
+
+ assert.equal(model.apply_patch_tool_type, "freeform");
+});
+
+test("codex catalog disables apply_patch bridge for non-GPT models when the Codex built-in route is off", () => {
+ const model = catalogModelFor({
+ Providers: [
+ { name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
+ ],
+ Router: {
+ builtInRules: {
+ "claude-code": { enabled: true },
+ codex: { enabled: false }
+ },
+ fallback: { mode: "off", models: [], retryCount: 1 },
+ rules: []
+ }
+ }, "openrouter/google/gemini-2.5-pro");
+
+ assert.equal(model.apply_patch_tool_type, null);
+});
+
test("codex catalog marks Fusion aliases with builtin web search as searchable", () => {
const model = catalogModelFor({
Providers: [],
diff --git a/tests/main/codex-patch-bridge.test.mjs b/tests/main/codex-patch-bridge.test.mjs
new file mode 100644
index 0000000..0b71eb7
--- /dev/null
+++ b/tests/main/codex-patch-bridge.test.mjs
@@ -0,0 +1,149 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ prepareCodexApplyPatchBridgeRequest,
+ transformCodexApplyPatchBridgeResponseValue,
+ transformCodexApplyPatchBridgeSseEvent
+} from "../../src/server/gateway/service.ts";
+
+const config = {
+ Providers: [],
+ Router: {
+ builtInRules: {
+ "claude-code": { enabled: true },
+ codex: { enabled: true }
+ },
+ fallback: { mode: "off", models: [], retryCount: 1 },
+ rules: []
+ }
+};
+
+test("Codex patch bridge rewrites apply_patch custom tool and prior output to virtual function items", () => {
+ const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
+ const result = prepareCodexApplyPatchBridgeRequest({
+ body: Buffer.from(JSON.stringify({
+ model: "openrouter/google/gemini-2.5-pro",
+ tools: [
+ { type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } }
+ ],
+ input: [
+ { type: "custom_tool_call", call_id: "call_patch", name: "apply_patch", input: patch },
+ { type: "custom_tool_call_output", call_id: "call_patch", output: "Success" }
+ ]
+ })),
+ config,
+ headers: { "user-agent": "codex-test" },
+ method: "POST",
+ path: "/v1/responses"
+ });
+
+ assert.ok(result);
+ const body = JSON.parse(result.body.toString("utf8"));
+ assert.equal(body.tools[0].type, "function");
+ assert.equal(body.tools[0].name, "virtual_apply_patch");
+ assert.equal(body.input[0].type, "function_call");
+ assert.equal(body.input[0].name, "virtual_apply_patch");
+ assert.deepEqual(JSON.parse(body.input[0].arguments), { patch });
+ assert.equal(body.input[1].type, "function_call_output");
+});
+
+test("Codex patch bridge leaves GPT models untouched", () => {
+ const result = prepareCodexApplyPatchBridgeRequest({
+ body: Buffer.from(JSON.stringify({
+ model: "openai/gpt-5-codex",
+ tools: [{ type: "custom", name: "apply_patch" }]
+ })),
+ config,
+ headers: { "user-agent": "codex-test" },
+ method: "POST",
+ path: "/v1/responses"
+ });
+
+ assert.equal(result, undefined);
+});
+
+test("Codex patch bridge discourages shell-based file edits", () => {
+ const result = prepareCodexApplyPatchBridgeRequest({
+ body: Buffer.from(JSON.stringify({
+ model: "provider-deepseek::openai_chat_completions/deepseek-v4-flash",
+ instructions: "You are Codex, a coding agent.",
+ tools: [
+ {
+ type: "function",
+ name: "exec_command",
+ description: "Runs a command.",
+ parameters: {
+ type: "object",
+ properties: {
+ cmd: { type: "string", description: "Shell command to execute." }
+ }
+ }
+ },
+ { type: "function", name: "write_stdin", description: "Writes to a running session." },
+ { type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } }
+ ]
+ })),
+ config,
+ headers: { "user-agent": "codex-test" },
+ method: "POST",
+ path: "/v1/responses"
+ });
+
+ assert.ok(result);
+ const body = JSON.parse(result.body.toString("utf8"));
+ assert.match(body.instructions, /When modifying files, call virtual_apply_patch/);
+ const execCommand = body.tools.find((tool) => tool.name === "exec_command");
+ const writeStdin = body.tools.find((tool) => tool.name === "write_stdin");
+ assert.match(execCommand.description, /do not use this tool to edit files/i);
+ assert.match(execCommand.parameters.properties.cmd.description, /cat >, tee, sed -i/);
+ assert.match(writeStdin.description, /Use virtual_apply_patch for manual file changes/);
+ const virtualApplyPatch = body.tools.find((tool) => tool.name === "virtual_apply_patch");
+ assert.equal(virtualApplyPatch?.type, "function");
+ assert.match(virtualApplyPatch.description, /The patch field must match this Lark grammar:/);
+ assert.match(virtualApplyPatch.description, /start: begin_patch hunk\+ end_patch/);
+ assert.match(virtualApplyPatch.description, /%import common\.LF/);
+ assert.match(virtualApplyPatch.parameters.properties.patch.description, /update_hunk: "\*\*\* Update File: " filename LF change_move\? change\?/);
+});
+
+test("Codex patch bridge rewrites virtual function response items to apply_patch custom tool calls", () => {
+ const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
+ const result = transformCodexApplyPatchBridgeResponseValue({
+ type: "response.output_item.done",
+ item: {
+ type: "function_call",
+ call_id: "call_patch",
+ name: "virtual_apply_patch",
+ arguments: JSON.stringify({ patch })
+ }
+ });
+
+ assert.equal(result.changed, true);
+ assert.deepEqual(result.value.item, {
+ type: "custom_tool_call",
+ call_id: "call_patch",
+ name: "apply_patch",
+ input: patch
+ });
+});
+
+test("Codex patch bridge rewrites virtual function SSE events", () => {
+ const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
+ const event = transformCodexApplyPatchBridgeSseEvent([
+ "event: response.output_item.done",
+ `data: ${JSON.stringify({
+ type: "response.output_item.done",
+ item: {
+ type: "function_call",
+ call_id: "call_patch",
+ name: "virtual_apply_patch",
+ arguments: JSON.stringify({ patch })
+ }
+ })}`
+ ].join("\n"));
+
+ assert.match(event, /^event: response\.output_item\.done\n/);
+ const data = JSON.parse(event.split("\ndata: ")[1]);
+ assert.equal(data.item.type, "custom_tool_call");
+ assert.equal(data.item.name, "apply_patch");
+ assert.equal(data.item.input, patch);
+});