Add Claude Code routing docs and app model catalog support

This commit is contained in:
musistudio 2026-07-01 18:48:28 +08:00
parent 56c1b666c6
commit f6c112148d
12 changed files with 935 additions and 31 deletions

View file

@ -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 `<CCR-SUBAGENT-MODEL>...</CCR-SUBAGENT-MODEL>` 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
<CCR-SUBAGENT-MODEL>provider/model</CCR-SUBAGENT-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 `<CCR-SUBAGENT-MODEL>provider/model</CCR-SUBAGENT-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.

View file

@ -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 文本块,并提取 `<CCR-SUBAGENT-MODEL>...</CCR-SUBAGENT-MODEL>` 模型标签。
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
<CCR-SUBAGENT-MODEL>供应商/模型</CCR-SUBAGENT-MODEL>
```
完整流程如下:
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 第一行会携带 `<CCR-SUBAGENT-MODEL>供应商/模型</CCR-SUBAGENT-MODEL>`
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 是请求失败后的降级策略。它不负责第一次选模型,而是在当前模型或上游失败时决定是否继续尝试。

View file

@ -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(

View file

@ -46,13 +46,13 @@ export type CodexModelCatalogItem = {
web_search_tool_type: string;
};
export function buildCodexModelCatalog(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): CodexModelCatalog {
export function buildCodexModelCatalog(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): CodexModelCatalog {
return {
models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config))
};
}
export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string[] {
export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string[] {
const ids: string[] = [];
pushUniqueModel(ids, normalizeModelSelector(selectedModel));
@ -98,11 +98,11 @@ export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Prov
return ids;
}
export function codexModelCatalogJson(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string {
export function codexModelCatalogJson(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string {
return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`;
}
export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string {
export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, 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<Pick<AppConfig, "Provid
function codexModelCatalogItem(
model: string,
priority: number,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>
): CodexModelCatalogItem {
const profile = codexModelCapabilityProfile(model, config);
const contextWindow = codexModelContextWindow(model, profile.catalogEntry);
@ -159,7 +159,7 @@ type CodexCapabilityProfile = {
function codexModelCapabilityProfile(
model: string,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>
): 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<Pick<AppConfig, "Router">>
): 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;

View file

@ -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);

View file

@ -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;
}),

View file

@ -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<RouterBuiltInAgentRuleConfig>) => void;
updateFallback: (fallback: RouterFallbackConfig) => void;
updateRule: (index: number, patch: Partial<RouterRule>) => 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 });
}

View file

@ -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";

View file

@ -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<string, ApiKeyWindowCounter>();
const providerCredentialCooldowns = new Map<string, { reason: string; until: number }>();
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<string, string | string[] | undefined>,
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<string, unknown>): {
body: Record<string, unknown>;
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<string, unknown> = { ...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<string>();
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<string>): { 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<string, unknown> {
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<string, unknown>, 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<string, unknown>).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)) : [];
}

View file

@ -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 });
}
});

View file

@ -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: [],

View file

@ -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);
});