mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Add Claude Code routing docs and app model catalog support
This commit is contained in:
parent
56c1b666c6
commit
f6c112148d
12 changed files with 935 additions and 31 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 是请求失败后的降级策略。它不负责第一次选模型,而是在当前模型或上游失败时决定是否继续尝试。
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)) : [];
|
||||
}
|
||||
|
|
|
|||
54
tests/main/codex-app-model-catalog.test.mjs
Normal file
54
tests/main/codex-app-model-catalog.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
149
tests/main/codex-patch-bridge.test.mjs
Normal file
149
tests/main/codex-patch-bridge.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue