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