Merge remote-tracking branch 'origin/dev/3.1' into dev/3.1

# Conflicts:
#	src/main/profile-service.ts
This commit is contained in:
musi 2026-07-01 21:45:48 +08:00
commit 9f99eb2d84
35 changed files with 3251 additions and 600 deletions

420
README.md
View file

@ -3,6 +3,7 @@
<p align="center">
<a href="README_zh.md"><img alt="Chinese README" src="https://img.shields.io/badge/%F0%9F%87%A8%F0%9F%87%B3-%E4%B8%AD%E6%96%87%E7%89%88-ff0000?style=flat" /></a>
<a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
<a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
<a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
</p>
@ -13,12 +14,12 @@
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
CCR runs on your machine, keeps provider configuration in your local config directory, and exposes a local gateway at `http://127.0.0.1:3456`.
CCR runs on your machine, keeps provider configuration in your local config directory, and exposes the local gateway at `http://localhost:8080` by default.
## Why Use CCR
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
- Route requests with explicit rules instead of editing client configuration by hand.
- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand.
- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers.
- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs.
- Manage everything from a desktop UI instead of editing JSON by hand.
@ -26,40 +27,18 @@ CCR runs on your machine, keeps provider configuration in your local config dire
## Features
- **Desktop dashboard**: start or stop the local gateway, inspect usage, configure the tray window, and manage runtime settings.
- **Provider management**: add provider presets or custom endpoints, test connectivity, manage credentials, and monitor supported account balances where available.
- **Routing rules**: configure conditional and model-prefix routing rules with fallback handling.
- **Agent profiles**: configure Claude Code, Codex, and ZCode profiles that point to the CCR gateway.
- **Gateway compatibility**: translate client requests through the local CCR wrapper and the core gateway runtime.
- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards.
- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available.
- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites.
- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles.
- **Gateway compatibility**: translate supported client requests through the local CCR model gateway.
- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture.
- **Plugins**: install or load wrapper plugins, including routes for Claude Design and Cursor Proxy style integrations.
- **Virtual models**: expose aliases or composed model profiles for clients that expect a specific model name.
- **Provider deeplinks**: import provider configuration through `ccr://provider?...` links after user confirmation.
- **Extensions**: install or load wrapper plugins, including routes for Claude Design and Cursor Proxy style integrations.
- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model.
- **Provider deeplinks**: import provider configuration, manifests, and embeddable one-click provider buttons through `ccr://provider?...` links after user confirmation.
## Download And Install
### npm CLI
Install the CLI package when you want to run CCR without the desktop tray or `ccr://` protocol integration:
```bash
npm install -g claude-code-router
ccr start
```
Common commands:
```bash
ccr start # start the background CCR service and web management UI
ccr stop # stop the background CCR service
ccr <profile-name> cli # launch the saved profile as a CLI
ccr <profile-name> app # launch the saved profile as an app
```
The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr start --host <host> --port <port>` to change it.
### Desktop App
1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
2. Download the package for your platform:
- macOS Apple Silicon: `Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` or `.zip`
@ -73,10 +52,7 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
CCR starts two local services when the gateway is enabled:
- CCR wrapper gateway: `http://127.0.0.1:3456`
- Core gateway runtime: `http://127.0.0.1:3457`
After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
## Quick Start
@ -84,25 +60,25 @@ CCR can be configured entirely from the desktop UI. Use this setup order for a c
### 1. Add a provider
Open **Providers**, click **Add Provider**, then choose a built-in preset or create a custom provider. Fill in the provider name, endpoint, protocol, API key, and model list in the form. Use the connectivity check when available, then save the provider.
Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
### 2. Configure routing
Open **Routing** to add explicit rules and configure failure handling.
Open **Routing** to choose the default route, add conditional rules, configure request rewrites, and set fallback behavior.
Use **Add Routing Rule** for request conditions, model-prefix routing, or fallback behavior.
Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets.
### 3. Start the gateway
Open **Server** and click **Start**. Enable auto start if you want CCR to start the local gateway whenever the desktop app opens.
Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
### 4. Connect your agent tool
Open **Profiles** and choose the client you want to use. Configure the Claude Code, Codex, or ZCode profile from the form, select the target model, and apply the profile. For app-based profiles, use the profile action button to open the target app through CCR.
Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR.
### 5. Monitor and adjust
Use **Dashboard** for usage and provider health, the tray window for quick token and account status, **Network Logs** for debugging provider behavior, and **Extensions** for plugin configuration.
Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status.
## Provider Deeplink
@ -112,22 +88,29 @@ Provider websites can open CCR and import a model provider with a custom protoco
ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions
```
Supported query parameters:
Supported query parameters include:
- `name`: display name for the provider.
- `base_url`: provider API base URL.
- `base_url`: provider API base URL; required for direct imports.
- `api_key`: optional provider API key.
- `models`: comma-separated or newline-separated model list. You can also repeat `models=...`.
- `protocol`: one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, or `gemini_generate_content`.
- `icon`: provider icon URL.
- `source`: provider website or config source.
- `manifest`: remote HTTPS manifest URL.
- `usage_url`, `fetch_usage`, `usage_method`, `usage_headers`, `usage_body`: optional account usage fetching configuration.
- `balance`, `balance_unit`, `subscription`, `subscription_limit`, `subscription_reset`, `subscription_unit`, `subscription_window`: optional usage field mappings.
For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields. CCR always opens a confirmation dialog before writing a provider imported from an external link.
For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields, or pass `manifest` to let CCR fetch a remote provider manifest. Manifest URLs must use HTTPS, return JSON, avoid local or private network hosts, and stay under 128 KB. Parameter names and protocol values must use the exact names above; aliases such as `baseUrl`, `apiKey`, `model`, `type`, or `openai` are not accepted.
## Plugins
CCR always opens a confirmation dialog before writing a provider imported from an external link.
CCR has two plugin layers:
## Extensions
- Core gateway plugins: use `providerPlugins` and `virtualModelProfiles`; these are passed through to the core gateway.
- Wrapper plugins: use top-level `plugins` to extend the Electron wrapper, register local HTTP backends, add gateway routes, and route proxy-mode traffic to plugin backends.
CCR has two extension layers:
- Wrapper plugins: use top-level `plugins` to extend the Electron wrapper, register local HTTP routes, start local HTTP backends, route proxy-mode traffic to plugin backends, add built-in browser entries, and connect provider account meters.
- Core gateway plugins: use `providerPlugins` or `plugins[].coreGateway.providerPlugins` to extend upstream provider, auth, or core gateway behavior. `plugins[].coreGateway.virtualModelProfiles` can inject virtual model profiles.
Example wrapper plugin route:
@ -153,46 +136,19 @@ Example wrapper plugin route:
}
```
Plugin modules export a function or object with `setup(ctx)`. The context supports:
Plugin modules export a function or an object with `setup(ctx)` or `activate(ctx)`. The context supports:
- `ctx.registerGatewayRoute({ method, path, auth, handler })`
- `ctx.registerHttpBackend({ id, host, port, handler })`
- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
- `ctx.registerApp(app)`
- `ctx.openSqliteStore({ filename, migrate })`
- `ctx.registerProviderAccountConnector(connector)`
- `ctx.registerCoreGatewayProviderPlugin(plugin)`
- `ctx.registerCoreGatewayVirtualModelProfile(profile)`
Local plugin examples are available in [examples/plugins](examples/plugins).
## Development
```bash
npm install
npm run dev
npm run typecheck
npm run build:assets
npm run build:app:mac
npm run build:app:win
```
`npm run build:assets` compiles the Electron main process and renderer assets into `dist/`.
`npm run build` packages the app for the current platform and writes installer artifacts to `release/`.
`npm run build:app:mac` and `npm run build:app:win` package platform-specific app artifacts. Linux AppImage packaging is configured in `electron-builder.json`.
`npm run build:app:mac` creates a local macOS test package in `release-local/` using ad-hoc signing. It is useful with a free Apple Account or Apple Development certificate, but it is not suitable for public distribution because downloaded copies will not pass Gatekeeper notarization checks.
macOS release builds are signed and notarized for distribution. Before running `npm run build:app:mac:release`, the build machine must have a `Developer ID Application` certificate available through the keychain or `CSC_LINK`/`CSC_KEY_PASSWORD`, full Xcode selected with `xcode-select`, and one notarization credential set:
- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`
- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`
- `APPLE_KEYCHAIN_PROFILE`, optionally with `APPLE_KEYCHAIN`
The macOS packaging hook validates codesigning, the stapled notarization ticket, and Gatekeeper assessment before writing distributable artifacts.
Packaged builds check GitHub Releases for updates through `electron-updater`. For local update feed testing, set `CCR_UPDATE_FEED_URL` to a generic electron-updater feed URL before starting the app. `CCR_UPDATE_ALLOW_PRERELEASE=1` enables prerelease updates.
## Further Reading
- [Project Motivation and How It Works](blog/en/project-motivation-and-how-it-works.md)
@ -200,113 +156,233 @@ Packaged builds check GitHub Releases for updates through `electron-updater`. Fo
## Acknowledgements
Codex support and Bot handoff are powered by [musistudio/codexl](https://github.com/musistudio/codexl).
Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl).
## Support & Sponsoring
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.
<div align="center">
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM)
[Paypal](https://paypal.me/musistudio1999)
<p>If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.</p>
<table>
<tr>
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
<td align="center" width="220">
<a href="https://ko-fi.com/F1F31GN2GM">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi" />
</a>
<br />
<sub>One-time support via Ko-fi</sub>
</td>
<td align="center" width="220">
<a href="https://paypal.me/musistudio1999">
<img src="https://img.shields.io/badge/PayPal-Sponsor-003087?logo=paypal&logoColor=white" alt="Sponsor with PayPal" />
</a>
<br />
<sub>International sponsorship</sub>
</td>
</tr>
</table>
<table>
<tr>
<td align="center" width="220">
<strong>Alipay</strong>
<br />
<img src="/blog/images/alipay.jpg" width="160" alt="Alipay QR code" />
</td>
<td align="center" width="220">
<strong>WeChat Pay</strong>
<br />
<img src="/blog/images/wechat.jpg" width="160" alt="WeChat Pay QR code" />
</td>
</tr>
</table>
</div>
### Our Sponsors
A huge thank you to all our sponsors for their generous support.
<div align="center">
- [AIHubmix](https://aihubmix.com/)
- [BurnCloud](https://ai.burncloud.com)
- @Simon Leischnig
- [@duanshuaimin](https://github.com/duanshuaimin)
- [@vrgitadmin](https://github.com/vrgitadmin)
- @\*o
- [@ceilwoo](https://github.com/ceilwoo)
- @\*说
- @\*更
- @K\*g
- @R\*R
- [@bobleer](https://github.com/bobleer)
- @\*苗
- @\*划
- [@Clarence-pan](https://github.com/Clarence-pan)
- [@carter003](https://github.com/carter003)
- @S\*r
- @\*晖
- @\*敏
- @Z\*z
- @\*然
- [@cluic](https://github.com/cluic)
- @\*苗
- [@PromptExpert](https://github.com/PromptExpert)
- @\*应
- [@yusnake](https://github.com/yusnake)
- @\*飞
- @董\*
- @\*汀
- @\*涯
- @\*:-
- @\*\*磊
- @\*琢
- @\*成
- @Z\*o
- @\*琨
- [@congzhangzh](https://github.com/congzhangzh)
- @\*\_
- @Z\*m
- @\*鑫
- @c\*y
- @\*昕
- [@witsice](https://github.com/witsice)
- @b\*g
- @\*亿
- @\*辉
- @JACK
- @\*光
- @W\*l
- [@kesku](https://github.com/kesku)
- [@biguncle](https://github.com/biguncle)
- @二吉吉
- @a\*g
- @\*林
- @\*咸
- @\*明
- @S\*y
- @f\*o
- @\*智
- @F\*t
- @r\*c
- [@qierkang](http://github.com/qierkang)
- @\*军
- [@snrise-z](http://github.com/snrise-z)
- @\*王
- [@greatheart1000](http://github.com/greatheart1000)
- @\*王
- @zcutlip
- [@Peng-YM](http://github.com/Peng-YM)
- @\*更
- @\*.
- @F\*t
- @\*政
- @\*铭
- @\*叶
- @七\*o
- @\*青
- @\*\*晨
- @\*远
- @\*霄
- @\*\*吉
- @\*\*飞
- @\*\*驰
- @x\*g
<p>A huge thank you to all our sponsors for their generous support.</p>
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
<table>
<tr>
<td align="center" width="260">
<a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ"><strong>Z智谱</strong></a>
</td>
<td align="center" width="260">
<a href="https://aihubmix.com/"><strong>AIHubmix</strong></a>
</td>
</tr>
<tr>
<td align="center" width="260">
<a href="https://ai.burncloud.com"><strong>BurnCloud</strong></a>
</td>
<td align="center" width="260">
<a href="https://share.302.ai/ZGVF9w"><strong>302.AI</strong></a>
</td>
</tr>
</table>
<h4>Community Sponsors</h4>
<table>
<tr>
<td align="center" width="25%">@Simon Leischnig</td>
<td align="center" width="25%"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
<td align="center" width="25%"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
<td align="center" width="25%">@*o</td>
</tr>
<tr>
<td align="center"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
<td align="center">@*说</td>
<td align="center">@*更</td>
<td align="center">@K*g</td>
</tr>
<tr>
<td align="center">@R*R</td>
<td align="center"><a href="https://github.com/bobleer">@bobleer</a></td>
<td align="center">@*苗</td>
<td align="center">@*划</td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
<td align="center"><a href="https://github.com/carter003">@carter003</a></td>
<td align="center">@S*r</td>
<td align="center">@*晖</td>
</tr>
<tr>
<td align="center">@*敏</td>
<td align="center">@Z*z</td>
<td align="center">@*然</td>
<td align="center"><a href="https://github.com/cluic">@cluic</a></td>
</tr>
<tr>
<td align="center">@*苗</td>
<td align="center"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
<td align="center">@*应</td>
<td align="center"><a href="https://github.com/yusnake">@yusnake</a></td>
</tr>
<tr>
<td align="center">@*飞</td>
<td align="center">@董*</td>
<td align="center">@*汀</td>
<td align="center">@*涯</td>
</tr>
<tr>
<td align="center">@*:-</td>
<td align="center">@**磊</td>
<td align="center">@*琢</td>
<td align="center">@*成</td>
</tr>
<tr>
<td align="center">@Z*o</td>
<td align="center">@*琨</td>
<td align="center"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
<td align="center">@*_</td>
</tr>
<tr>
<td align="center">@Z*m</td>
<td align="center">@*鑫</td>
<td align="center">@c*y</td>
<td align="center">@*昕</td>
</tr>
<tr>
<td align="center"><a href="https://github.com/witsice">@witsice</a></td>
<td align="center">@b*g</td>
<td align="center">@*亿</td>
<td align="center">@*辉</td>
</tr>
<tr>
<td align="center">@JACK</td>
<td align="center">@*光</td>
<td align="center">@W*l</td>
<td align="center"><a href="https://github.com/kesku">@kesku</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/biguncle">@biguncle</a></td>
<td align="center">@二吉吉</td>
<td align="center">@a*g</td>
<td align="center">@*林</td>
</tr>
<tr>
<td align="center">@*咸</td>
<td align="center">@*明</td>
<td align="center">@S*y</td>
<td align="center">@f*o</td>
</tr>
<tr>
<td align="center">@*智</td>
<td align="center">@F*t</td>
<td align="center">@r*c</td>
<td align="center"><a href="https://github.com/qierkang">@qierkang</a></td>
</tr>
<tr>
<td align="center">@*军</td>
<td align="center"><a href="https://github.com/snrise-z">@snrise-z</a></td>
<td align="center">@*王</td>
<td align="center"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
</tr>
<tr>
<td align="center">@*王</td>
<td align="center">@zcutlip</td>
<td align="center"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
<td align="center">@*更</td>
</tr>
<tr>
<td align="center">@*.</td>
<td align="center">@F*t</td>
<td align="center">@*政</td>
<td align="center">@*铭</td>
</tr>
<tr>
<td align="center">@*叶</td>
<td align="center">@七*o</td>
<td align="center">@*青</td>
<td align="center">@**晨</td>
</tr>
<tr>
<td align="center">@*远</td>
<td align="center">@*霄</td>
<td align="center">@**吉</td>
<td align="center">@**飞</td>
</tr>
<tr>
<td align="center">@**驰</td>
<td align="center">@x*g</td>
<td align="center">@**东</td>
<td align="center">@*落</td>
</tr>
<tr>
<td align="center">@哆*k</td>
<td align="center">@*涛</td>
<td align="center"><a href="https://github.com/WitMiao">@苗大</a></td>
<td align="center">@*呢</td>
</tr>
<tr>
<td align="center">@d*u</td>
<td align="center">@crizcraig</td>
<td align="center">s*s</td>
<td align="center">*火</td>
</tr>
<tr>
<td align="center">*勤</td>
<td align="center">**锟</td>
<td align="center">*涛</td>
<td align="center">**明</td>
</tr>
<tr>
<td align="center">*知</td>
<td align="center">*语</td>
<td align="center">*瓜</td>
<td align="center"></td>
</tr>
</table>
<sub>If your name is masked, please contact me via my homepage email to update it with your GitHub username.</sub>
</div>
## License

View file

@ -3,6 +3,7 @@
<p align="center">
<a href="README.md"><img alt="English README" src="https://img.shields.io/badge/%F0%9F%87%AC%F0%9F%87%A7-English-000aff?style=flat" /></a>
<a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
<a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
<a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
<a href="https://ccrdesk.top/"><img alt="文档" src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-ccrdesk.top-0ea5e9?style=flat" /></a>
</p>
@ -13,7 +14,7 @@
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
CCR 在你的本机运行Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://127.0.0.1:3456`。
CCR 在你的本机运行Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://localhost:8080`。
## 为什么使用 CCR
@ -26,40 +27,18 @@ CCR 在你的本机运行Provider 配置保存在本地配置目录,并默
## 功能和特性
- **桌面控制台**:启动或停止本地网关,查看用量,配置托盘窗口和运行时设置
- **Provider 管理**:添加预设或自定义端点,检测连通性,管理凭据,并在可用时查看账号余额。
- **路由规则**:配置默认、后台、thinking、长上下文、图片、Web Search、Subagent、模型前缀和条件路由
- **Agent Profiles**:为 Claude Code、Codex 和 ZCode 配置指向 CCR 网关的 Profile
- **网关兼容层**:通过本地 CCR wrapper 和 core gateway runtime 转换客户端请求。
- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片
- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。
- **路由规则**:配置默认路由、条件路由、模型前缀规则、失败降级和请求改写
- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置
- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。
- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。
- **插件系统**:安装或加载 wrapper 插件,包括 Claude Design、Cursor Proxy 这类集成路由。
- **虚拟模型**:为客户端暴露模型别名或组合模型配置,适配固定模型名场景
- **Provider Deeplink**:通过 `ccr://provider?...` 链接导入 Provider 配置,写入前会弹出确认。
- **扩展机制**:安装或加载 wrapper 插件,包括 Claude Design、Cursor Proxy 这类集成路由。
- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型
- **Provider Deeplink**:通过 `ccr://provider?...` 链接导入 Provider 配置、manifest 和嵌入式一键导入按钮,写入前会弹出确认。
## 下载和安装
### npm CLI
如果你只需要 CLI 版本,不需要桌面 Tray 或 `ccr://` 协议集成,可以安装 npm 包:
```bash
npm install -g claude-code-router
ccr start
```
常用命令:
```bash
ccr start # 后台启动 CCR 服务和 Web 管理端
ccr stop # 停止后台 CCR 服务
ccr <profile-name> cli # 以 CLI 方式启动保存的 profile
ccr <profile-name> app # 以 App 方式启动保存的 profile
```
Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host <host> --port <port>` 修改监听地址。
### 桌面应用
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
2. 按系统下载对应安装包:
- macOS Apple 芯片:`Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg``.zip`
@ -73,10 +52,7 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
启用网关后CCR 会启动两个本地服务:
- CCR wrapper gateway`http://127.0.0.1:3456`
- Core gateway runtime`http://127.0.0.1:3457`
**服务** 页面启动后CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host``Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
## 快速开始
@ -84,25 +60,25 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
### 1. 添加 Provider
打开 **Providers**,点击 **Add Provider**,选择内置预设或创建自定义 Provider。按表单填写 Provider 名称、端点、协议、API Key 和模型列表。可用时先运行连通性检测,然后保存 Provider。
打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
### 2. 设置路由
打开 **Routing**,先选择默认路由要使用的 provider/model。然后根据需要设置后台任务、Thinking、长上下文、图片任务和 Web Search 等场景的专用模型
打开 **路由**,选择默认路由,添加条件规则,配置请求改写和失败降级
如果需要更细粒度控制,使用 **Add Routing Rule** 添加模型前缀、Subagent、请求条件或 fallback 规则
如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标
### 3. 启动网关
打开 **Server**,点击 **Start** 启动本地网关。如果希望每次打开桌面应用时自动启动网关,可以启用 auto start
打开 **服务**,点击 **启动**。页面显示运行中后CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动
### 4. 连接 Agent 工具
打开 **Profiles**,选择要使用的客户端。通过表单配置 Claude Code、Codex 或 ZCode Profile选择目标模型并应用配置。对于 App 类型的 Profile可以使用页面里的操作按钮通过 CCR 打开目标应用。
打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode选择目标模型和作用范围然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。
### 5. 日常查看和调整
使用 **Dashboard** 查看用量和 Provider 状态,使用托盘窗口快速查看 Token 和账号状态,使用 **Network Logs** 调试 Provider 行为,使用 **Extensions** 配置插件
**设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model``resolved provider``resolved model`、状态码、tokens、耗时和错误使用托盘窗口快速查看 Token 和账号状态
## Provider Deeplink
@ -112,22 +88,29 @@ Provider 网站可以通过自定义协议打开 CCR 并导入模型服务配置
ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions
```
支持的 query 参数:
支持的 query 参数包括
- `name`Provider 展示名称。
- `base_url`Provider API Base URL。
- `base_url`Provider API Base URL,直链导入时必填
- `api_key`:可选 Provider API Key。
- `models`:逗号或换行分隔的模型列表,也可以重复传入 `models=...`
- `protocol``openai_chat_completions``openai_responses``anthropic_messages``gemini_generate_content`
- `icon`Provider 图标 URL。
- `source`Provider 官网或配置来源。
- `manifest`:远程 HTTPS manifest URL。
- `usage_url``fetch_usage``usage_method``usage_headers``usage_body`:可选账号用量读取配置。
- `balance``balance_unit``subscription``subscription_limit``subscription_reset``subscription_unit``subscription_window`:可选用量字段映射。
更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段。CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。
更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段,也可以传入 `manifest` 让 CCR 拉取远程 Provider manifest。Manifest 必须使用 HTTPS返回 JSON不能指向本地或内网地址体积不能超过 128 KB。参数名和协议值必须使用上面的完整规范名不再接受 `baseUrl``apiKey``model``type``openai` 等别名
## 插件
CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。
CCR 有两层插件:
## 扩展机制
- Core gateway plugins使用 `providerPlugins``virtualModelProfiles`,会透传给 core gateway。
- Wrapper plugins使用顶层 `plugins` 扩展 Electron wrapper注册本地 HTTP 后端、添加 gateway route或把代理模式流量路由到插件后端。
CCR 有两层扩展:
- Wrapper plugin使用顶层 `plugins` 扩展 Electron wrapper注册本地 HTTP 路由、启动本地后端、拦截代理流量、添加内置浏览器入口、连接 Provider 账号用量。
- Core gateway plugin使用 `providerPlugins``plugins[].coreGateway.providerPlugins` 扩展上游 Provider、认证方式或 core gateway 内部能力。`plugins[].coreGateway.virtualModelProfiles` 可以注入虚拟模型配置。
Wrapper plugin route 示例:
@ -153,46 +136,19 @@ Wrapper plugin route 示例:
}
```
插件模块需要导出函数或包含 `setup(ctx)` 的对象。上下文支持:
插件模块需要导出函数或包含 `setup(ctx)` / `activate(ctx)` 的对象。上下文支持:
- `ctx.registerGatewayRoute({ method, path, auth, handler })`
- `ctx.registerHttpBackend({ id, host, port, handler })`
- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
- `ctx.registerApp(app)`
- `ctx.openSqliteStore({ filename, migrate })`
- `ctx.registerProviderAccountConnector(connector)`
- `ctx.registerCoreGatewayProviderPlugin(plugin)`
- `ctx.registerCoreGatewayVirtualModelProfile(profile)`
本地插件示例见 [examples/plugins](examples/plugins)。
## 开发
```bash
npm install
npm run dev
npm run typecheck
npm run build:assets
npm run build:app:mac
npm run build:app:win
```
`npm run build:assets` 会把 Electron main process 和 renderer assets 编译到 `dist/`
`npm run build` 会为当前平台打包应用,并把安装包写入 `release/`
`npm run build:app:mac``npm run build:app:win` 会分别打包对应平台的应用产物。Linux AppImage 打包配置在 `electron-builder.json` 中。
`npm run build:app:mac` 会在 `release-local/` 生成本地测试用 macOS 包,使用 ad-hoc 签名。它适合免费 Apple Account 或只有 Apple Development 证书的本机测试,但不适合公开分发,因为用户下载后仍无法通过 Gatekeeper 公证检查。
macOS 发布包会使用 Developer ID 签名并提交 Apple 公证。运行 `npm run build:app:mac:release` 前,打包机器必须具备:可用的 `Developer ID Application` 证书(在 keychain 中,或通过 `CSC_LINK`/`CSC_KEY_PASSWORD` 提供)、已通过 `xcode-select` 选择完整 Xcode以及下面任意一组公证凭据
- `APPLE_API_KEY``APPLE_API_KEY_ID``APPLE_API_ISSUER`
- `APPLE_ID``APPLE_APP_SPECIFIC_PASSWORD``APPLE_TEAM_ID`
- `APPLE_KEYCHAIN_PROFILE`,可选 `APPLE_KEYCHAIN`
macOS 打包 hook 会在产物生成前验证代码签名、公证票据 stapling 和 Gatekeeper 评估,避免发布未公证的安装包。
打包后的应用会通过 `electron-updater` 检查 GitHub Releases。测试本地更新源时可以在启动应用前设置 `CCR_UPDATE_FEED_URL` 为 generic electron-updater feed URL。`CCR_UPDATE_ALLOW_PRERELEASE=1` 可以启用 prerelease 更新。
## 深入阅读
- [项目动机和工作原理](blog/zh/项目初衷及原理.md)
@ -200,113 +156,233 @@ macOS 打包 hook 会在产物生成前验证代码签名、公证票据 staplin
## 致谢
对 Codex 的支持以及 Bot handoff 来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
## 支持与赞助
如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。
<div align="center">
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM)
[Paypal](https://paypal.me/musistudio1999)
<p>如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。</p>
<table>
<tr>
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
<td align="center" width="220">
<a href="https://ko-fi.com/F1F31GN2GM">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="通过 Ko-fi 赞助" />
</a>
<br />
<sub>通过 Ko-fi 单次赞助</sub>
</td>
<td align="center" width="220">
<a href="https://paypal.me/musistudio1999">
<img src="https://img.shields.io/badge/PayPal-Sponsor-003087?logo=paypal&logoColor=white" alt="通过 PayPal 赞助" />
</a>
<br />
<sub>国际赞助通道</sub>
</td>
</tr>
</table>
<table>
<tr>
<td align="center" width="220">
<strong>支付宝</strong>
<br />
<img src="/blog/images/alipay.jpg" width="160" alt="支付宝收款码" />
</td>
<td align="center" width="220">
<strong>微信支付</strong>
<br />
<img src="/blog/images/wechat.jpg" width="160" alt="微信支付收款码" />
</td>
</tr>
</table>
</div>
### 我们的赞助商
非常感谢所有赞助商的慷慨支持。
<div align="center">
- [AIHubmix](https://aihubmix.com/)
- [BurnCloud](https://ai.burncloud.com)
- @Simon Leischnig
- [@duanshuaimin](https://github.com/duanshuaimin)
- [@vrgitadmin](https://github.com/vrgitadmin)
- @\*o
- [@ceilwoo](https://github.com/ceilwoo)
- @\*说
- @\*更
- @K\*g
- @R\*R
- [@bobleer](https://github.com/bobleer)
- @\*苗
- @\*划
- [@Clarence-pan](https://github.com/Clarence-pan)
- [@carter003](https://github.com/carter003)
- @S\*r
- @\*晖
- @\*敏
- @Z\*z
- @\*然
- [@cluic](https://github.com/cluic)
- @\*苗
- [@PromptExpert](https://github.com/PromptExpert)
- @\*应
- [@yusnake](https://github.com/yusnake)
- @\*飞
- @董\*
- @\*汀
- @\*涯
- @\*:-
- @\*\*磊
- @\*琢
- @\*成
- @Z\*o
- @\*琨
- [@congzhangzh](https://github.com/congzhangzh)
- @\*\_
- @Z\*m
- @\*鑫
- @c\*y
- @\*昕
- [@witsice](https://github.com/witsice)
- @b\*g
- @\*亿
- @\*辉
- @JACK
- @\*光
- @W\*l
- [@kesku](https://github.com/kesku)
- [@biguncle](https://github.com/biguncle)
- @二吉吉
- @a\*g
- @\*林
- @\*咸
- @\*明
- @S\*y
- @f\*o
- @\*智
- @F\*t
- @r\*c
- [@qierkang](http://github.com/qierkang)
- @\*军
- [@snrise-z](http://github.com/snrise-z)
- @\*王
- [@greatheart1000](http://github.com/greatheart1000)
- @\*王
- @zcutlip
- [@Peng-YM](http://github.com/Peng-YM)
- @\*更
- @\*.
- @F\*t
- @\*政
- @\*铭
- @\*叶
- @七\*o
- @\*青
- @\*\*晨
- @\*远
- @\*霄
- @\*\*吉
- @\*\*飞
- @\*\*驰
- @x\*g
<p>非常感谢所有赞助商的慷慨支持。</p>
(如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。)
<table>
<tr>
<td align="center" width="260">
<a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ"><strong>Z智谱</strong></a>
</td>
<td align="center" width="260">
<a href="https://aihubmix.com/"><strong>AIHubmix</strong></a>
</td>
</tr>
<tr>
<td align="center" width="260">
<a href="https://ai.burncloud.com"><strong>BurnCloud</strong></a>
</td>
<td align="center" width="260">
<a href="https://share.302.ai/ZGVF9w"><strong>302.AI</strong></a>
</td>
</tr>
</table>
<h4>社区赞助者</h4>
<table>
<tr>
<td align="center" width="25%">@Simon Leischnig</td>
<td align="center" width="25%"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
<td align="center" width="25%"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
<td align="center" width="25%">@*o</td>
</tr>
<tr>
<td align="center"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
<td align="center">@*说</td>
<td align="center">@*更</td>
<td align="center">@K*g</td>
</tr>
<tr>
<td align="center">@R*R</td>
<td align="center"><a href="https://github.com/bobleer">@bobleer</a></td>
<td align="center">@*苗</td>
<td align="center">@*划</td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
<td align="center"><a href="https://github.com/carter003">@carter003</a></td>
<td align="center">@S*r</td>
<td align="center">@*晖</td>
</tr>
<tr>
<td align="center">@*敏</td>
<td align="center">@Z*z</td>
<td align="center">@*然</td>
<td align="center"><a href="https://github.com/cluic">@cluic</a></td>
</tr>
<tr>
<td align="center">@*苗</td>
<td align="center"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
<td align="center">@*应</td>
<td align="center"><a href="https://github.com/yusnake">@yusnake</a></td>
</tr>
<tr>
<td align="center">@*飞</td>
<td align="center">@董*</td>
<td align="center">@*汀</td>
<td align="center">@*涯</td>
</tr>
<tr>
<td align="center">@*:-</td>
<td align="center">@**磊</td>
<td align="center">@*琢</td>
<td align="center">@*成</td>
</tr>
<tr>
<td align="center">@Z*o</td>
<td align="center">@*琨</td>
<td align="center"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
<td align="center">@*_</td>
</tr>
<tr>
<td align="center">@Z*m</td>
<td align="center">@*鑫</td>
<td align="center">@c*y</td>
<td align="center">@*昕</td>
</tr>
<tr>
<td align="center"><a href="https://github.com/witsice">@witsice</a></td>
<td align="center">@b*g</td>
<td align="center">@*亿</td>
<td align="center">@*辉</td>
</tr>
<tr>
<td align="center">@JACK</td>
<td align="center">@*光</td>
<td align="center">@W*l</td>
<td align="center"><a href="https://github.com/kesku">@kesku</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/biguncle">@biguncle</a></td>
<td align="center">@二吉吉</td>
<td align="center">@a*g</td>
<td align="center">@*林</td>
</tr>
<tr>
<td align="center">@*咸</td>
<td align="center">@*明</td>
<td align="center">@S*y</td>
<td align="center">@f*o</td>
</tr>
<tr>
<td align="center">@*智</td>
<td align="center">@F*t</td>
<td align="center">@r*c</td>
<td align="center"><a href="https://github.com/qierkang">@qierkang</a></td>
</tr>
<tr>
<td align="center">@*军</td>
<td align="center"><a href="https://github.com/snrise-z">@snrise-z</a></td>
<td align="center">@*王</td>
<td align="center"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
</tr>
<tr>
<td align="center">@*王</td>
<td align="center">@zcutlip</td>
<td align="center"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
<td align="center">@*更</td>
</tr>
<tr>
<td align="center">@*.</td>
<td align="center">@F*t</td>
<td align="center">@*政</td>
<td align="center">@*铭</td>
</tr>
<tr>
<td align="center">@*叶</td>
<td align="center">@七*o</td>
<td align="center">@*青</td>
<td align="center">@**晨</td>
</tr>
<tr>
<td align="center">@*远</td>
<td align="center">@*霄</td>
<td align="center">@**吉</td>
<td align="center">@**飞</td>
</tr>
<tr>
<td align="center">@**驰</td>
<td align="center">@x*g</td>
<td align="center">@**东</td>
<td align="center">@*落</td>
</tr>
<tr>
<td align="center">@哆*k</td>
<td align="center">@*涛</td>
<td align="center"><a href="https://github.com/WitMiao">@苗大</a></td>
<td align="center">@*呢</td>
</tr>
<tr>
<td align="center">@d*u</td>
<td align="center">@crizcraig</td>
<td align="center">s*s</td>
<td align="center">*火</td>
</tr>
<tr>
<td align="center">*勤</td>
<td align="center">**锟</td>
<td align="center">*涛</td>
<td align="center">**明</td>
</tr>
<tr>
<td align="center">*知</td>
<td align="center">*语</td>
<td align="center">*瓜</td>
<td align="center"></td>
</tr>
</table>
<sub>如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。</sub>
</div>
## 许可证

View file

@ -51,7 +51,7 @@ const isSectionDoc = !docProp || docProp === sectionDoc;
const doc = docProp ?? sectionDoc;
const { Content, frontmatter } = doc;
const allHeadings = doc.getHeadings();
const headings = allHeadings.filter((heading) => heading.depth === 2);
const tocHeadings = allHeadings.filter((heading) => heading.depth >= 2 && heading.depth <= 5);
const isExplicitHref = (value: string) =>
value.startsWith("#") ||
value.startsWith("/") ||
@ -155,9 +155,10 @@ const sidebarTree = sidebarNavItems.map((navItem, index) => {
})),
};
});
const tocItems = headings.map((heading) => ({
const tocItems = tocHeadings.map((heading) => ({
label: heading.text,
href: `#${heading.slug}`,
depth: heading.depth,
}));
const pageMarkdown = doc.rawContent();
---

View file

@ -5,16 +5,137 @@ eyebrow: Detailed Configuration
lead: Choose the model for a request, then automatically retry or switch to fallback models when the request fails.
---
## How Routing Works
## Built-In Routing
CCR first decides which model the request should use, then forwards the request upstream. The current implementation follows this order:
### Claude Code
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.
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.
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.
Claude Code **main requests** use the Claude Code Agent Config model by default; if it is unset, they use the Routing page default model. CCR also automatically removes the first `x-anthropic-billing-header` system message injected by Claude Code so that billing helper messages do not affect later routing decisions. Claude Code Subagent, Task, and Workflow-created agents can still choose different models through the tag mechanism below.
#### 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`.
### Codex
The built-in Codex route adapts Codex's `apply_patch` file-editing tool for third-party or non-GPT models. The goal is for those models to edit files through the patch tool instead of generating commands or scripts such as `cat >`, `sed -i`, `python`, or `node`.
Technically, this is a tool protocol bridge. Native Codex `apply_patch` is a custom/freeform tool whose input is raw patch text, while many OpenAI-compatible third-party models handle ordinary function tools more reliably. CCR rewrites `apply_patch` into an upstream-visible `virtual_apply_patch` function tool and injects the full `apply_patch.lark` grammar into the tool description, requiring the model to put the patch in the `patch` field.
When the model returns `virtual_apply_patch`, CCR rewrites it back to Codex's expected shape: `custom_tool_call` with `name = apply_patch` and `input = raw patch text`. CCR does not edit files directly; Codex still executes the resulting patch. This adaptation follows the built-in **Codex** route and has no separate switch. GPT-named models keep using Codex's native freeform `apply_patch` path.
## Custom Routing
Custom routes are configured in the Routing page rule list. The top **Search routing rules** field filters by rule name, condition, request action, and related row text; the top-right **Add** button opens the **Add Routing Rule** dialog. The table shows each rule under **Name**, **Condition**, **Request action**, **Status**, and **Action**.
Custom rules match in list order, and the first enabled matching rule rewrites the request. Use the move up and move down buttons to adjust priority. Use the edit button to open **Edit Routing Rule**, and the delete button to open a confirmation dialog. Turning off the **Status** toggle keeps the rule in the list but removes it from matching.
### Add Or Edit A Rule
The dialog fields map directly to the saved rule:
| UI field | How to fill it | Saved meaning |
| --- | --- | --- |
| **Name** | Enter a recognizable rule name. This field is required. | Shown in the **Name** column and included in search. |
| **Condition** | Choose `request.header` or `request.body`, then fill in field, operator, and value. | Builds `condition.left`, `condition.operator`, and `condition.right`. |
| **Rewrite request parameters** | Keep at least one rewrite row. Each row chooses an operation, target key, and required value fields. | Builds `rewrites`, applied when the rule matches. |
| **Enabled** | Turn the rule on or off. | Controls `enabled`; disabled rules do not match. |
| **On failure** | Configure fallback behavior for this rule. | Overrides **Default on failure** when this rule matches. |
The **Add** or **Save** button is enabled only when the form is valid: name, condition field, and condition value are required; every rewrite row must have a key. **Delete** only requires a key. **Replace in array** requires both **Match value** and **Value**. Other operations require **Value**.
### Condition
The **Condition** area has four controls: source, field, operator, and value.
| Source | Field examples | Matched path |
| --- | --- | --- |
| `request.header` | `user-agent`, `x-api-key`, `x-client-name` | `request.header.user-agent` |
| `request.body` | `model`, `messages`, `messages.0.role`, `tools` | `request.body.model` |
Header names are case-insensitive. Body fields use dot-path lookup, and numeric segments address array indexes; for example, `messages.0.role` reads the first message role. For nested arrays such as `messages` or `tools`, `contains deep` is usually more robust than a fixed index.
The value field is parsed as a common literal when possible: `true`, `false`, `null`, numbers, JSON objects, and JSON arrays compare as their corresponding types. Other input is treated as a string. To force a value to stay string-like, wrap it as `"123"` or `'123'`.
| Operator | Use |
| --- | --- |
| `==` / `!=` | Compare actual and expected values. Numbers compare numerically; other values compare by comparable text. |
| `>` / `>=` / `<` / `<=` | Compare numerically when both sides are numbers; otherwise compare text order. |
| `starts with` | Check whether the actual value starts with the input value. Useful for model-prefix routing. |
| `contains` | Check substring containment for strings; for arrays, check direct array elements. |
| `contains deep` | Recursively checks objects and arrays. Useful for searching `messages` and `tools`. |
| `not contains` | The inverse of `contains`. |
### Rewrite Request Parameters
The **Rewrite request parameters** area starts with one `request.body.model` row. This is the common model-routing path: choose **Set**, use key `request.body.model`, and set the value to a target `provider/model` or Fusion model.
Click **Add parameter** to add more rewrite rows. The trash button removes a row, but the last row cannot be removed. When the rule matches, CCR applies the rewrite rows in order.
| Operation | Required fields | Behavior |
| --- | --- | --- |
| **Set** | key, value | Sets a request field, such as `request.body.model = provider/model` or `request.body.temperature = 0.2`. |
| **Delete** | key | Deletes a request field. Deleting `request.header.x-test` removes that header; deleting `request.body.foo` removes that body field. |
| **Append to array** | key, value | Appends the value to the target array. If the target is not an array, CCR starts from an empty array. |
| **Prepend to array** | key, value | Prepends the value to the target array. |
| **Remove from array** | key, value | Removes array elements equal to the value. |
| **Replace in array** | key, match value, value | Replaces array elements matching **Match value** with the new value. |
Rewrite values are also parsed as literals, so `0.2` becomes a number, `true` becomes a boolean, and `{"type":"web_search"}` becomes an object. Only `request.body.model` receives additional CCR model-selector normalization.
### On Failure
The dialog **On failure** control is the same control used by the page-level **Default on failure** setting. Choose **Off** to avoid fallback. Choose **Retry** to reveal **Retries**. Choose **Fallback targets** to reveal the **Fallback target** input and **Add** button. Added targets appear as tags with move up, move down, and remove buttons for ordering the fallback chain.
When a rule matches, its **On failure** setting is used. Requests that do not match a rule continue to use the page-level default.
### Examples
| Goal | Condition source | Field | Operator | Value | Rewrite request parameters |
| --- | --- | --- | --- | --- | --- |
| Route by client header | `request.header` | `x-client-name` | `==` | `claude-code` | **Set** `request.body.model = provider/model` |
| Route by original model prefix | `request.body` | `model` | `starts with` | `claude-` | **Set** `request.body.model = provider/model` |
| Route message content to a vision model | `request.body` | `messages` | `contains deep` | `image` | **Set** `request.body.model = vision-provider/model` |
| Remove a debug header | `request.header` | `x-debug-route` | `==` | `1` | **Delete** `request.header.x-debug-route` |
After saving, the rule appears in the list. Use request logs, especially `request model`, `resolved provider`, `resolved model`, and route reason, to verify that it matched.
## Fallback Handling
@ -63,24 +184,6 @@ When adding or editing a routing rule, configure **On failure** for that rule.
Rule-level Fallback is useful for high-risk or expensive targets. For example, route image tasks to a Fusion vision model first, then fall back to another multimodal model; or route complex tasks to a strong model first, then fall back to a stable model.
### Conditional Routing
The current UI primarily creates conditional rules. Conditions can read request headers or the request body:
| Source | Example |
| --- | --- |
| `request.header` | `x-client-name == claude-code` |
| `request.body` | `model starts-with claude-` |
| `request.body` | `messages contains-deep image` |
After a match, the request action can set, delete, or modify request fields. The most common action is:
```text
set request.body.model = provider/model
```
The model can also be a Fusion model, so routing can send selected requests to vision, search, or tool-augmented models.
## Verification
After saving, send a request and inspect Logs:

View file

@ -5,16 +5,137 @@ eyebrow: 详细配置
lead: 设置请求如何选择模型,并在失败时通过 Fallback 自动重试或切换到备用模型。
---
## 路由如何生效
## 内置路由
CCR 的路由会先决定本次请求使用哪个模型,然后再把请求交给上游。当前代码中的主要顺序是:
### Claude Code
1. 如果请求里的 `model` 已经是已知的 `供应商/模型` 形式CCR 会直接使用这个模型。
2. 如果配置了自定义路由脚本,脚本返回的模型优先级高于界面里的路由规则。
3. 路由规则按列表顺序匹配,第一条命中的规则会执行对应的请求改写。
4. 没有规则命中时,使用默认路由;如果没有默认路由,就保留请求原本的模型。
Claude Code 内置路由的作用是识别 Claude Code 发来的请求,并把主请求路由到 Claude Code Agent 配置或默认路由中的模型。
路由规则的核心是 **条件 + 请求动作**。条件判断请求是否命中,请求动作修改请求字段。最常用的动作是把 `request.body.model` 设置为目标模型或 Fusion 模型。
Claude Code **主请求** 默认使用 Claude Code Agent 配置中的模型如果未设置则使用路由页的默认模型。CCR 也会自动删除 Claude Code 注入的第一条 `x-anthropic-billing-header` system 消息避免这类计费辅助消息影响后续路由判断。Claude Code 创建的 Subagent、Task 或 Workflow 内部 Agent 可以继续用下面的标签机制自动选择不同模型。
#### 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`
### Codex
Codex 内置路由会为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件编辑工具。目标是让这些模型通过 patch 工具完成文件修改,而不是生成 `cat >``sed -i``python``node` 等命令或脚本来编辑文件。
技术原理是做一次工具协议桥接Codex 原生的 `apply_patch` 是 custom/freeform 工具,入参是原始 patch 文本;很多 OpenAI-compatible 三方模型更擅长普通 function tool。CCR 会在上游请求中把 `apply_patch` 转成 `virtual_apply_patch` function tool并在工具说明里注入完整的 `apply_patch.lark` 语法,要求模型把 patch 写入 `patch` 字段。
模型返回 `virtual_apply_patch`CCR 会把它转换回 Codex 期望的 `custom_tool_call``name = apply_patch``input = 原始 patch 文本`。CCR 不直接修改文件,真正执行 patch 的仍然是 Codex 客户端。这个适配跟随 **Codex** 内置路由启用或关闭没有单独开关GPT 命名模型继续使用 Codex 原生 freeform `apply_patch` 路径。
## 自定义路由
自定义路由在路由页的规则列表中配置。页面顶部的 **搜索路由规则** 可以按名称、条件、请求动作等文本过滤列表;右上角 **添加** 按钮打开 **添加路由规则** 弹窗。规则表格按 **名称**、**条件**、**请求动作**、**状态**、**操作** 展示每条规则。
自定义规则按列表顺序匹配,第一条命中的启用规则会改写请求。表格右侧的上移、下移按钮用来调整优先级,编辑按钮打开 **编辑路由规则**,删除按钮会先弹出确认框。**状态** 列的开关关闭后,规则保留在列表里,但不会参与匹配。
### 添加或编辑规则
弹窗里的字段和保存后的配置一一对应:
| UI 字段 | 填写方式 | 保存后的含义 |
| --- | --- | --- |
| **名称** | 填一个便于识别的规则名。该字段不能为空。 | 显示在列表 **名称** 列,也参与搜索。 |
| **条件** | 选择 `request.header``request.body`,填写字段名、操作符和值。 | 生成 `condition.left``condition.operator``condition.right`。 |
| **改写请求参数** | 至少保留一行 rewrite。每行选择操作、目标 key 和需要的值。 | 生成 `rewrites`,规则命中后按行改写请求。 |
| **启用** | 打开或关闭规则。 | 控制 `enabled`,关闭时不会匹配。 |
| **失败时** | 配置这条规则自己的 Fallback。 | 规则命中后覆盖页面顶部的 **默认失败处理**。 |
**添加** 或 **保存** 按钮只有在表单有效时才可点击:名称、条件字段、条件值都必须填写;每条 rewrite 都必须有 key。`删除` 操作只需要 key`替换数组元素` 需要同时填写 **匹配值****值**;其他操作需要填写 **值**
### 条件
**条件** 区域有四个输入:来源、字段、操作符和值。
| 来源 | 字段示例 | 实际匹配路径 |
| --- | --- | --- |
| `request.header` | `user-agent``x-api-key``x-client-name` | `request.header.user-agent` |
| `request.body` | `model``messages``messages.0.role``tools` | `request.body.model` |
Header 名不区分大小写。Body 字段按点号路径读取,数字片段表示数组下标;例如 `messages.0.role` 读取第一条 message 的 role。对于 messages、tools 这类嵌套数组,通常用 `contains deep` 比固定下标更稳。
值输入框会按常见字面量解析:`true``false``null`、数字、JSON 对象或数组会按对应类型比较;其他内容按字符串处理。需要强制作为字符串时,可以写成 `"123"``'123'`
| 操作符 | 用法 |
| --- | --- |
| `==` / `!=` | 比较实际值和输入值。数字会按数字比较,其他值按可比较文本比较。 |
| `>` / `>=` / `<` / `<=` | 两边都是数字时按数字比较,否则按文本顺序比较。 |
| `starts with` | 判断实际值是否以输入值开头,适合模型前缀分流。 |
| `contains` | 对字符串做包含判断;对数组只检查数组元素。 |
| `contains deep` | 递归检查对象和数组,适合在 `messages``tools` 中查找内容。 |
| `not contains` | `contains` 的反向判断。 |
### 改写请求参数
**改写请求参数** 区域默认给出一行 `request.body.model`。这也是最常用的模型路由写法:选择 **设置**key 填 `request.body.model`,值填目标 `供应商/模型` 或 Fusion 模型。
点击 **添加参数** 可以追加多行 rewrite垃圾桶按钮删除当前行最后一行不能删除。规则命中后CCR 会按列表顺序应用这些 rewrite。
| 操作 | 需要填写 | 行为 |
| --- | --- | --- |
| **设置** | key、值 | 设置请求里的字段,例如 `request.body.model = provider/model``request.body.temperature = 0.2`。 |
| **删除** | key | 删除请求字段。删除 `request.header.x-test` 会移除对应 Header删除 `request.body.foo` 会移除 body 字段。 |
| **追加到数组** | key、值 | 把值追加到目标数组末尾。目标不是数组时按空数组开始。 |
| **插入到数组开头** | key、值 | 把值插到目标数组开头。 |
| **从数组移除** | key、值 | 从目标数组中移除等于该值的元素。 |
| **替换数组元素** | key、匹配值、值 | 把数组中匹配 **匹配值** 的元素替换为新值。 |
Rewrite 的值也会按字面量解析,所以 `0.2` 会变成数字,`true` 会变成布尔值,`{"type":"web_search"}` 会变成对象。只有 `request.body.model` 的值会额外按 CCR 的模型选择器格式规范化。
### 失败时
弹窗底部的 **失败时** 和页面顶部的 **默认失败处理** 是同一套控件。选择 **关闭** 时不降级;选择 **继续重试** 时会出现 **重试次数**;选择 **失败降级目标** 时会出现 **失败降级目标** 输入框和 **添加** 按钮。添加后的目标会以标签形式显示,标签上的上移、下移、移除按钮用于调整降级顺序。
规则命中时会使用这条规则自己的 **失败时** 设置;没有命中的请求才继续使用页面顶部的默认设置。
### 配置示例
| 目标 | 条件来源 | 字段 | 操作符 | 值 | 改写请求参数 |
| --- | --- | --- | --- | --- | --- |
| 按客户端 Header 分流 | `request.header` | `x-client-name` | `==` | `claude-code` | **设置** `request.body.model = 供应商/模型` |
| 按原始模型前缀分流 | `request.body` | `model` | `starts with` | `claude-` | **设置** `request.body.model = 供应商/模型` |
| 按消息内容分流到视觉模型 | `request.body` | `messages` | `contains deep` | `image` | **设置** `request.body.model = 视觉供应商/模型` |
| 删除调试 Header | `request.header` | `x-debug-route` | `==` | `1` | **删除** `request.header.x-debug-route` |
保存后,规则会出现在列表中。请求日志里的 `request model``resolved provider``resolved model` 和路由原因可以用来确认规则是否命中。
## Fallback 处理
@ -63,24 +184,6 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成;
规则级 Fallback 适合高风险或高成本模型。例如:图片任务先走 Fusion 视觉模型,失败后切到另一个多模态模型;复杂任务先走强模型,失败后切到稳定模型。
### 条件路由
当前界面新增规则时主要使用条件路由。条件可以读取请求 Header 或请求 Body
| 来源 | 示例 |
| --- | --- |
| `request.header` | `x-client-name == claude-code` |
| `request.body` | `model starts-with claude-` |
| `request.body` | `messages contains-deep image` |
命中后,请求动作可以设置、删除或修改请求字段。最常用的是:
```text
set request.body.model = 供应商/模型
```
模型也可以是 Fusion 模型。这样路由可以把特定请求导向视觉、搜索或工具增强模型。
## 验证方式
保存后发一次请求,到请求日志里检查:

View file

@ -394,14 +394,21 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";
</h2>
<nav>
{
tocItems.map((item, index) => (
<a
class:list={{ active: index === 0 }}
href={typeof item === "string" ? `#section-${index + 1}` : item.href}
>
{typeof item === "string" ? item : item.label}
</a>
))
tocItems.map((item, index) => {
const tocItem = typeof item === "string"
? { label: item, href: `#section-${index + 1}`, depth: 2 }
: { depth: 2, ...item };
const depth = Math.min(Math.max(Number(tocItem.depth), 2), 6);
return (
<a
class:list={[`toc-depth-${depth}`, { active: index === 0 }]}
href={tocItem.href}
>
{tocItem.label}
</a>
);
})
}
</nav>
</div>

View file

@ -847,6 +847,8 @@ pre {
h1,
h2,
h3,
h4,
h5,
p {
overflow-wrap: anywhere;
}
@ -956,6 +958,24 @@ h1 {
font-weight: 720;
}
.doc-article h4,
.doc-markdown > h4 {
margin: 24px 0 8px;
color: var(--heading);
font-size: 15px;
line-height: 1.45;
font-weight: 700;
}
.doc-article h5,
.doc-markdown > h5 {
margin: 18px 0 6px;
color: var(--heading);
font-size: 14px;
line-height: 1.45;
font-weight: 680;
}
.doc-markdown > ul,
.doc-markdown > ol {
margin: 0 0 22px;
@ -1558,6 +1578,7 @@ pre code span {
}
.toc a {
--toc-dot-left: 0px;
position: relative;
display: block;
padding: 5px 0 5px 21px;
@ -1565,6 +1586,25 @@ pre code span {
line-height: 1.35;
}
.toc a.toc-depth-3 {
--toc-dot-left: 12px;
padding-left: 33px;
font-size: 13.5px;
}
.toc a.toc-depth-4 {
--toc-dot-left: 24px;
padding-left: 45px;
font-size: 13px;
}
.toc a.toc-depth-5,
.toc a.toc-depth-6 {
--toc-dot-left: 36px;
padding-left: 57px;
font-size: 12.5px;
}
.toc a.active {
color: var(--green);
font-weight: 720;
@ -1572,7 +1612,7 @@ pre code span {
.toc a.active::before {
position: absolute;
left: 0;
left: var(--toc-dot-left);
top: 13px;
width: 5px;
height: 5px;

8
package-lock.json generated
View file

@ -9,7 +9,7 @@
"version": "3.0.4",
"license": "MIT",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.2",
"@the-next-ai/ai-gateway": "^1.0.3",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
@ -2066,9 +2066,9 @@
}
},
"node_modules/@the-next-ai/ai-gateway": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.2.tgz",
"integrity": "sha512-K0UiXwyC1i6dpPtQWDndJ1SgwI+zmfGpwqFUWhnVico4dxCt9eph5d9mt0RHcEduZOv4MRBdWDHyHMpxDgSUCw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.3.tgz",
"integrity": "sha512-+cZtmhCB1UbxYpn0EPVHlr4qO6W/yBhMidF/JPYdeNG11uGbxSPY9CnS5rKF1v2B6mrgL65lUzL3VnMK4Iq2Ug==",
"license": "MIT",
"dependencies": {
"diff": "^8.0.3",

View file

@ -52,7 +52,7 @@
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.2",
"@the-next-ai/ai-gateway": "^1.0.3",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",

View file

@ -41,6 +41,12 @@ export type CodexAppLaunchResult = {
userDataDir: string;
};
export type CodexCompatibleAppModelCatalogWriteResult = {
changed: boolean;
file: string;
userDataDir: string;
};
const codexAppSpec: CodexCompatibleAppSpec = {
bundledCliNames: ["codex", "Codex", "OpenAI Codex"],
defaultCliCommand: "codex",
@ -137,18 +143,36 @@ export function refreshCodexCompatibleAppProfileFiles(
configDir: string,
profile: ProfileConfig,
config?: AppConfig
): { modelCatalogFile: string; userDataDir: string } {
): { modelCatalogChanged: boolean; modelCatalogFile: string; userDataDir: string } {
const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec;
const configFile = resolveCodexConfigFile(configDir, profile);
if (spec.kind === "zcode" && config?.APIKEY) {
writeZcodeGatewayConfig(config, profile, config.APIKEY, { backup: false });
}
const modelCatalog = writeCodexCompatibleAppModelCatalog(configDir, profile, config);
return {
modelCatalogChanged: modelCatalog.changed,
modelCatalogFile: modelCatalog.file,
userDataDir: modelCatalog.userDataDir
};
}
export function writeCodexCompatibleAppModelCatalog(
configDir: string,
profile: ProfileConfig,
config?: AppConfig
): CodexCompatibleAppModelCatalogWriteResult {
const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec;
const configFile = resolveCodexConfigFile(configDir, profile);
const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile);
const userDataDir = codexElectronUserDataDir(codexHome, profile, spec);
mkdirSync(userDataDir, { recursive: true });
const modelCatalogFile = codexAppModelCatalogFile(userDataDir, spec);
writeFileSync(modelCatalogFile, codexModelCatalogJson(config, profile.model), "utf8");
return { modelCatalogFile, userDataDir };
const file = codexAppModelCatalogFile(userDataDir, spec);
const content = codexModelCatalogJson(config, profile.model);
const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
if (previous !== content) {
writeFileSync(file, content, "utf8");
}
return { changed: previous !== content, file, userDataDir };
}
function launchCodexCompatibleAppProfile(

View file

@ -46,13 +46,13 @@ export type CodexModelCatalogItem = {
web_search_tool_type: string;
};
export function buildCodexModelCatalog(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): CodexModelCatalog {
export function buildCodexModelCatalog(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): CodexModelCatalog {
return {
models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config))
};
}
export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string[] {
export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string[] {
const ids: string[] = [];
pushUniqueModel(ids, normalizeModelSelector(selectedModel));
@ -98,11 +98,11 @@ export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Prov
return ids;
}
export function codexModelCatalogJson(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string {
export function codexModelCatalogJson(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string {
return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`;
}
export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string {
export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string {
const catalog = buildCodexModelCatalog(config, selectedModel);
return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64");
}
@ -110,7 +110,7 @@ export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Provid
function codexModelCatalogItem(
model: string,
priority: number,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>
): CodexModelCatalogItem {
const profile = codexModelCapabilityProfile(model, config);
const contextWindow = codexModelContextWindow(model, profile.catalogEntry);
@ -159,7 +159,7 @@ type CodexCapabilityProfile = {
function codexModelCapabilityProfile(
model: string,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>
): CodexCapabilityProfile {
const selector = parseModelSelector(model);
const provider = selector?.provider ? findConfiguredProvider(config, selector.provider) : undefined;
@ -171,7 +171,7 @@ function codexModelCapabilityProfile(
const supportsReasoning = readCatalogCapability(capabilities, "reasoning");
const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry);
const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling");
const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry)
const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config)
? "freeform"
: null;
const supportsSearchTool =
@ -275,6 +275,22 @@ function catalogModelLooksLikeGpt(model: string, entry: ModelCatalogEntry | unde
].some((value) => typeof value === "string" && value.toLowerCase().includes("gpt"));
}
function codexPatchBridgeApplies(
model: string,
entry: ModelCatalogEntry | undefined,
config?: Partial<Pick<AppConfig, "Router">>
): boolean {
const codexRule = config?.Router?.builtInRules?.codex;
if (!codexRule || codexRule.enabled === false) {
return false;
}
return !catalogModelLooksLikeGpt(modelNameForPatchBridge(model), entry);
}
function modelNameForPatchBridge(model: string): string {
return parseModelSelector(model)?.model ?? model;
}
function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined {
if (typeof value !== "string") {
return undefined;

View file

@ -36,6 +36,7 @@ import type {
ProfileRuntimeConfig,
ProxyRouteTarget,
ProxyRuntimeConfig,
RouterBuiltInRulesConfig,
RouterConfig,
RouterFallbackConfig,
RouterFallbackMode,
@ -965,6 +966,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
const models = Array.isArray(item.models)
? item.models.map((model) => readString(model)).filter((model): model is string => Boolean(model))
: [];
const modelDescriptions = parseModelDescriptions(item.modelDescriptions ?? item.model_descriptions, models);
const modelDisplayNames = parseModelDisplayNames(item.modelDisplayNames ?? item.model_display_names, models);
if (!name) {
@ -986,6 +988,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
extraHeaders: item.extraHeaders,
icon: readString(item.icon),
id: readString(item.id),
modelDescriptions,
modelDisplayNames,
models,
name,
@ -1000,6 +1003,22 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
return withProviderIds(providers);
}
function parseModelDescriptions(value: unknown, models: string[]): Record<string, string> | undefined {
if (!isObject(value)) {
return undefined;
}
const modelIds = new Set(models);
const entries = Object.entries(value)
.map(([rawModel, rawDescription]) => [rawModel.trim(), readString(rawDescription)] as const)
.filter((entry): entry is [string, string] => {
const [model, description] = entry;
return Boolean(model && description && modelIds.has(model));
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function parseModelDisplayNames(value: unknown, models: string[]): Record<string, string> | undefined {
if (!isObject(value)) {
return undefined;
@ -1171,15 +1190,13 @@ function parseRouter(value: unknown): Partial<RouterConfig> | undefined {
}
const router: Partial<RouterConfig> = {};
for (const key of ["background", "default"] as const) {
const route = readString(value[key]);
if (route) {
router[key] = route;
}
const defaultRoute = readString(value.default);
if (defaultRoute) {
router.default = defaultRoute;
}
const threshold = readNumber(value.longContextThreshold);
if (threshold !== undefined && threshold > 0) {
router.longContextThreshold = threshold;
const builtInRules = parseRouterBuiltInRules(value.builtInRules ?? value.builtinRules ?? value.agentRules);
if (builtInRules) {
router.builtInRules = builtInRules;
}
const rules = parseRouterRules(value.rules);
if (rules) {
@ -1194,6 +1211,29 @@ function parseRouter(value: unknown): Partial<RouterConfig> | undefined {
return router;
}
function parseRouterBuiltInRules(value: unknown): RouterBuiltInRulesConfig | undefined {
if (!isObject(value)) {
return undefined;
}
return {
"claude-code": parseRouterBuiltInAgentRule(value["claude-code"] ?? value.claudeCode ?? value.claude),
codex: parseRouterBuiltInAgentRule(value.codex)
};
}
function parseRouterBuiltInAgentRule(value: unknown): { enabled: boolean } {
if (typeof value === "boolean") {
return { enabled: value };
}
if (!isObject(value)) {
return { enabled: true };
}
return {
enabled: typeof value.enabled === "boolean" ? value.enabled : true
};
}
function parseRouterFallback(value: unknown): RouterFallbackConfig | undefined {
if (!isObject(value)) {
return undefined;

View file

@ -6,6 +6,7 @@ import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MO
import { replacePersistedApiKeys } from "./api-key-store";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { claudeCodeUtcTimezoneEnvOverride } from "./claude-environment";
import { writeCodexCompatibleAppModelCatalog } from "./codex-app-launch";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { codexModelCatalogJson } from "./codex-model-catalog";
import { CONFIGDIR } from "./constants";
@ -153,6 +154,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,
@ -180,9 +182,14 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
providerId
})
: undefined;
const changed = writeResult.changed || modelCatalogResult.changed || Boolean(separateProfileResult?.changed) || Boolean(middlewareResult?.changed);
const changed = writeResult.changed ||
modelCatalogResult.changed ||
appModelCatalogResult.changed ||
Boolean(separateProfileResult?.changed) ||
Boolean(middlewareResult?.changed);
const extras = [
modelCatalogFile ? `catalog ${modelCatalogFile}` : "",
appModelCatalogResult.file ? `app catalog ${appModelCatalogResult.file}` : "",
separateProfileResult?.file ? `profile ${separateProfileResult.file}` : "",
middlewareResult?.file ? `middleware ${middlewareResult.file}` : ""
].filter(Boolean);

View file

@ -17,9 +17,9 @@ import {
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
isTraySupportedPlatform,
isRoutingRewriteDraftRowValid,
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDisplayNamesForModels,
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels,
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft,
providerCredentialsFromDraft,
@ -1157,6 +1157,7 @@ function App() {
return {
...next,
modelDescriptions: patch.modelDescriptions ?? current.modelDescriptions,
modelDisplayNames: patch.modelDisplayNames,
modelsText: mergeProviderModelLists(current.selectedModels, splitLines(next.modelsText)).join("\n"),
selectedModels: [],
@ -1368,6 +1369,7 @@ function App() {
}
const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [fallbackProtocol];
const modelDescriptions = modelDescriptionsForModels(providerDraft.modelDescriptions, models);
const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models);
const selectedProtocolSet = new Set(protocolsToSave);
const capabilityCandidates = mergeProviderCapabilities(
@ -1436,6 +1438,7 @@ function App() {
account: accountConfig,
credentials: credentials.length > 0 ? credentials : undefined,
icon: providerDraft.icon.trim() || undefined,
modelDescriptions,
modelDisplayNames,
models,
name: providerName,
@ -1518,6 +1521,27 @@ function App() {
return persistConfig(next, setActionError);
}
function updateProviderModelDescription(providerIndex: number, model: string, description: string) {
const next = buildConfigUpdate((config) => {
const provider = config.Providers[providerIndex];
const models = provider ? mergeProviderModelLists(provider.models) : [];
if (!provider || !models.includes(model)) {
return config;
}
const descriptions = { ...(provider.modelDescriptions ?? {}) };
const trimmed = description.trim();
if (trimmed) {
descriptions[model] = trimmed;
} else {
delete descriptions[model];
}
provider.modelDescriptions = modelDescriptionsForModels(descriptions, models);
return config;
});
setConfigDraft(next);
void persistConfig(next, setActionError);
}
async function confirmProviderDelete() {
if (providerDeleteIndex === undefined) {
return;
@ -2807,7 +2831,8 @@ function App() {
updateFilter: updateRequestLogFilter
},
models: {
config: draftConfig
config: draftConfig,
updateModelDescription: updateProviderModelDescription
},
networking: {
clearCaptures: () => void clearProxyNetworkCaptures(),
@ -2866,6 +2891,21 @@ function App() {
moveRule: moveRoutingRule,
providers: draftConfig.Providers,
removeRule: setRoutingDeleteIndex,
updateBuiltInRule: (agent, patch) => updateConfig((config) => {
config.Router.builtInRules = normalizeRouterBuiltInRules(config.Router.builtInRules);
if (agent === "claude-code") {
config.Router.builtInRules["claude-code"] = {
...config.Router.builtInRules["claude-code"],
...patch
};
} else {
config.Router.builtInRules.codex = {
...config.Router.builtInRules.codex,
...patch
};
}
return config;
}),
updateFallback: (fallback) => updateConfig((config) => {
config.Router.fallback = normalizeRouterFallbackConfig(fallback);
return config;

View file

@ -1063,41 +1063,43 @@ function LogJsonFullscreenViewer({
<div
aria-label={`${title} ${t("Fullscreen JSON viewer")}`}
aria-modal="true"
className="network-json-fullscreen fixed inset-0 z-[80] flex min-h-0 flex-col"
className="network-json-fullscreen fixed inset-0 z-[100] flex min-h-0"
role="dialog"
>
<div className="network-json-fullscreen-header flex h-12 min-w-0 shrink-0 items-center gap-3 border-b px-4">
<span className="network-pane-title min-w-0 truncate text-[15px] font-bold">{title}</span>
{subtitle ? <span className="network-muted shrink-0 text-[12px] font-semibold">{subtitle}</span> : null}
<button
aria-label={t("Close fullscreen JSON viewer")}
className="network-control-button ml-auto flex h-7 w-7 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
onClick={onClose}
title={t("Close")}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onQueryChange={onQueryChange}
onToggleTextBody={onToggleTextBody}
preferTextBody={preferTextBody}
query={query}
title={title}
/>
<div className="network-json-fullscreen-body flex min-h-0 flex-1">
<LogBodyViewer copyLabel={copyLabel} copyText={copyText}>
<LogJsonBodyContent
expandedJsonPaths={expandedJsonPaths}
onToggleJsonPath={onToggleJsonPath}
showJsonTree={showJsonTree}
value={value}
visible={visible}
/>
</LogBodyViewer>
<div className="network-json-fullscreen-panel flex min-h-0 flex-1 flex-col overflow-hidden border">
<div className="network-json-fullscreen-header flex h-12 min-w-0 shrink-0 items-center gap-3 border-b px-4">
<span className="network-pane-title min-w-0 truncate text-[15px] font-bold">{title}</span>
{subtitle ? <span className="network-muted shrink-0 text-[12px] font-semibold">{subtitle}</span> : null}
<button
aria-label={t("Close fullscreen JSON viewer")}
className="network-control-button ml-auto flex h-7 w-7 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
onClick={onClose}
title={t("Close")}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onQueryChange={onQueryChange}
onToggleTextBody={onToggleTextBody}
preferTextBody={preferTextBody}
query={query}
title={title}
/>
<div className="network-json-fullscreen-body flex min-h-0 flex-1">
<LogBodyViewer copyLabel={copyLabel} copyText={copyText}>
<LogJsonBodyContent
expandedJsonPaths={expandedJsonPaths}
onToggleJsonPath={onToggleJsonPath}
showJsonTree={showJsonTree}
value={value}
visible={visible}
/>
</LogBodyViewer>
</div>
</div>
</div>
);

View file

@ -250,8 +250,22 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
);
}
export function ModelsView({ config }: { config: AppConfig }) {
export function ModelsView({
config,
updateModelDescription
}: {
config: AppConfig;
updateModelDescription: (providerIndex: number, model: string, description: string) => void;
}) {
const t = useAppText();
const [descriptionDraft, setDescriptionDraft] = useState("");
const [descriptionTarget, setDescriptionTarget] = useState<{
description: string;
displayName?: string;
model: string;
providerIndex: number;
providerName?: string;
}>();
const [query, setQuery] = useState("");
const rows = useMemo(() => createModelCatalogItems(config), [config]);
const normalizedQuery = query.trim().toLowerCase();
@ -260,6 +274,34 @@ export function ModelsView({ config }: { config: AppConfig }) {
[normalizedQuery, rows]
);
function openDescriptionDialog(row: (typeof rows)[number]) {
if (row.providerIndex === undefined) {
return;
}
const description = row.description ?? "";
setDescriptionDraft(description);
setDescriptionTarget({
description,
displayName: row.displayName,
model: row.model,
providerIndex: row.providerIndex,
providerName: row.providerName
});
}
function closeDescriptionDialog() {
setDescriptionDraft("");
setDescriptionTarget(undefined);
}
function saveDescriptionDialog() {
if (!descriptionTarget) {
return;
}
updateModelDescription(descriptionTarget.providerIndex, descriptionTarget.model, descriptionDraft);
closeDescriptionDialog();
}
return (
<motion.div
animate={{ opacity: 1 }}
@ -297,21 +339,55 @@ export function ModelsView({ config }: { config: AppConfig }) {
) : null}
{visibleRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="min-w-[360px]">
<div className="sticky top-0 z-10 grid h-10 grid-cols-[minmax(0,1fr)] items-center gap-3 border-b border-border/60 bg-muted/95 px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="min-w-[680px]">
<div className="sticky top-0 z-10 grid h-10 grid-cols-[minmax(0,1fr)_minmax(260px,1.5fr)] items-center gap-3 border-b border-border/60 bg-muted/95 px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="truncate">{t("Model")}</div>
<div className="truncate">{t("Description")}</div>
</div>
<div className="divide-y divide-border/60">
<AnimatePresence initial={false}>
{visibleRows.map((row) => (
<AnimatedListItem
className="grid min-h-[48px] grid-cols-[minmax(0,1fr)] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
className="grid min-h-[76px] grid-cols-[minmax(0,1fr)_minmax(260px,1.5fr)] items-start gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
key={row.key}
>
<div className="min-w-0">
<div className="truncate text-[12px] font-semibold text-foreground" title={row.displayName ?? row.model}>
{row.displayName ?? row.model}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground" title={row.providerName ? `${row.providerName}/${row.model}` : row.model}>
{row.providerName ? `${row.providerName}/${row.model}` : row.model}
</div>
{row.providerName ? (
<Badge className="mt-1 max-w-full" variant="outline">
<span className="truncate">{row.providerName}</span>
</Badge>
) : null}
</div>
<div className="min-w-0">
{row.providerIndex !== undefined ? (
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-[12px] leading-5 text-muted-foreground" title={row.description ?? ""}>
{row.description || "-"}
</div>
</div>
<Button
aria-label={`${t("Edit description")} ${row.displayName ?? row.model}`}
className="h-7 w-7 shrink-0 p-0"
onClick={() => openDescriptionDialog(row)}
title={t("Edit description")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="line-clamp-2 text-[12px] leading-5 text-muted-foreground" title={row.description ?? ""}>
{row.description || "-"}
</div>
)}
</div>
</AnimatedListItem>
))}
@ -322,10 +398,74 @@ export function ModelsView({ config }: { config: AppConfig }) {
) : null}
</CardContent>
</Card>
<ModelCatalogDescriptionDialog
draft={descriptionDraft}
onChange={setDescriptionDraft}
onClose={closeDescriptionDialog}
onSave={saveDescriptionDialog}
target={descriptionTarget}
/>
</motion.div>
);
}
function ModelCatalogDescriptionDialog({
draft,
onChange,
onClose,
onSave,
target
}: {
draft: string;
onChange: (value: string) => void;
onClose: () => void;
onSave: () => void;
target?: {
description: string;
displayName?: string;
model: string;
providerName?: string;
};
}) {
const t = useAppText();
const open = Boolean(target);
const title = target?.displayName || target?.model || t("Model");
const subtitle = target?.providerName ? `${target.providerName}/${target.model}` : target?.model;
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<DialogContent className="max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("Edit description")}</DialogTitle>
</DialogHeader>
<DialogBody className="space-y-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground" title={title}>{title}</div>
{subtitle ? <div className="truncate font-mono text-[11px] text-muted-foreground" title={subtitle}>{subtitle}</div> : null}
</div>
<Field label={t("Description")}>
<Textarea
aria-label={`${t("Description")} ${title}`}
className="min-h-[160px] resize-y text-[12px]"
onChange={(event) => onChange(event.target.value)}
placeholder={t("Describe model strengths, tradeoffs, and best-fit tasks.")}
value={draft}
/>
</Field>
</DialogBody>
<DialogFooter>
<Button onClick={onClose} type="button" variant="outline">
{t("Cancel")}
</Button>
<Button disabled={draft.trim() === (target?.description ?? "").trim()} onClick={onSave} type="button">
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ProviderAccountListCell({ provider, snapshots }: { provider: GatewayProviderConfig; snapshots: ProviderAccountSnapshot[] }) {
const t = useAppText();
const sortedSnapshots = [...snapshots].sort(compareProviderAccountSnapshots);
@ -1059,6 +1199,7 @@ function LocalAgentProviderImportPanel({
baseUrl: result.provider.baseUrl,
credentials: [],
icon: result.provider.icon ?? "",
modelDescriptions: result.provider.modelDescriptions,
modelDisplayNames: result.provider.modelDisplayNames,
modelSearch: "",
modelsText: result.provider.models.join("\n"),
@ -1237,11 +1378,12 @@ export function AddProviderForm({
...getProviderPresets().map((preset) => ({ label: t(preset.name), preset, value: preset.id }))
];
const selectableProtocols = providerSelectableProtocolsFromProbe(probe);
const configuredModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
const hasConnectivityCheckInputs = Boolean(
!localAgentImport &&
draft.baseUrl.trim() &&
draft.apiKey.trim() &&
mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText)).length > 0
configuredModels.length > 0
);
useEffect(() => {
@ -1295,6 +1437,7 @@ export function AddProviderForm({
...createDefaultProviderAccountDraft(),
baseUrl: "",
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelSearch: "",
presetId,
@ -1310,6 +1453,7 @@ export function AddProviderForm({
...createDefaultProviderAccountDraft(),
baseUrl: "",
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelSearch: "",
presetId,
@ -1329,6 +1473,7 @@ export function AddProviderForm({
...accountDraft,
baseUrl: endpoint?.baseUrl ?? "",
icon: "",
modelDescriptions: undefined,
modelDisplayNames: preset?.defaultModelDisplayNames,
modelSearch: "",
modelsText: draft.modelsText.trim() || preset?.defaultModels?.join("\n") || "",
@ -1437,6 +1582,12 @@ export function AddProviderForm({
value={splitLines(draft.modelsText)}
/>
)}
<ModelDescriptionsEditor
descriptions={draft.modelDescriptions}
displayNames={draft.modelDisplayNames}
models={configuredModels}
onChange={(modelDescriptions) => onChange({ modelDescriptions })}
/>
</Field>
<div className="sm:col-span-2 flex min-w-0 flex-wrap items-center justify-between gap-2 text-[12px] text-muted-foreground">
<div className="min-w-0 flex-1">
@ -2439,6 +2590,62 @@ function ModelTagInput({
);
}
function ModelDescriptionsEditor({
descriptions,
displayNames,
models,
onChange
}: {
descriptions?: Record<string, string>;
displayNames?: Record<string, string>;
models: string[];
onChange: (value: Record<string, string> | undefined) => void;
}) {
const t = useAppText();
const normalizedModels = mergeProviderModelLists(models);
if (normalizedModels.length === 0) {
return null;
}
function updateDescription(model: string, value: string) {
const next: Record<string, string> = {};
for (const item of normalizedModels) {
const description = (item === model ? value : descriptions?.[item] ?? "").trim();
if (description) {
next[item] = description;
}
}
onChange(Object.keys(next).length > 0 ? next : undefined);
}
return (
<div className="space-y-2 rounded-md border border-border bg-muted/20 p-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="block truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{t("Model descriptions")}</span>
<span className="shrink-0 text-[11px] leading-4 text-muted-foreground/75">{t("Used in Agent routing prompts")}</span>
</div>
<div className="grid grid-cols-1 gap-2">
{normalizedModels.map((model) => {
const label = displayNames?.[model]?.trim() || model;
return (
<div className="grid grid-cols-1 gap-1 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)] sm:items-start" key={model}>
<Label className="min-h-8 min-w-0 pt-1.5 text-[12px] font-medium text-foreground" title={model}>
<span className="block truncate">{label}</span>
</Label>
<Textarea
className="min-h-[58px] resize-y text-[12px]"
onChange={(event) => updateDescription(model, event.target.value)}
placeholder={t("Describe model strengths, tradeoffs, and best-fit tasks.")}
value={descriptions?.[model] ?? ""}
/>
</div>
);
})}
</div>
</div>
);
}
function ModelMultiSelect({
displayNames,
models,

View file

@ -4,8 +4,9 @@ import {
CardHeader, Check, CircleAlert, clampNumber, cn, createRouteModelOptions, createRoutingRewriteDraftRow,
Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle,
disclosureSpringTransition, Field, formatRouterRuleCondition, formatRouterRuleTarget, GatewayProviderConfig, Input,
motion, normalizeRouterFallbackConfig, Pencil, Plus, Route, RouterFallbackConfig,
RouterFallbackMode, routerConditionSourceOptions, routerFallbackModeOptions, RouterRule, routerRewriteOperationOptions, routerRuleOperatorOptions,
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";
@ -17,6 +18,7 @@ export function RoutingView({
moveRule,
providers,
removeRule,
updateBuiltInRule,
updateFallback,
updateRule
}: {
@ -26,13 +28,14 @@ export function RoutingView({
moveRule: (index: number, direction: -1 | 1) => void;
providers: GatewayProviderConfig[];
removeRule: (index: number) => void;
updateBuiltInRule: (agent: RouterBuiltInAgentRuleId, patch: Partial<RouterBuiltInAgentRuleConfig>) => void;
updateFallback: (fallback: RouterFallbackConfig) => void;
updateRule: (index: number, patch: Partial<RouterRule>) => void;
}) {
const t = useAppText();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const rows = useMemo(() => buildRoutingRuleRows(config), [config.Router.rules]);
const rows = useMemo(() => buildRoutingRuleRows(config), [config]);
const fallback = config.Router.fallback;
const visibleRules = useMemo(
() => rows.filter((row) => routingRuleRowMatchesQuery(row, normalizedQuery)),
@ -90,66 +93,87 @@ export function RoutingView({
<div className="truncate">{t("Condition")}</div>
<div className="truncate">{t("Request action")}</div>
<div className="truncate">{t("Status")}</div>
<div aria-hidden="true" />
<div className="truncate text-right">{t("Action")}</div>
</div>
<div className="divide-y divide-border/60">
<AnimatePresence initial={false}>
{visibleRules.map((row) => (
<AnimatedListItem
className="grid min-h-[58px] grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(240px,1.15fr)_84px_148px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
key={row.key}
>
{visibleRules.map((row) => {
const rowSourceLabel = row.builtInAgent ? t(row.sourceLabel) : row.sourceLabel;
const rowTarget = row.target === "Profile model unset" ? t(row.target) : row.target;
return (
<AnimatedListItem
className="grid min-h-[58px] grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(240px,1.15fr)_84px_148px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
key={row.key}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[12px] font-semibold">{row.name || t("Unnamed")}</div>
{row.readonly ? <Badge variant="outline">{t("Plugin")}</Badge> : null}
{row.builtInAgent ? <BuiltInRouteInfoIcon description={builtInRouteDescription(row.builtInAgent, t)} /> : null}
{row.builtInAgent ? <Badge variant="outline">{t("Built-in")}</Badge> : row.readonly ? <Badge variant="outline">{t("Plugin")}</Badge> : null}
</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={`${row.sourceLabel}: ${row.ruleId}`}>
{row.sourceLabel}: {row.ruleId}
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={`${rowSourceLabel}: ${row.ruleId}`}>
{rowSourceLabel}: {row.ruleId}
</div>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline">{t(row.typeLabel)}</Badge>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={row.condition}>
{row.condition}
</span>
</div>
{!row.builtInAgent ? (
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline">{t(row.typeLabel)}</Badge>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={row.condition}>
{row.condition}
</span>
</div>
) : null}
</div>
<div className="min-w-0 truncate font-mono text-[11px] text-muted-foreground" title={row.target}>
{row.target}
<div className="min-w-0 truncate font-mono text-[11px] text-muted-foreground" title={row.builtInAgent ? undefined : rowTarget}>
{row.builtInAgent ? null : rowTarget}
</div>
<div className="flex min-w-0 items-center gap-2">
<Toggle checked={row.enabled} disabled={row.readonly} onChange={(enabled) => row.index !== undefined && updateRule(row.index, { enabled })} />
</div>
<div className="flex items-center justify-end gap-1">
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("up")}`} disabled={row.readonly || row.index === undefined || row.index === 0} onClick={() => row.index !== undefined && moveRule(row.index, -1)} size="iconSm" title={t("Move up")} type="button" variant="ghost">
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("down")}`} disabled={row.readonly || row.index === undefined || row.index === row.ruleCount - 1} onClick={() => row.index !== undefined && moveRule(row.index, 1)} size="iconSm" title={t("Move down")} type="button" variant="ghost">
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
aria-label={`${t("Edit")} ${row.name || t("rule")}`}
disabled={row.readonly || row.index === undefined}
onClick={() => {
if (row.index !== undefined) {
editRule(row.index);
<Toggle
checked={row.enabled}
disabled={row.readonly || row.toggleDisabled}
onChange={(enabled) => {
if (row.builtInAgent) {
updateBuiltInRule(row.builtInAgent, { enabled });
} else if (row.index !== undefined) {
updateRule(row.index, { enabled });
}
}}
size="iconSm"
title={t("Edit rule")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Remove")} ${row.name || t("rule")}`} disabled={row.readonly || row.index === undefined} onClick={() => row.index !== undefined && removeRule(row.index)} size="iconSm" title={t("Remove rule")} type="button" variant="ghost">
<Trash2 className="h-3.5 w-3.5" />
</Button>
/>
</div>
</AnimatedListItem>
))}
<div className="flex items-center justify-end gap-1">
{!row.builtInAgent ? (
<>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("up")}`} disabled={row.readonly || row.index === undefined || row.index === 0} onClick={() => row.index !== undefined && moveRule(row.index, -1)} size="iconSm" title={t("Move up")} type="button" variant="ghost">
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("down")}`} disabled={row.readonly || row.index === undefined || row.index === row.ruleCount - 1} onClick={() => row.index !== undefined && moveRule(row.index, 1)} size="iconSm" title={t("Move down")} type="button" variant="ghost">
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
aria-label={`${t("Edit")} ${row.name || t("rule")}`}
disabled={row.readonly || row.index === undefined}
onClick={() => {
if (row.index !== undefined) {
editRule(row.index);
}
}}
size="iconSm"
title={t("Edit rule")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Remove")} ${row.name || t("rule")}`} disabled={row.readonly || row.index === undefined} onClick={() => row.index !== undefined && removeRule(row.index)} size="iconSm" title={t("Remove rule")} type="button" variant="ghost">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
) : null}
</div>
</AnimatedListItem>
);
})}
</AnimatePresence>
</div>
</div>
@ -161,6 +185,24 @@ export function RoutingView({
);
}
function BuiltInRouteInfoIcon({ description }: { description: string }) {
return (
<span aria-label={description} className="group relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30" tabIndex={0} title={description}>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
<span className="pointer-events-none absolute left-full top-1/2 z-[80] ml-2 hidden w-[260px] -translate-y-1/2 rounded-md border border-border bg-popover px-2.5 py-2 text-left text-[11px] font-medium leading-4 text-popover-foreground shadow-card group-hover:block group-focus:block">
{description}
</span>
</span>
);
}
function builtInRouteDescription(agent: RouterBuiltInAgentRuleId, t: (value: string) => string): string {
if (agent === "claude-code") {
return t("Routes Claude Code requests by matching the Claude user-agent and setting request.body.model to the Claude Code profile or default model.");
}
return t("Routes Codex requests by matching the Codex user-agent and setting request.body.model to the Codex profile or default model.");
}
function RouterFallbackControl({
className,
fallback,

View file

@ -643,6 +643,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Action": "操作",
"Check trust": "检查信任",
"Choose where each agent uses CCR.": "选择每个 Agent 在哪里使用 CCR。",
"Built-in": "内置",
"Routes Claude Code requests by matching the Claude user-agent and setting request.body.model to the Claude Code profile or default model.": "通过匹配 Claude user-agent 路由 Claude Code 请求,并将 request.body.model 设置为 Claude Code 配置档案模型或默认模型。",
"Routes Codex requests by matching the Codex user-agent and setting request.body.model to the Codex profile or default model.": "通过匹配 Codex user-agent 路由 Codex 请求,并将 request.body.model 设置为 Codex 配置档案模型或默认模型。",
"Click Check Connection to verify connectivity with a real model request.": "点击检测连通性,用一次真实模型请求验证是否可用。",
"Click Add to create one": "点击添加创建一项",
"Click Install to add one": "点击安装添加一项",
@ -828,10 +831,12 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Request log database": "请求日志数据库",
"Method": "方法",
"Model": "模型",
"Model descriptions": "模型描述",
"Model override": "模型覆盖",
"Model routing": "模型路由",
"Model prefix": "模型前缀",
"Models": "模型",
"Describe model strengths, tradeoffs, and best-fit tasks.": "描述模型优势、取舍和最适合的任务。",
"Module path": "模块路径",
"Name": "名称",
"Networking": "网络",
@ -877,6 +882,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Phone Bluetooth target": "手机蓝牙目标",
"Phone Wi-Fi target": "手机 Wi-Fi 目标",
"Plugin": "插件",
"Profile model unset": "未设置配置档案模型",
"Plugin apps must be a JSON array.": "插件 App 必须是 JSON 数组。",
"Plugin config JSON": "插件配置 JSON",
"Plugin config must be a JSON object.": "插件配置必须是 JSON 对象。",
@ -1348,6 +1354,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Detecting icon": "正在检测图标",
"Detecting provider": "正在检测供应商",
"Disabled": "已禁用",
"Edit description": "编辑描述",
"Expand": "展开",
"Expand models": "展开模型",
"Expires": "过期",
@ -1367,6 +1374,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"More": "更多",
"Provider models": "供应商模型",
"Runtime provider": "运行时供应商",
"Used in Agent routing prompts": "用于 Agent 路由提示词",
"Read only": "只读",
"Search all models": "搜索全部模型",
"Source": "来源",

View file

@ -18,3 +18,4 @@ export * from "./routing";
export * from "./virtual-models";
export * from "./extensions";
export * from "./providers";
export type { RouterBuiltInAgentRuleConfig, RouterBuiltInAgentRuleId, RouterBuiltInRulesConfig } from "../../../../shared/app";

View file

@ -390,26 +390,36 @@ export const localAgentProviderIconUrls: Record<LocalAgentProviderKind, string>
};
export function createModelCatalogItems(config: AppConfig): ModelCatalogItem[] {
const providerModels: string[] = [];
const displayNames: Record<string, string> = {};
for (const provider of config.Providers) {
for (const model of mergeProviderModelLists(provider.models)) {
providerModels.push(model);
const displayName = provider.modelDisplayNames?.[model]?.trim();
if (displayName && !displayNames[model]) {
displayNames[model] = displayName;
}
const rows: ModelCatalogItem[] = [];
config.Providers.forEach((provider, providerIndex) => {
const providerName = provider.name?.trim();
if (!providerName) {
return;
}
}
for (const model of mergeProviderModelLists(provider.models)) {
const description = provider.modelDescriptions?.[model]?.trim();
const displayName = provider.modelDisplayNames?.[model]?.trim();
rows.push({
...(description ? { description } : {}),
...(displayName ? { displayName } : {}),
key: `provider-model:${providerIndex}:${providerName}:${model}`,
model,
providerIndex,
providerName
});
}
});
const virtualModels = (config.virtualModelProfiles ?? [])
.filter(virtualModelIsCatalogVisible)
.flatMap(virtualModelCatalogNames);
return uniqueStrings([...providerModels, ...virtualModels]).map((model, index) => ({
...(displayNames[model] ? { displayName: displayNames[model] } : {}),
key: `model:${index}:${model}`,
model
}));
return [
...rows,
...uniqueStrings(virtualModels).map((model, index) => ({
key: `virtual-model:${index}:${model}`,
model
}))
];
}
export function virtualModelIsCatalogVisible(profile: VirtualModelProfileConfig): boolean {
@ -462,7 +472,10 @@ export function modelCatalogItemMatchesQuery(row: ModelCatalogItem, query: strin
return true;
}
return row.model.toLowerCase().includes(query) || (row.displayName ?? "").toLowerCase().includes(query);
return row.model.toLowerCase().includes(query) ||
(row.providerName ?? "").toLowerCase().includes(query) ||
(row.displayName ?? "").toLowerCase().includes(query) ||
(row.description ?? "").toLowerCase().includes(query);
}
export function createRouteModelOptions(providers: GatewayProviderConfig[]): Array<{ label: string; value: string }> {
@ -611,7 +624,7 @@ export function createRoutingRuleDraft(config?: AppConfig): AddRoutingRuleDraft
rewriteValue: rewrite.value,
rewrites: [rewrite],
target: "",
threshold: String(config?.Router.longContextThreshold || 200000),
threshold: "200000",
type: "condition"
};
}
@ -635,7 +648,7 @@ export function createRoutingRuleDraftFromRule(rule: RouterRule, config?: AppCon
rewriteValue: firstRewrite.value,
rewrites: rewrites.length ? rewrites : [firstRewrite],
target: rule.target ?? "",
threshold: String(rule.threshold ?? config?.Router.longContextThreshold ?? 200000),
threshold: String(rule.threshold ?? 200000),
type: "condition"
};
}
@ -843,6 +856,7 @@ export function createProviderDraftFromDeepLinkPayload(
baseUrl,
credentials: [],
icon: payload.icon?.trim() || "",
modelDescriptions: modelDescriptionsForModels(payload.modelDescriptions, models),
modelDisplayNames: modelDisplayNamesForModels(
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), payload.modelDisplayNames),
models
@ -905,6 +919,7 @@ export function createProviderConfigFromDeepLink(
api_key: apiKey,
capabilities: capabilities.length > 0 ? capabilities : undefined,
icon: payload.icon?.trim() || undefined,
modelDescriptions: modelDescriptionsForModels(payload.modelDescriptions, models),
modelDisplayNames: modelDisplayNamesForModels(mergeModelDisplayNames(payload.modelDisplayNames, probe?.modelDisplayNames), models),
models,
name,
@ -930,6 +945,7 @@ export function createProviderDraft(providers: GatewayProviderConfig[]): AddProv
baseUrl: "",
credentials: [],
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelSearch: "",
modelsText: "",
@ -953,6 +969,7 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
baseUrl,
credentials: (provider.credentials ?? []).map(providerCredentialDraftFromConfig),
icon: provider.icon ?? "",
modelDescriptions: modelDescriptionsForModels(provider.modelDescriptions, provider.models),
modelDisplayNames: modelDisplayNamesForModels(
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), provider.modelDisplayNames),
provider.models
@ -1502,11 +1519,13 @@ export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, prob
return accountKeySafetyIssue.message;
}
const modelDescriptions = modelDescriptionsForModels(draft.modelDescriptions, models);
const modelDisplayNames = modelDisplayNamesForModels(draft.modelDisplayNames, models);
const payload: ProviderDeepLinkPayload = {
...(account ? { account } : {}),
baseUrl,
...(draft.icon.trim() ? { icon: draft.icon.trim() } : {}),
...(modelDescriptions ? { modelDescriptions } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
name: providerName,
@ -1892,6 +1911,17 @@ export function modelDisplayNamesForModels(
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function modelDescriptionsForModels(
value: Record<string, string> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const entries = Object.entries(value ?? {})
.map(([rawModel, rawDescription]) => [rawModel.trim(), rawDescription.trim()] as const)
.filter(([model, description]) => model && description && modelIds.has(model));
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function providerModelDisplayName(provider: GatewayProviderConfig, model: string): string {
return provider.modelDisplayNames?.[model]?.trim() || model;
}

View file

@ -205,6 +205,8 @@ import type {
RequestLogListFilter,
RequestLogPage,
RequestLogStatusFilter,
RouterBuiltInAgentRuleId,
RouterBuiltInRulesConfig,
RouterConfig,
RouterFallbackConfig,
RouterFallbackMode,
@ -376,7 +378,7 @@ import type { MotionSafeDivAttributes } from "./motion";
import { positiveInteger } from "./api-keys";
import { isPlainRecord, stringValue, uniqueStrings } from "./common";
import { sanitizeConfigId } from "./extensions";
import { formatRouterRuleCondition, formatRouterRuleTarget, numberValue, routerRuleTypeLabel } from "./providers";
import { formatRouterRuleCondition, formatRouterRuleTarget, routerRuleTypeLabel } from "./providers";
import { clampNumber } from "./services";
import type { ClaudeDesignRouteRuleType, ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, PluginRoutingConfigItem, RoutingRuleRow } from "./types";
@ -388,12 +390,30 @@ export function normalizeRouterConfig(value: Partial<RouterConfig> | undefined):
const rules = normalizeRouterRules((value as Record<string, unknown> | undefined)?.rules) ?? [];
return {
...router,
builtInRules: normalizeRouterBuiltInRules((value as Record<string, unknown> | undefined)?.builtInRules),
fallback: normalizeRouterFallbackConfig((value as Record<string, unknown> | undefined)?.fallback),
longContextThreshold: Number(router.longContextThreshold) > 0 ? numberValue(String(router.longContextThreshold)) : fallbackConfig.Router.longContextThreshold,
rules
};
}
export function normalizeRouterBuiltInRules(value: unknown): RouterBuiltInRulesConfig {
const record = isPlainRecord(value) ? value : {};
return {
"claude-code": normalizeRouterBuiltInAgentRule(record["claude-code"] ?? record.claudeCode ?? record.claude),
codex: normalizeRouterBuiltInAgentRule(record.codex)
};
}
function normalizeRouterBuiltInAgentRule(value: unknown): { enabled: boolean } {
if (typeof value === "boolean") {
return { enabled: value };
}
const record = isPlainRecord(value) ? value : {};
return {
enabled: typeof record.enabled === "boolean" ? record.enabled : true
};
}
export function normalizeRouterFallbackConfig(value: Partial<RouterFallbackConfig> | unknown): RouterFallbackConfig {
const record = isPlainRecord(value) ? value : {};
const mode = parseRouterFallbackMode(record.mode) ?? fallbackConfig.Router.fallback.mode;
@ -806,19 +826,74 @@ export function claudeDesignRoutingConfigFromDraft(draft: ClaudeDesignRoutingDra
}
export function buildRoutingRuleRows(config: AppConfig): RoutingRuleRow[] {
return config.Router.rules.map((rule, index): RoutingRuleRow => ({
condition: formatRouterRuleCondition(rule),
enabled: rule.enabled,
index,
key: `router-${rule.id}-${index}`,
name: rule.name || "Unnamed",
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: rule.id,
sourceLabel: "Router",
target: formatRouterRuleTarget(rule),
typeLabel: routerRuleTypeLabel(rule.type)
}));
return [
...buildBuiltInAgentRoutingRows(config),
...config.Router.rules.map((rule, index): RoutingRuleRow => ({
condition: formatRouterRuleCondition(rule),
enabled: rule.enabled,
index,
key: `router-${rule.id}-${index}`,
name: rule.name || "Unnamed",
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: rule.id,
sourceLabel: "Router",
target: formatRouterRuleTarget(rule),
typeLabel: routerRuleTypeLabel(rule.type)
}))
];
}
export function buildBuiltInAgentRoutingRows(config: AppConfig): RoutingRuleRow[] {
return routerBuiltInAgentRuleIds.map((agent): RoutingRuleRow => {
const target = routerBuiltInAgentRouteTarget(config, agent);
const profileEnabled = Boolean(routerBuiltInAgentProfile(config, agent));
return {
builtInAgent: agent,
condition: `request.header.user-agent contains ${routerBuiltInAgentUserAgentNeedle(agent)}`,
enabled: routerBuiltInAgentRuleIsActive(config, agent),
key: `builtin-agent-${agent}`,
name: routerBuiltInAgentRuleName(agent),
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: `builtin-agent-${agent}`,
sourceLabel: "Built-in",
target: target ? `set request.body.model = ${target}` : "Profile model unset",
toggleDisabled: !profileEnabled || !target,
typeLabel: "Condition"
};
});
}
const routerBuiltInAgentRuleIds: RouterBuiltInAgentRuleId[] = ["claude-code", "codex"];
export function routerBuiltInAgentRuleIsActive(config: AppConfig, agent: RouterBuiltInAgentRuleId): boolean {
return routerBuiltInAgentRulePreferenceEnabled(config, agent) &&
Boolean(routerBuiltInAgentProfile(config, agent)) &&
Boolean(routerBuiltInAgentRouteTarget(config, agent));
}
export function routerBuiltInAgentRulePreferenceEnabled(config: AppConfig, agent: RouterBuiltInAgentRuleId): boolean {
return config.Router.builtInRules?.[agent]?.enabled !== false;
}
export function routerBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltInAgentRuleId): ProfileConfig | undefined {
if (config.profile.enabled === false) {
return undefined;
}
return config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent);
}
export function routerBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string {
return routerBuiltInAgentProfile(config, agent)?.model.trim() || config.Router.default?.trim() || "";
}
export function routerBuiltInAgentRuleName(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "Claude Code" : "Codex";
}
export function routerBuiltInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "claude" : "codex";
}
export function buildPluginRoutingRows(plugin: AppConfig["plugins"][number], pluginIndex: number): RoutingRuleRow[] {

View file

@ -204,6 +204,7 @@ import type {
RequestLogListFilter,
RequestLogPage,
RequestLogStatusFilter,
RouterBuiltInAgentRuleId,
RouterConfig,
RouterFallbackConfig,
RouterFallbackMode,
@ -421,6 +422,7 @@ export type AddProviderDraft = {
baseUrl: string;
credentials: ProviderCredentialDraft[];
icon: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
modelSearch: string;
modelsText: string;
@ -694,9 +696,12 @@ export type ExtensionListItem = {
};
export type ModelCatalogItem = {
description?: string;
displayName?: string;
key: string;
model: string;
providerIndex?: number;
providerName?: string;
};
export type PluginInstallCandidate = {
@ -715,6 +720,7 @@ export type PluginSettingsDraft = {
};
export type RoutingRuleRow = {
builtInAgent?: RouterBuiltInAgentRuleId;
condition: string;
enabled: boolean;
index?: number;
@ -726,6 +732,7 @@ export type RoutingRuleRow = {
ruleId: string;
sourceLabel: string;
target: string;
toggleDisabled?: boolean;
typeLabel: string;
};

View file

@ -446,7 +446,7 @@ export function createVirtualModelDraftFromProfile(profile: VirtualModelProfileC
}
return {
baseModelMode: "fixed",
clientToolsPolicy: profile.execution?.clientToolsPolicy === "deny" ? "deny" : "allow",
clientToolsPolicy: "allow",
customMcpServer: customMcpServerDraft,
customToolName,
description: profile.description ?? "",
@ -752,7 +752,7 @@ export function virtualModelProfileFromDraft(
displayName,
enabled: draft.enabled,
execution: {
clientToolsPolicy: draft.clientToolsPolicy,
clientToolsPolicy: "allow",
...flags,
maxToolCalls: clampNumber(maxToolCalls || Math.max(tools.length, 1), 1, 50),
maxTurns: clampNumber(maxTurns || 6, 1, 50),

View file

@ -556,7 +556,7 @@
.network-table-scroll,
.network-detail,
.network-json-fullscreen,
.network-json-fullscreen-panel,
.network-json-fullscreen-body,
.network-pane-body {
background: var(--network-panel);
@ -564,13 +564,28 @@
}
.network-json-fullscreen {
align-items: center;
background: rgba(0, 0, 0, .82);
color: var(--network-text);
justify-content: center;
padding: clamp(14px, 4.4vw, 56px) clamp(14px, 4.4vw, 84px);
}
.network-json-fullscreen-panel {
border-color: var(--network-border);
border-radius: 24px;
box-shadow: 0 24px 72px rgba(0, 0, 0, .42);
height: 100%;
max-height: 100%;
max-width: 1760px;
min-width: 0;
width: 100%;
}
.network-json-fullscreen-header {
background: var(--network-panel-alt);
border-color: var(--network-border);
box-shadow: var(--network-shadow);
box-shadow: none;
}
.network-table-header,
@ -760,6 +775,19 @@
color: color-mix(in oklab, var(--network-active-bg) 70%, var(--network-text));
}
@media (max-width: 720px), (max-height: 520px) {
.network-json-fullscreen {
padding: 0;
}
.network-json-fullscreen-panel {
border-left: 0;
border-radius: 0;
border-right: 0;
max-width: none;
}
}
.onboarding-mascot-sprite {
aspect-ratio: 1 / 1;
background-position: 0 0;

View file

@ -2,12 +2,13 @@ import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import type { AppConfig, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
import type { AppConfig, RouterBuiltInAgentRuleId, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
import { CONFIGDIR } from "../../main/constants";
type HeaderValue = string | string[] | undefined;
export type MutableRequestLike = {
builtInSubagentModel?: string;
body: Record<string, unknown>;
headers: Record<string, HeaderValue>;
log: Pick<Console, "debug" | "error" | "info" | "warn">;
@ -47,17 +48,22 @@ export class ClaudeCodeRouterPlugin {
url: string;
}): Promise<{ body: Record<string, unknown>; decision: ClaudeCodeRouteDecision }> {
const body = cloneRecord(input.body);
const sessionId = resolveSessionId(body, input.headers);
const tokenCount = calculateTokenCount(body.messages, body.system, body.tools);
const request: MutableRequestLike = {
body,
headers: input.headers,
log: console,
method: input.method,
sessionId,
tokenCount,
url: input.url
};
if (builtInAgentRouteMatches(request, this.config, "claude-code")) {
injectClaudeCodeAgentToolDescription(body, this.config);
removeClaudeCodeBillingSystemHeader(body);
request.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(body);
}
const sessionId = resolveSessionId(body, input.headers);
const tokenCount = calculateTokenCount(body.messages, body.system, body.tools);
request.sessionId = sessionId;
request.tokenCount = tokenCount;
const customModel = await this.resolveCustomRoute(request);
const configuredDecision = resolveConfiguredRouteDecision(request, this.config);
@ -179,6 +185,11 @@ function resolveConfiguredRouteDecision(
request: MutableRequestLike,
config: AppConfig
): ConfiguredRouteDecision {
const builtInSubagentDecision = resolveBuiltInClaudeCodeSubagentRouteDecision(request, config);
if (builtInSubagentDecision) {
return builtInSubagentDecision;
}
const requestedModel = readString(request.body.model);
const explicitModel = normalizeRouteSelector(requestedModel);
if (explicitModel && isKnownInlineRoute(explicitModel, config)) {
@ -186,6 +197,11 @@ function resolveConfiguredRouteDecision(
}
const router = config.Router;
const builtInDecision = resolveBuiltInAgentRouteDecision(request, config);
if (builtInDecision) {
return builtInDecision;
}
const rules = router.rules ?? [];
for (const rule of rules) {
const decision = resolveRouterRule(rule, request, router);
@ -197,6 +213,374 @@ function resolveConfiguredRouteDecision(
return { fallback: router.fallback, model: normalizeRouteSelector(router.default) ?? explicitModel, reason: "default" };
}
function resolveBuiltInClaudeCodeSubagentRouteDecision(
request: MutableRequestLike,
config: AppConfig
): ConfiguredRouteDecision | undefined {
if (!builtInAgentRouteMatches(request, config, "claude-code")) {
return undefined;
}
const target = normalizeRouteSelector(request.builtInSubagentModel);
if (!target) {
return undefined;
}
return {
fallback: config.Router.fallback,
model: target,
reason: "builtin:claude-code-subagent",
rewrite: {
key: "request.body.model",
operation: "set",
value: target
}
};
}
function resolveBuiltInAgentRouteDecision(
request: MutableRequestLike,
config: AppConfig
): ConfiguredRouteDecision | undefined {
for (const agent of builtInAgentRuleIds) {
if (!builtInAgentRouteMatches(request, config, agent)) {
continue;
}
const target = resolveBuiltInAgentRouteTarget(config, agent);
if (!target) {
continue;
}
return {
fallback: config.Router.fallback,
model: target,
reason: `builtin:${agent}`,
rewrite: {
key: "request.body.model",
operation: "set",
value: target
}
};
}
return undefined;
}
const builtInAgentRuleIds: RouterBuiltInAgentRuleId[] = ["claude-code", "codex"];
function builtInAgentRouteMatches(
request: MutableRequestLike,
config: AppConfig,
agent: RouterBuiltInAgentRuleId
): boolean {
if (config.Router.builtInRules?.[agent]?.enabled === false) {
return false;
}
if (!resolveBuiltInAgentProfile(config, agent)) {
return false;
}
const userAgent = readRequestHeader(request.headers, "user-agent")?.toLowerCase() ?? "";
return userAgent.includes(builtInAgentUserAgentNeedle(agent));
}
function resolveBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltInAgentRuleId) {
if (config.profile.enabled === false) {
return undefined;
}
return config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent);
}
function resolveBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string | undefined {
return normalizeRouteSelector(resolveBuiltInAgentProfile(config, agent)?.model) ??
normalizeRouteSelector(config.Router.default);
}
function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "claude" : "codex";
}
const ccrSubagentModelOpenTag = "<CCR-SUBAGENT-MODEL>";
const ccrSubagentModelCloseTag = "</CCR-SUBAGENT-MODEL>";
const ccrSubagentModelTagExample = `${ccrSubagentModelOpenTag}Provider/model${ccrSubagentModelCloseTag}`;
const claudeCodeBillingSystemHeaderPrefix = "x-anthropic-billing-header";
const ccrSubagentToolModelInstruction =
`CCR subagent routing is enabled. When calling this tool, the prompt parameter MUST start with ` +
`${ccrSubagentModelTagExample} on its own first line, replacing Provider/model with the best configured CCR gateway model. ` +
`CCR consumes the tag, removes it from the prompt, and routes the spawned agent request to that model. ` +
`Do not omit the tag, do not put it in description or subagent_type, and do not rely on the tool model enum for CCR-specific models. ` +
`中文要求调用该工具时prompt 参数第一行必须写入 ${ccrSubagentModelTagExample},并将 Provider/model 替换成最适合的 CCR 模型。`;
const ccrWorkflowSubagentModelInstruction =
`CCR workflow subagent routing is enabled. When this workflow creates Agent/Task subagents, each spawned agent prompt MUST start with ` +
`${ccrSubagentModelTagExample} on its own first line, replacing Provider/model with the best configured CCR gateway model. ` +
`Put the tag inside the Agent/Task prompt created by the workflow, not in the workflow description or subagent_type. ` +
`CCR consumes the tag from the spawned agent request, removes it, and routes that request to the selected model. ` +
`Do not rely on the Agent/Task model enum for CCR-specific models. ` +
`中文要求Workflow 中创建 Agent/Task 时,每个被创建 agent 的 prompt 第一行必须写入 ${ccrSubagentModelTagExample},并替换成最适合的 CCR 模型。`;
const ccrSubagentPromptFieldInstruction =
`CCR subagent routing is enabled. This prompt string MUST start with ${ccrSubagentModelTagExample} on its own first line, ` +
`with Provider/model replaced by one configured CCR gateway model. Put the subagent task after that line; CCR removes the tag before the subagent runs. ` +
`中文要求:这个 prompt 字符串第一行必须是替换后的模型标签,后面再写 subagent 任务正文。`;
type ClaudeCodeSubagentToolKind = "subagent" | "workflow";
const claudeCodeAgentToolNames = new Set(["agent", "task"]);
const claudeCodeWorkflowToolNames = new Set(["workflow"]);
function injectClaudeCodeAgentToolDescription(body: Record<string, unknown>, config: AppConfig): void {
if (!Array.isArray(body.tools)) {
return;
}
const instructions = claudeCodeAgentToolInstructions(config);
if (!instructions) {
return;
}
for (const tool of body.tools) {
if (!isRecord(tool)) {
continue;
}
const toolKind = claudeCodeSubagentToolKind(tool);
if (!toolKind) {
continue;
}
appendToolDescriptionInstruction(tool, toolKind === "workflow" ? instructions.workflow : instructions.tool);
if (toolKind === "subagent") {
appendPromptSchemaDescriptionInstruction(tool, instructions.prompt);
}
}
}
function claudeCodeSubagentToolKind(tool: Record<string, unknown>): ClaudeCodeSubagentToolKind | undefined {
const functionSpec = isRecord(tool.function) ? tool.function : undefined;
const name = readString(tool.name)?.toLowerCase() ?? readString(functionSpec?.name)?.toLowerCase();
if (!name) {
return undefined;
}
if (claudeCodeAgentToolNames.has(name)) {
return "subagent";
}
if (claudeCodeWorkflowToolNames.has(name)) {
return "workflow";
}
return undefined;
}
function appendToolDescriptionInstruction(tool: Record<string, unknown>, instruction: string): void {
if (isRecord(tool.function)) {
tool.function.description = appendDescriptionInstruction(readOptionalString(tool.function.description), instruction);
return;
}
tool.description = appendDescriptionInstruction(readOptionalString(tool.description), instruction);
}
function appendPromptSchemaDescriptionInstruction(tool: Record<string, unknown>, instruction: string): void {
const functionSpec = isRecord(tool.function) ? tool.function : undefined;
const schema = isRecord(tool.input_schema)
? tool.input_schema
: isRecord(tool.inputSchema)
? tool.inputSchema
: isRecord(functionSpec?.parameters)
? functionSpec.parameters
: undefined;
const properties = isRecord(schema?.properties) ? schema.properties : undefined;
const prompt = isRecord(properties?.prompt) ? properties.prompt : undefined;
if (!prompt) {
return;
}
prompt.description = appendDescriptionInstruction(readOptionalString(prompt.description), instruction);
}
function appendDescriptionInstruction(description: string | undefined, instruction: string): string {
const existing = description?.trim() ?? "";
if (existing.includes(ccrSubagentModelOpenTag)) {
return existing;
}
return existing ? `${existing}\n\n${instruction}` : instruction;
}
function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; tool: string; workflow: string } | undefined {
const modelRows = configuredSubagentModelDescriptionRows(config);
if (modelRows.length === 0) {
return undefined;
}
const modelList = [
"Configured CCR gateway models:",
...modelRows
].join("\n");
return {
prompt: [
ccrSubagentPromptFieldInstruction,
"",
modelList
].join("\n"),
tool: [
ccrSubagentToolModelInstruction,
"",
modelList
].join("\n"),
workflow: [
ccrWorkflowSubagentModelInstruction,
"",
modelList
].join("\n")
};
}
function configuredSubagentModelDescriptionRows(config: AppConfig): string[] {
const rows: string[] = [];
const seen = new Set<string>();
for (const provider of config.Providers) {
const providerName = provider.name?.trim();
if (!providerName || !Array.isArray(provider.models)) {
continue;
}
for (const rawModel of provider.models) {
const model = rawModel.trim();
const description = provider.modelDescriptions?.[model]?.trim();
if (!model || !description) {
continue;
}
const selector = `${providerName}/${model}`;
const key = selector.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
const displayName = provider.modelDisplayNames?.[model]?.trim();
const label = displayName && displayName !== model ? `${selector} (${displayName})` : selector;
rows.push(`- ${label}: ${singleLineText(description, 320)}`);
}
}
return rows;
}
function removeClaudeCodeBillingSystemHeader(body: Record<string, unknown>): void {
const system = body.system;
if (!Array.isArray(system) || system.length === 0) {
return;
}
const firstBlock = system[0];
const firstText = typeof firstBlock === "string"
? firstBlock
: isRecord(firstBlock) && firstBlock.type === "text" && typeof firstBlock.text === "string"
? firstBlock.text
: undefined;
if (!firstText?.startsWith(claudeCodeBillingSystemHeaderPrefix)) {
return;
}
system.shift();
if (system.length === 0) {
delete body.system;
}
}
function extractAndRemoveClaudeCodeSubagentModelTag(body: Record<string, unknown>): string | undefined {
const systemModel = extractAndRemoveSystemSubagentModelTag(body);
if (systemModel) {
return systemModel;
}
return extractAndRemoveMessageSubagentModelTag(body);
}
function extractAndRemoveSystemSubagentModelTag(body: Record<string, unknown>): string | undefined {
const system = body.system;
if (typeof system === "string") {
return extractAndRemoveSubagentModelTagFromText(system, (text) => {
body.system = text;
});
}
if (!Array.isArray(system)) {
return undefined;
}
for (let index = 0; index < system.length; index += 1) {
const block = system[index];
const model = extractAndRemoveSubagentModelTagFromContentBlock(block, (text) => {
if (typeof block === "string") {
system[index] = text;
} else if (isRecord(block)) {
block.text = text;
}
});
if (model) {
return model;
}
}
return undefined;
}
function extractAndRemoveMessageSubagentModelTag(body: Record<string, unknown>): string | undefined {
if (!Array.isArray(body.messages)) {
return undefined;
}
const limit = Math.min(body.messages.length, 2);
for (let index = 0; index < limit; index += 1) {
const message = body.messages[index];
if (!isRecord(message) || message.role !== "user") {
continue;
}
const model = extractAndRemoveSubagentModelTagFromMessage(message);
if (model) {
return model;
}
}
return undefined;
}
function extractAndRemoveSubagentModelTagFromMessage(message: Record<string, unknown>): string | undefined {
if (typeof message.content === "string") {
return extractAndRemoveSubagentModelTagFromText(message.content, (text) => {
message.content = text;
});
}
if (!Array.isArray(message.content)) {
return undefined;
}
const content = message.content;
for (let index = 0; index < content.length; index += 1) {
const block = content[index];
const model = extractAndRemoveSubagentModelTagFromContentBlock(block, (text) => {
if (typeof block === "string") {
content[index] = text;
} else if (isRecord(block)) {
block.text = text;
}
});
if (model) {
return model;
}
}
return undefined;
}
function extractAndRemoveSubagentModelTagFromContentBlock(
block: unknown,
replace: (text: string) => void
): string | undefined {
if (typeof block === "string") {
return extractAndRemoveSubagentModelTagFromText(block, replace);
}
if (!isRecord(block) || typeof block.text !== "string") {
return undefined;
}
return extractAndRemoveSubagentModelTagFromText(block.text, replace);
}
function extractAndRemoveSubagentModelTagFromText(
text: string,
replace: (text: string) => void
): string | undefined {
const openIndex = text.indexOf(ccrSubagentModelOpenTag);
if (openIndex < 0) {
return undefined;
}
const modelStart = openIndex + ccrSubagentModelOpenTag.length;
const closeIndex = text.indexOf(ccrSubagentModelCloseTag, modelStart);
if (closeIndex < 0) {
return undefined;
}
const model = normalizeRouteSelector(text.slice(modelStart, closeIndex));
if (!model) {
return undefined;
}
const nextText = `${text.slice(0, openIndex)}${text.slice(closeIndex + ccrSubagentModelCloseTag.length)}`;
replace(nextText);
return model;
}
function resolveRouterRule(
rule: RouterRule,
request: MutableRequestLike,
@ -617,6 +1001,14 @@ function conditionComparableText(value: unknown): string | undefined {
return String(value);
}
function singleLineText(value: string, maxLength: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
}
function routerRuleReason(rule: RouterRule): string {
if (rule.id.startsWith("legacy-")) {
return rule.id.replace(/^legacy-/, "");
@ -753,3 +1145,7 @@ function readHeader(value: HeaderValue): string | undefined {
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

View file

@ -3,7 +3,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createRequire } from "node:module";
import { networkInterfaces } from "node:os";
import { Readable } from "node:stream";
import { Readable, Transform } from "node:stream";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "node:path";
import type {
@ -191,6 +191,7 @@ const clientClosedRequestStatusCode = 499;
const clientDisconnectMessage = "Client connection closed before response completed.";
const localObservabilityHeaderNames = new Set([
"x-ccr-claude-app-model-rewrite",
"x-ccr-codex-patch-bridge",
"x-ccr-claude-model-discovery",
"x-ccr-cursor-openai-compat",
"x-ccr-logical-provider",
@ -205,9 +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;
@ -548,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;
@ -658,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,
@ -724,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) {
@ -748,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;
@ -882,10 +955,10 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v
...pluginService.getCoreProviderPlugins()
]);
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
...(config.virtualModelProfiles ?? []),
...pluginService.getVirtualModelProfiles()
]))), config);
])), config);
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint);
const providers = [
@ -1399,25 +1472,6 @@ function withCodexCompatibleVirtualModelProfiles(profiles: unknown[]): unknown[]
});
}
function withOptimisticVirtualModelStreams(profiles: unknown[]): unknown[] {
return profiles.map((profile) => {
if (!isRecord(profile) || profile.enabled === false) {
return profile;
}
const execution = isRecord(profile.execution) ? profile.execution : {};
if (execution.streamMode === "optimistic") {
return profile;
}
return {
...profile,
execution: {
...execution,
streamMode: "optimistic"
}
};
});
}
function fusionModelSelector(model: string): string {
const normalized = fusionModelNameFromSelector(model);
return normalized ? `${fusionModelProviderName}/${normalized}` : "";
@ -1831,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")) {
@ -2642,6 +3123,15 @@ function spawnGatewayProcess(config: AppConfig, upstreamProxyUrl: string | undef
}
function resolveGatewayEntry(): string {
const override = process.env[gatewayEntryOverrideEnv]?.trim();
if (override) {
const entry = pathResolve(override);
if (!existsSync(entry)) {
throw new Error(`${gatewayEntryOverrideEnv} points to a missing gateway entry: ${entry}`);
}
return entry;
}
for (const packageName of gatewayPackageCandidates) {
try {
return requireFromHere.resolve(packageName);
@ -2794,27 +3284,43 @@ function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[
function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] {
const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : [];
const normalized: GatewayProviderCapability[] = [];
const seen = new Set<string>();
const byProtocol = new Map<GatewayProviderProtocol, GatewayProviderCapability>();
for (const capability of capabilities) {
const type = normalizeProviderProtocol(capability.type);
const baseUrl = capability.baseUrl?.trim();
if (!type || !baseUrl) {
continue;
}
const key = `${type}\n${baseUrl}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
const item = {
...capability,
baseUrl,
type
});
};
const existing = byProtocol.get(type);
if (!existing || providerCapabilityPriority(item) < providerCapabilityPriority(existing)) {
byProtocol.set(type, item);
}
}
for (const capability of capabilities) {
const type = normalizeProviderProtocol(capability.type);
const selected = type ? byProtocol.get(type) : undefined;
if (selected && !normalized.includes(selected)) {
normalized.push(selected);
}
}
return normalized;
}
function providerCapabilityPriority(capability: GatewayProviderCapability): number {
if (capability.source === "preset") {
return 0;
}
if (capability.source === "detected") {
return 2;
}
return 1;
}
function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string {
return `${providerRuntimeId(provider)}::${protocol}`;
}
@ -3682,6 +4188,10 @@ function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function rawStringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function stringListValue(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) : [];
}

View file

@ -129,6 +129,7 @@ export type GatewayProviderConfig = {
extraHeaders?: unknown;
icon?: string;
id?: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
models: string[];
name: string;
@ -273,6 +274,7 @@ export type ProviderDeepLinkPayload = {
apiKey?: string;
baseUrl: string;
icon?: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
models: string[];
name?: string;
@ -520,16 +522,19 @@ export type RouterFallbackConfig = {
retryCount: number;
};
export type RouterBuiltInAgentRuleId = "claude-code" | "codex";
export type RouterBuiltInAgentRuleConfig = {
enabled: boolean;
};
export type RouterBuiltInRulesConfig = Record<RouterBuiltInAgentRuleId, RouterBuiltInAgentRuleConfig>;
export type RouterConfig = {
background?: string;
builtInRules: RouterBuiltInRulesConfig;
default?: string;
fallback: RouterFallbackConfig;
image?: string;
longContext?: string;
longContextThreshold: number;
rules: RouterRule[];
think?: string;
webSearch?: string;
};
export type GatewayRuntimeConfig = {

View file

@ -20,6 +20,7 @@ const maxIconLength = 8_192;
const maxManifestUrlLength = 2_048;
const maxSourceLength = 2_048;
const maxModelLength = 256;
const maxModelDescriptionLength = 1_000;
const maxModels = 300;
const providerProtocols = new Set<GatewayProviderProtocol>([
@ -147,6 +148,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa
firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"])
);
const models = readDeepLinkModels(params, payload);
const modelDescriptions = readDeepLinkModelDescriptions(params, payload, models);
const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models);
const account = readDeepLinkAccount(params, payload);
const source = boundedString(
@ -160,6 +162,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa
...(apiKey ? { apiKey } : {}),
baseUrl,
...(icon ? { icon } : {}),
...(modelDescriptions ? { modelDescriptions } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
...(name ? { name } : {}),
@ -218,6 +221,7 @@ function parseProviderPayloadFields(
firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"])
);
const models = readDeepLinkModels(params, payload);
const modelDescriptions = readDeepLinkModelDescriptions(params, payload, models);
const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models);
const account = readDeepLinkAccount(params, payload);
const source = boundedString(
@ -233,6 +237,7 @@ function parseProviderPayloadFields(
...(apiKey ? { apiKey } : {}),
baseUrl,
...(icon ? { icon } : {}),
...(modelDescriptions ? { modelDescriptions } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
...(name ? { name } : {}),
@ -563,6 +568,43 @@ function readDeepLinkModelDisplayNames(
return Object.keys(displayNames).length > 0 ? displayNames : undefined;
}
function readDeepLinkModelDescriptions(
params: URLSearchParams,
payload: Record<string, unknown> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const descriptions: Record<string, string> = {};
const addDescription = (rawModel: unknown, rawDescription: unknown) => {
const model = typeof rawModel === "string" ? rawModel.trim() : "";
const description = typeof rawDescription === "string" ? rawDescription.trim() : "";
if (!model || !description || !modelIds.has(model)) {
return;
}
if (description.length > maxModelDescriptionLength) {
throw new Error("Model description is too long.");
}
descriptions[model] = description;
};
const explicit = parseJsonValueParam(params, payload, ["modelDescriptions", "model_descriptions"]);
if (isRecord(explicit)) {
for (const [model, description] of Object.entries(explicit)) {
addDescription(model, description);
}
}
const payloadModelList = Array.isArray(payload?.models) ? payload.models : [];
for (const item of payloadModelList) {
if (!isRecord(item)) {
continue;
}
addDescription(readPayloadModelId(item), firstPayloadString(item, ["description", "desc", "summary"]));
}
return Object.keys(descriptions).length > 0 ? descriptions : undefined;
}
function readPayloadModelId(value: unknown): string | undefined {
if (!isRecord(value)) {
return undefined;

View file

@ -33,12 +33,19 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
PORT: 3456,
Providers: [],
Router: {
builtInRules: {
"claude-code": {
enabled: true
},
codex: {
enabled: true
}
},
fallback: {
mode: "off",
models: [],
retryCount: 1
},
longContextThreshold: 200000,
rules: []
},
agent: {

View file

@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { writeCodexCompatibleAppModelCatalog } from "../../src/main/codex-app-launch.ts";
test("Codex App model catalog write includes patch bridge capabilities", () => {
const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-app-catalog-"));
try {
const config = {
Providers: [
{ name: "DeepSeek", type: "openai_chat_completions", models: ["deepseek-v4-flash"] }
],
Router: {
builtInRules: {
"claude-code": { enabled: true },
codex: { enabled: true }
},
fallback: { mode: "off", models: [], retryCount: 1 },
rules: []
}
};
const profile = {
agent: "codex",
enabled: true,
id: "codex-main",
model: "DeepSeek/deepseek-v4-flash",
name: "Codex Main",
providerId: "openai-codex",
scope: "ccr",
surface: "app"
};
const result = writeCodexCompatibleAppModelCatalog(configDir, profile, config);
assert.equal(result.changed, true);
assert.equal(path.basename(result.file), "ccr-codex-model-catalog.json");
assert.equal(
result.userDataDir,
path.join(configDir, "profiles", "codex-main", "codex", ".claude-code-router", "codex-app-user-data", "codex-main")
);
const catalog = JSON.parse(readFileSync(result.file, "utf8"));
const model = catalog.models.find((item) => item.slug === "DeepSeek/deepseek-v4-flash");
assert.ok(model);
assert.equal(model.apply_patch_tool_type, "freeform");
const second = writeCodexCompatibleAppModelCatalog(configDir, profile, config);
assert.equal(second.changed, false);
assert.equal(second.file, result.file);
} finally {
rmSync(configDir, { force: true, recursive: true });
}
});

View file

@ -104,6 +104,42 @@ test("codex catalog keeps freeform apply_patch when provider advertises Response
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog enables apply_patch bridge for non-GPT models when Codex built-in route enables it", () => {
const model = catalogModelFor({
Providers: [
{ name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
],
Router: {
builtInRules: {
"claude-code": { enabled: true },
codex: { enabled: true }
},
fallback: { mode: "off", models: [], retryCount: 1 },
rules: []
}
}, "openrouter/google/gemini-2.5-pro");
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog disables apply_patch bridge for non-GPT models when the Codex built-in route is off", () => {
const model = catalogModelFor({
Providers: [
{ name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
],
Router: {
builtInRules: {
"claude-code": { enabled: true },
codex: { enabled: false }
},
fallback: { mode: "off", models: [], retryCount: 1 },
rules: []
}
}, "openrouter/google/gemini-2.5-pro");
assert.equal(model.apply_patch_tool_type, null);
});
test("codex catalog marks Fusion aliases with builtin web search as searchable", () => {
const model = catalogModelFor({
Providers: [],

View file

@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
prepareCodexApplyPatchBridgeRequest,
transformCodexApplyPatchBridgeResponseValue,
transformCodexApplyPatchBridgeSseEvent
} from "../../src/server/gateway/service.ts";
const config = {
Providers: [],
Router: {
builtInRules: {
"claude-code": { enabled: true },
codex: { enabled: true }
},
fallback: { mode: "off", models: [], retryCount: 1 },
rules: []
}
};
test("Codex patch bridge rewrites apply_patch custom tool and prior output to virtual function items", () => {
const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
const result = prepareCodexApplyPatchBridgeRequest({
body: Buffer.from(JSON.stringify({
model: "openrouter/google/gemini-2.5-pro",
tools: [
{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } }
],
input: [
{ type: "custom_tool_call", call_id: "call_patch", name: "apply_patch", input: patch },
{ type: "custom_tool_call_output", call_id: "call_patch", output: "Success" }
]
})),
config,
headers: { "user-agent": "codex-test" },
method: "POST",
path: "/v1/responses"
});
assert.ok(result);
const body = JSON.parse(result.body.toString("utf8"));
assert.equal(body.tools[0].type, "function");
assert.equal(body.tools[0].name, "virtual_apply_patch");
assert.equal(body.input[0].type, "function_call");
assert.equal(body.input[0].name, "virtual_apply_patch");
assert.deepEqual(JSON.parse(body.input[0].arguments), { patch });
assert.equal(body.input[1].type, "function_call_output");
});
test("Codex patch bridge leaves GPT models untouched", () => {
const result = prepareCodexApplyPatchBridgeRequest({
body: Buffer.from(JSON.stringify({
model: "openai/gpt-5-codex",
tools: [{ type: "custom", name: "apply_patch" }]
})),
config,
headers: { "user-agent": "codex-test" },
method: "POST",
path: "/v1/responses"
});
assert.equal(result, undefined);
});
test("Codex patch bridge discourages shell-based file edits", () => {
const result = prepareCodexApplyPatchBridgeRequest({
body: Buffer.from(JSON.stringify({
model: "provider-deepseek::openai_chat_completions/deepseek-v4-flash",
instructions: "You are Codex, a coding agent.",
tools: [
{
type: "function",
name: "exec_command",
description: "Runs a command.",
parameters: {
type: "object",
properties: {
cmd: { type: "string", description: "Shell command to execute." }
}
}
},
{ type: "function", name: "write_stdin", description: "Writes to a running session." },
{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } }
]
})),
config,
headers: { "user-agent": "codex-test" },
method: "POST",
path: "/v1/responses"
});
assert.ok(result);
const body = JSON.parse(result.body.toString("utf8"));
assert.match(body.instructions, /When modifying files, call virtual_apply_patch/);
const execCommand = body.tools.find((tool) => tool.name === "exec_command");
const writeStdin = body.tools.find((tool) => tool.name === "write_stdin");
assert.match(execCommand.description, /do not use this tool to edit files/i);
assert.match(execCommand.parameters.properties.cmd.description, /cat >, tee, sed -i/);
assert.match(writeStdin.description, /Use virtual_apply_patch for manual file changes/);
const virtualApplyPatch = body.tools.find((tool) => tool.name === "virtual_apply_patch");
assert.equal(virtualApplyPatch?.type, "function");
assert.match(virtualApplyPatch.description, /The patch field must match this Lark grammar:/);
assert.match(virtualApplyPatch.description, /start: begin_patch hunk\+ end_patch/);
assert.match(virtualApplyPatch.description, /%import common\.LF/);
assert.match(virtualApplyPatch.parameters.properties.patch.description, /update_hunk: "\*\*\* Update File: " filename LF change_move\? change\?/);
});
test("Codex patch bridge rewrites virtual function response items to apply_patch custom tool calls", () => {
const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
const result = transformCodexApplyPatchBridgeResponseValue({
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "call_patch",
name: "virtual_apply_patch",
arguments: JSON.stringify({ patch })
}
});
assert.equal(result.changed, true);
assert.deepEqual(result.value.item, {
type: "custom_tool_call",
call_id: "call_patch",
name: "apply_patch",
input: patch
});
});
test("Codex patch bridge rewrites virtual function SSE events", () => {
const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
const event = transformCodexApplyPatchBridgeSseEvent([
"event: response.output_item.done",
`data: ${JSON.stringify({
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "call_patch",
name: "virtual_apply_patch",
arguments: JSON.stringify({ patch })
}
})}`
].join("\n"));
assert.match(event, /^event: response\.output_item\.done\n/);
const data = JSON.parse(event.split("\ndata: ")[1]);
assert.equal(data.item.type, "custom_tool_call");
assert.equal(data.item.name, "apply_patch");
assert.equal(data.item.input, patch);
});

View file

@ -16,7 +16,7 @@ function base64UrlJson(value) {
.replace(/=+$/, "");
}
test("parseProviderDeepLinkPayload reads payload JSON, models, display names, and usage account mapping", () => {
test("parseProviderDeepLinkPayload reads payload JSON, models, descriptions, display names, and usage account mapping", () => {
const payload = {
account: {
connectors: {
@ -34,8 +34,11 @@ test("parseProviderDeepLinkPayload reads payload JSON, models, display names, an
model_display_names: {
"model-a": "Model A"
},
model_descriptions: {
"model-a": "Fast general-purpose model."
},
models: [
{ displayName: "Model B", id: "model-b" },
{ description: "Best at coding tasks.", displayName: "Model B", id: "model-b" },
"model-a,model-c"
],
name: "Example AI",
@ -54,6 +57,10 @@ test("parseProviderDeepLinkPayload reads payload JSON, models, display names, an
"model-a": "Model A",
"model-b": "Model B"
});
assert.deepEqual(parsed.modelDescriptions, {
"model-a": "Fast general-purpose model.",
"model-b": "Best at coding tasks."
});
assert.equal(parsed.account?.enabled, true);
assert.equal(parsed.account?.refreshIntervalMs, 60000);
assert.equal(parsed.account?.connectors?.[0]?.type, "http-json");

View file

@ -0,0 +1,492 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ClaudeCodeRouterPlugin } from "../../src/server/gateway/claude-code-router-plugin.ts";
function createRouterPlugin(options = {}) {
const agent = options.agent ?? "claude-code";
return new ClaudeCodeRouterPlugin({
CUSTOM_ROUTER_PATH: "",
Providers: [
{
modelDescriptions: options.modelDescriptions,
modelDisplayNames: options.modelDisplayNames,
models: ["claude-sonnet", "gpt-5-codex"],
name: "Provider",
type: "anthropic_messages"
}
],
Router: {
builtInRules: {
"claude-code": { enabled: options.claudeCodeRuleEnabled ?? true },
codex: { enabled: options.codexRuleEnabled ?? true }
},
default: options.defaultModel ?? "",
fallback: { mode: "off", models: [], retryCount: 1 },
rules: []
},
profile: {
enabled: options.profileRuntimeEnabled ?? true,
profiles: [
{
agent,
enabled: options.profileEnabled ?? true,
id: `${agent}-profile`,
model: options.profileModel ?? "",
name: agent,
scope: "global"
}
]
}
});
}
test("built-in Claude Code route matches user-agent case-insensitively", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default"
},
headers: {
"user-agent": "claude-code/1.0"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-sonnet");
assert.equal(result.decision.model, "Provider/claude-sonnet");
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("built-in Codex route can use Router.default when profile model is unset", async () => {
const plugin = createRouterPlugin({
agent: "codex",
defaultModel: "Provider/gpt-5-codex"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "gpt-5"
},
headers: {
"user-agent": "openai-codex test"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/gpt-5-codex");
assert.equal(result.decision.reason, "builtin:codex");
});
test("built-in agent route stays off after the user disables it", async () => {
const plugin = createRouterPlugin({
claudeCodeRuleEnabled: false,
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "claude-default");
assert.equal(result.decision.reason, "default");
});
test("built-in Claude Code route injects subagent model instructions into Agent and Task tools", async () => {
const plugin = createRouterPlugin({
modelDescriptions: {
"claude-sonnet": "Balanced coding model for everyday implementation.",
"gpt-5-codex": "Use for long refactors and repository-scale reasoning."
},
modelDisplayNames: {
"claude-sonnet": "Claude Sonnet"
},
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Start a subagent.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Agent"
},
{
description: "Start a task.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Task"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
for (const tool of result.body.tools) {
assert.match(tool.description, /<CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.description, /MUST start/);
assert.match(tool.description, /Provider\/claude-sonnet \(Claude Sonnet\): Balanced coding model/);
assert.match(tool.description, /Provider\/gpt-5-codex: Use for long refactors/);
assert.match(tool.input_schema.properties.prompt.description, /MUST start with <CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.input_schema.properties.prompt.description, /Provider\/claude-sonnet \(Claude Sonnet\): Balanced coding model/);
assert.match(tool.input_schema.properties.prompt.description, /Provider\/gpt-5-codex: Use for long refactors/);
assert.doesNotMatch(tool.input_schema.properties.prompt.description, /optionally include/);
}
});
test("built-in Claude Code route injects workflow subagent model instructions into the Workflow tool", async () => {
const plugin = createRouterPlugin({
modelDescriptions: {
"claude-sonnet": "Balanced coding model for everyday implementation.",
"gpt-5-codex": "Use for long refactors and repository-scale reasoning."
},
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Run a workflow.",
input_schema: {
properties: {
script: { description: "Workflow script.", type: "string" }
},
type: "object"
},
name: "Workflow"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const tool = result.body.tools[0];
assert.match(tool.description, /CCR workflow subagent routing is enabled/);
assert.match(tool.description, /Agent\/Task subagents/);
assert.match(tool.description, /each spawned agent prompt MUST start with <CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.description, /Provider\/claude-sonnet: Balanced coding model/);
assert.match(tool.description, /Provider\/gpt-5-codex: Use for long refactors/);
assert.equal(tool.input_schema.properties.script.description, "Workflow script.");
});
test("built-in Claude Code route injects subagent model instructions into function-style Agent tools", async () => {
const plugin = createRouterPlugin({
modelDescriptions: {
"claude-sonnet": "Balanced coding model for everyday implementation."
},
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
function: {
description: "Start a subagent.",
name: "Agent",
parameters: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
}
},
type: "function"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const tool = result.body.tools[0];
assert.match(tool.function.description, /<CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.function.parameters.properties.prompt.description, /MUST start with <CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
});
test("built-in Claude Code route skips subagent instruction injection when no model has a description", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Start a subagent.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Agent"
},
{
description: "Run a workflow.",
input_schema: {
properties: {
script: { description: "Workflow script.", type: "string" }
},
type: "object"
},
name: "Workflow"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const agentTool = result.body.tools[0];
const workflowTool = result.body.tools[1];
assert.equal(agentTool.description, "Start a subagent.");
assert.equal(agentTool.input_schema.properties.prompt.description, "Task prompt.");
assert.equal(workflowTool.description, "Run a workflow.");
assert.equal(workflowTool.input_schema.properties.script.description, "Workflow script.");
});
test("disabled built-in Claude Code route does not inject Agent tool instructions", async () => {
const plugin = createRouterPlugin({
claudeCodeRuleEnabled: false,
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Start a subagent.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Task"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const tool = result.body.tools[0];
assert.equal(tool.description, "Start a subagent.");
assert.equal(tool.input_schema.properties.prompt.description, "Task prompt.");
});
test("built-in Claude Code subagent route uses model tag from system", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: "Use <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL> for this subagent."
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-opus");
assert.equal(result.body.system, "Use for this subagent.");
assert.equal(result.decision.model, "Provider/claude-opus");
assert.equal(result.decision.reason, "builtin:claude-code-subagent");
});
test("built-in Claude Code route removes the first billing system block before subagent tag extraction", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: [
{
text: "x-anthropic-billing-header: {\"cc_is_subagent\":true}",
type: "text"
},
{
text: "Use <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL> for this subagent.",
type: "text"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-opus");
assert.deepEqual(result.body.system, [
{
text: "Use for this subagent.",
type: "text"
}
]);
assert.equal(result.decision.reason, "builtin:claude-code-subagent");
});
test("built-in Claude Code route keeps a string billing system prompt unchanged", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: "x-anthropic-billing-header: {\"cc_is_subagent\":true}"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.system, "x-anthropic-billing-header: {\"cc_is_subagent\":true}");
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("built-in Claude Code route removes only the first billing system array item", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: [
{
text: "x-anthropic-billing-header: {\"cc_is_subagent\":true}",
type: "text"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal("system" in result.body, false);
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("non-Claude-Code routes keep billing system prompts unchanged", async () => {
const plugin = createRouterPlugin({
agent: "codex",
defaultModel: "Provider/gpt-5-codex"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "gpt-5",
system: "x-anthropic-billing-header: {\"cc_is_subagent\":true}"
},
headers: {
"user-agent": "openai-codex test"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.system, "x-anthropic-billing-header: {\"cc_is_subagent\":true}");
assert.equal(result.decision.reason, "builtin:codex");
});
test("built-in Claude Code subagent route scans only the first two messages for tags", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [
{ content: "first", role: "user" },
{
content: [
{
text: "second <CCR-SUBAGENT-MODEL>Provider/claude-haiku</CCR-SUBAGENT-MODEL>",
type: "text"
}
],
role: "user"
},
{ content: "third <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL>", role: "user" }
],
model: "claude-default"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-haiku");
assert.equal(result.body.messages[1].content[0].text, "second ");
assert.match(result.body.messages[2].content, /Provider\/claude-opus/);
assert.equal(result.decision.reason, "builtin:claude-code-subagent");
});
test("built-in Claude Code subagent route ignores tags outside the first two messages", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [
{ content: "first", role: "user" },
{ content: "assistant response", role: "assistant" },
{ content: "third <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL>", role: "user" }
],
model: "claude-default"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-sonnet");
assert.match(result.body.messages[2].content, /Provider\/claude-opus/);
assert.equal(result.decision.reason, "builtin:claude-code");
});

View file

@ -3,6 +3,7 @@ import test from "node:test";
import { createDefaultAppConfig } from "../../src/shared/default-config.ts";
import {
createVirtualModelDraft,
createVirtualModelDraftFromProfile,
validateVirtualModelDraft,
virtualModelProfileFromDraft
} from "../../src/renderer/pages/home/shared/virtual-models.ts";
@ -32,11 +33,30 @@ test("Fusion draft saves multiple selected tools into one profile", () => {
assert.equal(profile.execution.matchMultimodal, true);
assert.equal(profile.execution.matchWebSearch, true);
assert.equal(profile.execution.maxToolCalls, 8);
assert.equal(profile.execution.clientToolsPolicy, "allow");
assert.equal(profile.execution.streamMode, "optimistic");
assert.equal(metadataString(profile.metadata, "fusionVision", "toolName"), "vision_understand_fusion_plus");
assert.equal(metadataString(profile.metadata, "fusionWebSearch", "toolName"), "web_search_fusion_plus");
assert.equal(metadataString(profile.metadata, "fusionTool", "mcpServerName"), "customer-tools");
});
test("Fusion default editing keeps client tools allowed", () => {
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-generated.json" });
const draft = createVirtualModelDraft(config);
draft.exactAliasesText = "fusion-default-tools";
draft.fixedModel = "provider/base-model";
draft.visionModel = "provider/vision-model";
const profile = virtualModelProfileFromDraft(draft, [], undefined);
profile.execution.clientToolsPolicy = "deny";
const editDraft = createVirtualModelDraftFromProfile(profile, config);
assert.equal(editDraft.clientToolsPolicy, "allow");
const savedProfile = virtualModelProfileFromDraft(editDraft, [], undefined);
assert.equal(savedProfile.execution.clientToolsPolicy, "allow");
});
function metadataString(metadata: Record<string, unknown> | undefined, key: string, field: string): string | undefined {
const value = metadata?.[key];
if (!value || typeof value !== "object") {