Merge branch 'dev/3.1'

This commit is contained in:
musi
2026-07-01 22:30:37 +08:00
77 changed files with 3742 additions and 897 deletions
+242 -233
View File
@@ -3,63 +3,40 @@
<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>
<p align="center">
<img src="blog/images/claude-code-router.png" alt="Claude Code Router Desktop screenshot" />
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop screenshot" />
</p>
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`.
## 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.
- Extend the gateway with plugins, proxy routes, local HTTP backends, and provider deeplinks.
## 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.
- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model.
## Documentation
Read the full documentation at [ccrdesk.top](https://ccrdesk.top/).
## 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 +50,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,229 +58,264 @@ 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 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.
## Provider Deeplink
Provider websites can open CCR and import a model provider with a custom protocol link:
```text
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:
- `name`: display name for the provider.
- `base_url`: provider API base URL.
- `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`.
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.
## Plugins
CCR has two plugin layers:
- 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.
Example wrapper plugin route:
```json
{
"plugins": [
{
"id": "local-admin-api",
"enabled": true,
"proxy": {
"routes": [
{
"id": "admin-api",
"host": "api.example.com",
"paths": ["/v1/admin"],
"upstream": "http://127.0.0.1:4510",
"stripPathPrefix": false
}
]
}
}
]
}
```
Plugin modules export a function or object with `setup(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.openSqliteStore({ filename, migrate })`
- `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)
- [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md)
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.
## 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 width="100%">
<tr>
<td align="center" width="330">
<a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ">
<img src="/docs/public/provider-icons/zhipu-cn-general.png" width="42" height="42" alt="Zhipu icon" />
<br />
<strong>Z智谱</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://aihubmix.com/">
<img src="https://www.google.com/s2/favicons?domain=aihubmix.com&amp;sz=128" width="42" height="42" alt="AIHubmix icon" />
<br />
<strong>AIHubmix</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://ai.burncloud.com">
<img src="https://www.burncloud.com/favicon.png" width="42" height="42" alt="BurnCloud icon" />
<br />
<strong>BurnCloud</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://share.302.ai/ZGVF9w">
<img src="https://www.google.com/s2/favicons?domain=302.ai&amp;sz=128" width="42" height="42" alt="302.AI icon" />
<br />
<strong>302.AI</strong>
</a>
</td>
</tr>
<tr>
<td align="center" width="330">
<a href="https://runapi.co/register?aff=IX1t">
<img src="/docs/public/provider-icons/runapi.jpg" width="42" height="42" alt="RunAPI icon" />
<br />
<strong>RunAPI</strong>
</a>
</td>
</tr>
</table>
<h4>Community Sponsors</h4>
<table width="100%">
<tr>
<td align="center" width="220">@Simon Leischnig</td>
<td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
<td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
<td align="center" width="220">@*o</td>
<td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
<td align="center" width="220">@*说</td>
</tr>
<tr>
<td align="center" width="220">@*更</td>
<td align="center" width="220">@K*g</td>
<td align="center" width="220">@R*R</td>
<td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
<td align="center" width="220">@*苗</td>
<td align="center" width="220">@*划</td>
</tr>
<tr>
<td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
<td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
<td align="center" width="220">@S*r</td>
<td align="center" width="220">@*晖</td>
<td align="center" width="220">@*敏</td>
<td align="center" width="220">@Z*z</td>
</tr>
<tr>
<td align="center" width="220">@*然</td>
<td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
<td align="center" width="220">@*苗</td>
<td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
<td align="center" width="220">@*应</td>
<td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
</tr>
<tr>
<td align="center" width="220">@*飞</td>
<td align="center" width="220">@董*</td>
<td align="center" width="220">@*汀</td>
<td align="center" width="220">@*涯</td>
<td align="center" width="220">@*:-</td>
<td align="center" width="220">@**磊</td>
</tr>
<tr>
<td align="center" width="220">@*琢</td>
<td align="center" width="220">@*成</td>
<td align="center" width="220">@Z*o</td>
<td align="center" width="220">@*琨</td>
<td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
<td align="center" width="220">@*_</td>
</tr>
<tr>
<td align="center" width="220">@Z*m</td>
<td align="center" width="220">@*鑫</td>
<td align="center" width="220">@c*y</td>
<td align="center" width="220">@*昕</td>
<td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
<td align="center" width="220">@b*g</td>
</tr>
<tr>
<td align="center" width="220">@*亿</td>
<td align="center" width="220">@*辉</td>
<td align="center" width="220">@JACK</td>
<td align="center" width="220">@*光</td>
<td align="center" width="220">@W*l</td>
<td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
</tr>
<tr>
<td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
<td align="center" width="220">@二吉吉</td>
<td align="center" width="220">@a*g</td>
<td align="center" width="220">@*林</td>
<td align="center" width="220">@*咸</td>
<td align="center" width="220">@*明</td>
</tr>
<tr>
<td align="center" width="220">@S*y</td>
<td align="center" width="220">@f*o</td>
<td align="center" width="220">@*智</td>
<td align="center" width="220">@F*t</td>
<td align="center" width="220">@r*c</td>
<td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
</tr>
<tr>
<td align="center" width="220">@*军</td>
<td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
<td align="center" width="220">@*王</td>
<td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
<td align="center" width="220">@*王</td>
<td align="center" width="220">@zcutlip</td>
</tr>
<tr>
<td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
<td align="center" width="220">@*更</td>
<td align="center" width="220">@*.</td>
<td align="center" width="220">@F*t</td>
<td align="center" width="220">@*政</td>
<td align="center" width="220">@*铭</td>
</tr>
<tr>
<td align="center" width="220">@*叶</td>
<td align="center" width="220">@七*o</td>
<td align="center" width="220">@*青</td>
<td align="center" width="220">@**晨</td>
<td align="center" width="220">@*远</td>
<td align="center" width="220">@*霄</td>
</tr>
<tr>
<td align="center" width="220">@**吉</td>
<td align="center" width="220">@**飞</td>
<td align="center" width="220">@**驰</td>
<td align="center" width="220">@x*g</td>
<td align="center" width="220">@**东</td>
<td align="center" width="220">@*落</td>
</tr>
<tr>
<td align="center" width="220">@哆*k</td>
<td align="center" width="220">@*涛</td>
<td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
<td align="center" width="220">@*呢</td>
<td align="center" width="220">@d*u</td>
<td align="center" width="220">@crizcraig</td>
</tr>
<tr>
<td align="center" width="220">s*s</td>
<td align="center" width="220">*火</td>
<td align="center" width="220">*勤</td>
<td align="center" width="220">**锟</td>
<td align="center" width="220">*涛</td>
<td align="center" width="220">**明</td>
</tr>
<tr>
<td align="center" width="220">*知</td>
<td align="center" width="220">*语</td>
<td align="center" width="220">*瓜</td>
<td align="center" width="220"></td>
<td align="center" width="220"></td>
<td align="center" width="220"></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
+241 -233
View File
@@ -3,63 +3,39 @@
<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>
<p align="center">
<img src="blog/images/claude-code-router.png" alt="Claude Code Router Desktop 项目截图" />
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop 项目截图" />
</p>
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://127.0.0.1:3456`
## 为什么使用 CCR
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
- 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。
- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。
- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。
- 使用桌面 UI 管理配置,减少手写 JSON。
- 通过插件、代理路由、本地 HTTP 后端和 Provider deeplink 扩展网关能力。
## 功能和特性
- **桌面控制台**:启动或停止本地网关,查看用量,配置托盘窗口和运行时设置
- **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 配置,写入前会弹出确认。
- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型
## 文档
完整文档见 [ccrdesk.top](https://ccrdesk.top/)。
## 下载和安装
### 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 +49,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,229 +57,264 @@ 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** 配置插件
## Provider Deeplink
Provider 网站可以通过自定义协议打开 CCR 并导入模型服务配置:
```text
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 参数:
- `name`Provider 展示名称。
- `base_url`Provider API Base URL。
- `api_key`:可选 Provider API Key。
- `models`:逗号或换行分隔的模型列表,也可以重复传入 `models=...`
- `protocol``openai_chat_completions``openai_responses``anthropic_messages``gemini_generate_content`
更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段。CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。
## 插件
CCR 有两层插件:
- Core gateway plugins:使用 `providerPlugins``virtualModelProfiles`,会透传给 core gateway。
- Wrapper plugins:使用顶层 `plugins` 扩展 Electron wrapper,注册本地 HTTP 后端、添加 gateway route,或把代理模式流量路由到插件后端。
Wrapper plugin route 示例:
```json
{
"plugins": [
{
"id": "local-admin-api",
"enabled": true,
"proxy": {
"routes": [
{
"id": "admin-api",
"host": "api.example.com",
"paths": ["/v1/admin"],
"upstream": "http://127.0.0.1:4510",
"stripPathPrefix": false
}
]
}
}
]
}
```
插件模块需要导出函数或包含 `setup(ctx)` 的对象。上下文支持:
- `ctx.registerGatewayRoute({ method, path, auth, handler })`
- `ctx.registerHttpBackend({ id, host, port, handler })`
- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
- `ctx.openSqliteStore({ filename, migrate })`
- `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)
- [也许我们可以用路由器做更多事情](blog/zh/或许我们能在Router中做更多事情.md)
**设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model``resolved provider``resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态
## 致谢
对 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 width="100%">
<tr>
<td align="center" width="330">
<a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ">
<img src="/docs/public/provider-icons/zhipu-cn-general.png" width="42" height="42" alt="智谱图标" />
<br />
<strong>Z智谱</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://aihubmix.com/">
<img src="https://www.google.com/s2/favicons?domain=aihubmix.com&amp;sz=128" width="42" height="42" alt="AIHubmix 图标" />
<br />
<strong>AIHubmix</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://ai.burncloud.com">
<img src="https://www.burncloud.com/favicon.png" width="42" height="42" alt="BurnCloud 图标" />
<br />
<strong>BurnCloud</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://share.302.ai/ZGVF9w">
<img src="https://www.google.com/s2/favicons?domain=302.ai&amp;sz=128" width="42" height="42" alt="302.AI 图标" />
<br />
<strong>302.AI</strong>
</a>
</td>
</tr>
<tr>
<td align="center" width="330">
<a href="https://runapi.co/register?aff=IX1t">
<img src="/docs/public/provider-icons/runapi.jpg" width="42" height="42" alt="RunAPI 图标" />
<br />
<strong>RunAPI</strong>
</a>
</td>
</tr>
</table>
<h4>社区赞助者</h4>
<table width="100%">
<tr>
<td align="center" width="220">@Simon Leischnig</td>
<td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
<td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
<td align="center" width="220">@*o</td>
<td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
<td align="center" width="220">@*说</td>
</tr>
<tr>
<td align="center" width="220">@*更</td>
<td align="center" width="220">@K*g</td>
<td align="center" width="220">@R*R</td>
<td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
<td align="center" width="220">@*苗</td>
<td align="center" width="220">@*划</td>
</tr>
<tr>
<td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
<td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
<td align="center" width="220">@S*r</td>
<td align="center" width="220">@*晖</td>
<td align="center" width="220">@*敏</td>
<td align="center" width="220">@Z*z</td>
</tr>
<tr>
<td align="center" width="220">@*然</td>
<td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
<td align="center" width="220">@*苗</td>
<td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
<td align="center" width="220">@*应</td>
<td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
</tr>
<tr>
<td align="center" width="220">@*飞</td>
<td align="center" width="220">@董*</td>
<td align="center" width="220">@*汀</td>
<td align="center" width="220">@*涯</td>
<td align="center" width="220">@*:-</td>
<td align="center" width="220">@**磊</td>
</tr>
<tr>
<td align="center" width="220">@*琢</td>
<td align="center" width="220">@*成</td>
<td align="center" width="220">@Z*o</td>
<td align="center" width="220">@*琨</td>
<td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
<td align="center" width="220">@*_</td>
</tr>
<tr>
<td align="center" width="220">@Z*m</td>
<td align="center" width="220">@*鑫</td>
<td align="center" width="220">@c*y</td>
<td align="center" width="220">@*昕</td>
<td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
<td align="center" width="220">@b*g</td>
</tr>
<tr>
<td align="center" width="220">@*亿</td>
<td align="center" width="220">@*辉</td>
<td align="center" width="220">@JACK</td>
<td align="center" width="220">@*光</td>
<td align="center" width="220">@W*l</td>
<td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
</tr>
<tr>
<td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
<td align="center" width="220">@二吉吉</td>
<td align="center" width="220">@a*g</td>
<td align="center" width="220">@*林</td>
<td align="center" width="220">@*咸</td>
<td align="center" width="220">@*明</td>
</tr>
<tr>
<td align="center" width="220">@S*y</td>
<td align="center" width="220">@f*o</td>
<td align="center" width="220">@*智</td>
<td align="center" width="220">@F*t</td>
<td align="center" width="220">@r*c</td>
<td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
</tr>
<tr>
<td align="center" width="220">@*军</td>
<td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
<td align="center" width="220">@*王</td>
<td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
<td align="center" width="220">@*王</td>
<td align="center" width="220">@zcutlip</td>
</tr>
<tr>
<td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
<td align="center" width="220">@*更</td>
<td align="center" width="220">@*.</td>
<td align="center" width="220">@F*t</td>
<td align="center" width="220">@*政</td>
<td align="center" width="220">@*铭</td>
</tr>
<tr>
<td align="center" width="220">@*叶</td>
<td align="center" width="220">@七*o</td>
<td align="center" width="220">@*青</td>
<td align="center" width="220">@**晨</td>
<td align="center" width="220">@*远</td>
<td align="center" width="220">@*霄</td>
</tr>
<tr>
<td align="center" width="220">@**吉</td>
<td align="center" width="220">@**飞</td>
<td align="center" width="220">@**驰</td>
<td align="center" width="220">@x*g</td>
<td align="center" width="220">@**东</td>
<td align="center" width="220">@*落</td>
</tr>
<tr>
<td align="center" width="220">@哆*k</td>
<td align="center" width="220">@*涛</td>
<td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
<td align="center" width="220">@*呢</td>
<td align="center" width="220">@d*u</td>
<td align="center" width="220">@crizcraig</td>
</tr>
<tr>
<td align="center" width="220">s*s</td>
<td align="center" width="220">*火</td>
<td align="center" width="220">*勤</td>
<td align="center" width="220">**锟</td>
<td align="center" width="220">*涛</td>
<td align="center" width="220">**明</td>
</tr>
<tr>
<td align="center" width="220">*知</td>
<td align="center" width="220">*语</td>
<td align="center" width="220">*瓜</td>
<td align="center" width="220"></td>
<td align="center" width="220"></td>
<td align="center" width="220"></td>
</tr>
</table>
<sub>如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。</sub>
</div>
## 许可证
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+3 -2
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();
---
@@ -5,12 +5,6 @@ eyebrow: Detailed Configuration
lead: Manage API keys that clients use to access the CCR gateway, with expiration and local limits.
---
## Basic Concept
The API Keys page manages client access keys for CCR. These keys are different from provider `API key` values and provider credential pools: API Keys control who can call CCR; provider keys control how CCR calls upstream providers.
When API keys are configured, clients should send `Authorization: Bearer <key>` or `x-api-key: <key>`.
## List Fields
| Field | Capability |
@@ -5,10 +5,6 @@ eyebrow: Detailed Configuration
lead: Forward agent messages to instant-messaging platforms or hand off active work after desktop idle.
---
## What Bots Do
Bots forward agent messages to instant-messaging platforms and can hand off active work after the desktop has been idle.
## Common Modes
- **Forward agent messages**: mirror agent messages into IM.
@@ -5,7 +5,7 @@ eyebrow: Detailed Configuration
lead: Learn how CCR extensions are loaded, what they can register, and how to create, install, and debug your own extension.
---
## What Extensions Are
## Extension Types
CCR has two extension layers:
@@ -177,7 +177,7 @@ The recommended flow is through the desktop UI:
4. Save the config.
5. Open **Server** and restart the gateway.
CCR stores runtime configuration in SQLite, so add extensions through the UI instead of editing the legacy JSON config file. The extension entry has this shape:
CCR stores runtime configuration in SQLite. Add extensions through the UI; the legacy JSON config file is kept here only as a reference. The extension entry has this shape:
```json
{
@@ -270,7 +270,7 @@ Proxy route matching rules:
| Response is 401 | Routes require gateway API key by default; set `auth: "none"` for debug routes |
| Code changes do not apply | Wrapper plugins are not hot reloaded; restart the gateway or CCR |
| Port is already in use | Omit `port` in `registerHttpBackend` so CCR can allocate one automatically |
| Proxy route is not hit | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname |
| Proxy route misses requests | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname |
## Security Notes
@@ -5,12 +5,6 @@ eyebrow: Detailed Configuration
lead: Customize the CCR home dashboard for system status, account balance, requests, tokens, cost, model distribution, and share cards.
---
## Basic Concept
Overview is the CCR dashboard. It turns gateway status, provider accounts, request usage, and analysis results into draggable widgets. You can switch the time range, edit widgets, resize them, change styles, and generate shareable cards from selected data.
Overview depends on request logs, usage stats, and provider account usage. Some widgets require Request logs or Agent observability to be enabled in Settings. Account balance widgets require `Fetch usage` on providers.
## Top Controls
| Field | Capability |
@@ -5,11 +5,16 @@ eyebrow: Detailed Configuration
lead: Create reusable launch configurations for Claude Code, Codex, and ZCode, and open separate agent instances from different configs.
---
## What Agent Config Is
## Configuration Flow
Agent Config is the desktop app capability for managing Claude Code, Codex, and ZCode launch entries. It is not a provider or a routing rule; it is the full entry point for one agent launch: agent type, entry mode, model, effect scope, config file location, and optional Bot binding.
1. Add at least one usable provider and model in **Provider Config**, or create the Fusion model you want to use.
2. Open **Agent Config** and click **Add profile**.
3. Choose the agent type, name the config, then choose the effect scope and entry mode.
4. Select a model. The value is usually `Provider name/model name`, and Fusion models can be selected too.
5. If the entry mode includes App, optionally bind a Bot and choose whether to forward agent messages or enable handoff.
6. Save the config, then open it from the Agent Config card: the terminal button copies the CLI command, and the play button starts the App instance.
This page exists in Detailed Configuration to explain which config opens which agent instance, rather than provider, routing, or Fusion fields.
During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, or ZCode setup you open directly from the system.
## Multi-Instance Mechanism
@@ -26,20 +31,80 @@ This lets you create multiple configs for the same agent, such as "Claude Code -
## Common Options
| Option | Description |
| Option | Applies to | Description |
| --- | --- | --- |
| Agent | All | Claude Code, Codex, or ZCode. ZCode supports App only. |
| Config name | All | Identifies the config in CCR and can be used as the `ccr <config-name>` launch target. Names can contain spaces; copied commands are quoted automatically. |
| Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. |
| Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. |
| Entry mode | Claude Code, Codex | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. |
| Model | All | Default model for the opened agent, either a provider model or Fusion model. For Claude Code, leaving it empty keeps the Claude Code default. |
| Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. |
| Environment variables | All | Extra environment variables injected into this config. Claude Code includes `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` by default so gateway model discovery is enabled. |
## Per-Agent Options
### Claude Code
| Option | What it does |
| --- | --- |
| Agent | Claude Code, Codex, or ZCode |
| Config name | Identifies the config in CCR and can be used as the `ccr <config-name>` launch target |
| Effect scope | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config |
| Entry mode | `CLI & APP`, `CLI only`, or `App only`; ZCode supports App only |
| Model | Default model for the opened agent, either a provider model or Fusion model |
| Bot | App entry can bind a Bot for IM forwarding or handoff |
| Model override | Writes `ANTHROPIC_MODEL` for Claude Code. Leave it empty to keep Claude Code's own default model. |
| Small fast model | Writes `ANTHROPIC_SMALL_FAST_MODEL` for Claude Code lightweight tasks. Leave it empty to keep the Claude Code default. |
| Settings file | System-default mode uses the Claude Code default settings file; Only opened from CCR creates an isolated settings file under CCR's config directory, separated by Agent Config `id`. |
| Environment variables | Merged into the Claude Code settings `env`. CCR also writes the gateway endpoint, API key helper, and launch wrapper. |
| Bot | Applies only to the Claude App entry. Select a saved Bot, then choose message forwarding or handoff. |
After Claude Code CLI is opened from CCR, it uses CCR gateway model discovery. In Claude Code CLI, enter `/model` to view and switch the models exposed by CCR, including normal provider models and visible Fusion models.
Claude App is **zero-config**: when CCR opens Claude App from the desktop app, CCR automatically writes the Claude App gateway config, API key, model discovery list, and isolated user-data directory. No extra user action is required; opening Claude App from CCR automatically completes all necessary configuration. If Claude App is already running, restart it or reopen it from CCR when prompted.
Claude App and Claude Code CLI use different model-list adapters:
| Entry | Model list source | Notes |
| --- | --- | --- |
| Claude Code CLI | CCR gateway model discovery | Use `/model` in the CLI to view the list; selected requests still go through CCR providers, routing, and Fusion. |
| Claude App | CCR-generated Claude App inference models | Claude App needs Claude-compatible model names. CCR maps `Provider/model` and Fusion models into model entries Claude App can recognize, while display labels keep the real model meaning visible. |
### Codex
| Option | What it does |
| --- | --- |
| Provider ID | Writes Codex `model_provider`, defaulting to `claude-code-router`. Keep it stable and use only letters, numbers, dots, underscores, or hyphens. |
| Provider name | Display name shown in Codex, defaulting to `Claude Code Router`. |
| Codex model | Default Codex model. It can be a provider model or Fusion model; if left empty, CCR uses the first available default model. |
| Show all sessions | Lets Codex show all sessions. ZCode does not expose this option. |
| Config file | Defaults to `~/.codex/config.toml`. Only opened from CCR writes into CCR-managed isolated config directories. |
| Environment variables | Injected into Codex CLI or Codex App. Claude Code-specific model discovery variables are not passed to Codex. |
| Bot | Applies only to the Codex App entry. |
After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr "Codex - Work"`. Use the play button to open Codex App. CCR generates `config.toml`, a model catalog file, and a middleware launcher so Codex CLI and Codex App use the same CCR model and provider information.
### ZCode
| Option | What it does |
| --- | --- |
| Provider ID | Writes the ZCode provider reference, defaulting to `claude-code-router`. |
| Provider name | Display name shown in ZCode, defaulting to `Claude Code Router`. |
| ZCode model | Default model when ZCode App opens. It can be a provider model or Fusion model. |
| Config file | Defaults to `~/.zcode/cli/config.json`; CCR also writes ZCode v2 config and model cache. |
| Environment variables | Injected into ZCode App and the middleware launcher. |
| Bot | Applies only to the ZCode App entry. |
ZCode supports App only, so its entry mode is fixed to `App only`. The `Show all sessions` option is hidden for ZCode.
## CLI And App Modes
| Mode | How to open | Best for | Key differences |
| --- | --- | --- | --- |
| CLI | Click the terminal button to copy the command, then run `ccr <config-name>` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. |
| App | Click the play button in the CCR desktop app | Desktop windows, side-by-side instances, Bot forwarding, handoff | Uses a separate user-data directory per Agent Config; reopening the same config activates the existing window, while different configs can run in parallel. |
| CLI & APP | One config exposes both CLI and App entry points | Reusing the same model config in both terminal and desktop App workflows | Both entries share the config name, model, effect scope, and environment variables, but launch differently. |
## Agent Differences
### Claude Code
Claude Code config writes a settings file. With **Only opened from CCR**, CCR creates an isolated settings file under its own config directory and opens Claude Code through a separate launch wrapper.
Claude Code CLI config writes a settings file. With **Only opened from CCR**, CCR creates an isolated settings file under its own config directory and opens Claude Code through a separate launch wrapper.
When opening Claude App from the desktop app, CCR also prepares a separate user-data directory for that config. Different Agent Config entries use different directories, so multiple Claude App instances can run at the same time.
@@ -70,6 +70,10 @@ Choose a provider below to get started. CCR shows what will be added before savi
<span class="provider-import-icon-shell"><img src="../../../provider-icons/siliconflow.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">SiliconFlow</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-runapi" href="ccr://provider?name=RunAPI&amp;base_url=https%3A%2F%2Frunapi.co%2Fv1&amp;protocol=openai_responses" aria-label="Import RunAPI provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/runapi.jpg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">RunAPI</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
</a>
</div>
## Embeddable Button Component
@@ -5,20 +5,54 @@ eyebrow: Detailed Configuration
lead: Configure upstream model services, credentials, protocols, base URLs, and model lists.
---
## Basic Concept
## Import Local Agent Login
A provider is an upstream model service. One provider config describes the upstream address, protocol capabilities, model list, authentication, optional multi-key rotation, and optional account usage fetching.
When you add a provider, CCR scans for reusable local agent login state. If usable credentials are found, the add dialog shows the matching import entry. Importing creates a normal provider plus provider plugins, so CCR can reuse the local agent authorization without requiring a pasted API key.
Each provider needs at least a name, API endpoint, usable protocol, model list, and one valid credential. The add/edit dialog changes which fields are visible depending on preset providers, custom endpoints, local agent login import, and advanced settings.
### Claude Code
Preset providers fill common endpoints, protocols, models, and usage-fetching settings, so they are the recommended starting point. For custom providers, enter the endpoint manually. CCR uses the endpoint and API key to probe protocol compatibility and help identify whether the upstream works with OpenAI Chat, OpenAI Responses, Anthropic Messages, or Gemini Generate.
Claude Code import reads local Claude Code OAuth credentials. When a usable access token is available, CCR can import it as a `Claude Code API` provider.
After import:
1. The protocol is `anthropic_messages`.
2. The default model list includes `claude-sonnet-4-20250514`; you can later add or remove models in the provider model list.
3. CCR creates OAuth provider plugins that convert requests to use the Claude Code login state.
4. Account usage uses the Anthropic OAuth usage endpoint, so quota state can appear in the provider list, tray, and account panels.
If CCR only detects login traces but no usable access token, the import entry shows why it cannot be imported. Re-authenticate in Claude Code, then return to CCR and add the provider again.
### Codex
Codex import reads the local Codex auth file and model cache. When a Codex access token or refresh token is available, CCR can import it as a `Codex API` provider.
After import:
1. The protocol is `openai_responses`.
2. The API endpoint points to the Codex backend. The model list always includes at least `gpt-5-codex` and also merges models and display names from the local model cache.
3. CCR creates Codex OAuth provider plugins and refreshes access credentials when needed.
4. Account usage reads Codex quota, balance, and token-stat endpoints.
After import, select `Codex API/model-name` in routing or Agent Config. If the model cache is stale, open Codex first so it refreshes the model list, then return to CCR to import again or edit the models.
### ZCode
ZCode import reads provider API keys, API endpoints, and model lists from local ZCode config. It can be imported as a `ZCode API` provider only when CCR finds a usable provider key and Base URL.
After import:
1. The protocol is `anthropic_messages`.
2. Models come from local ZCode config first; if none are configured, CCR uses the ZCode runtime cache or default models.
3. CCR creates API-key provider plugins that use the key from local ZCode config for request authentication.
4. If the API endpoint matches a built-in CCR preset, account usage settings are reused from that preset.
If CCR detects ZCode login state but no usable provider API key, the import entry remains unavailable. Configure a usable model provider in ZCode first, then return to CCR and add the provider.
## Main Fields
| Field | Capability |
| --- | --- |
| Select preset provider | Applies a built-in provider template, including default endpoint, supported protocols, default models, icon, provider website, and sometimes account usage settings. Choose `Other / custom API endpoint` for any OpenAI, Anthropic, or Gemini compatible upstream. |
| Import local agent login | Appears while adding a provider if CCR finds usable Claude Code, Codex, or ZCode login state on this computer. Importing creates a provider and provider plugin that reuse the local login credential instead of a normal pasted API key. |
| Name | Internal CCR display name. It is also used by routing, model selectors, logs, and config references. Names must be unique. |
| API endpoint | Upstream API base URL. It controls where requests are sent, and is also used for protocol probing, model discovery, icon detection, and safety checks. Preset providers hide it by default while adding, but it can be overridden in Advanced settings. Custom providers must provide it. |
| API key | Default provider credential. When the credential pool is empty, model requests use this key. Protocol probing, model discovery, connection checks, and default usage fetching also use it. Only use a key issued for the selected endpoint. |
@@ -27,13 +61,13 @@ Preset providers fill common endpoints, protocols, models, and usage-fetching se
| Custom models | Manually adds model IDs that discovery did not return. Use this when the provider lacks a `/models` endpoint or a new model is not in the catalog yet. |
| Check Connection | Sends real test requests with the current endpoint, API key, protocol, and selected models. It verifies key, model name, and protocol usability. |
| Models to check | Model selection inside the connection-check confirmation dialog. Use it to test only some models. |
| Check results | Shows whether each model is available, which protocol matched, and the upstream diagnostic message. Passing results do not automatically add models; the main model selection remains authoritative. |
| Check results | Shows whether each model is available, which protocol matched, and the upstream diagnostic message. Results are diagnostic. Add models through the main model selection when you want them saved. |
## Connectivity Checks
`Check Connection` sends real model requests for the models you select. It verifies whether the endpoint, API key, protocol, and model IDs are usable. The check limits generated output, but it can still create extra token usage or count against provider-side request limits.
If the provider bills by request, input tokens, or output tokens, select only the models you need to verify instead of checking every model at once. Check results are diagnostic only; they do not automatically change the model list or usage-fetching settings.
If the provider bills by request, input tokens, or output tokens, select only the models you need to verify. Checking every model at once can create unnecessary usage. Review the diagnostics, then adjust the model list or usage-fetching settings manually when needed.
## Credentials
@@ -90,7 +124,7 @@ This mode tries provider-hosted CCR account endpoints such as `/.well-known/ccr/
### HTTP JSON Request
Use this mode when the provider has a balance or quota JSON endpoint that does not match CCR's standard account format.
Use this mode when the provider has a balance or quota endpoint that returns a custom JSON shape.
| Field | Capability |
| --- | --- |
+131 -28
View File
@@ -5,20 +5,141 @@ 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.
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. If that model is unset, the built-in route remains inactive. 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.
## What Fallback Does
#### Subagent / Workflow Auto-Routing
Fallback is the failure strategy after a model or upstream request fails. It does not pick the first model; it decides whether CCR should keep trying after the current target fails.
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
Fallback is the failure strategy after a model or upstream request fails. Routing picks the first model; Fallback decides whether CCR should keep trying after the current target fails.
The **Default on failure** control at the top of the Routing page is the global Fallback. Each rule also has **On failure**. When a rule matches, its rule-level Fallback overrides the global Fallback.
@@ -55,7 +176,7 @@ Configure **Default on failure** at the top of the Routing page:
2. If you choose **Retry**, set `Retries`.
3. If you choose **Fallback targets**, add backup models in priority order.
Global Fallback applies to the default route and to rules that do not define their own Fallback.
Global Fallback applies to routing rules that do not define their own Fallback.
### Rule-Level Fallback
@@ -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:
@@ -5,10 +5,6 @@ eyebrow: Detailed Configuration
lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception and proxying into CCR.
---
## Basic Concept
The Server page controls how the local CCR gateway listens for requests. Normal model requests usually need only `Host` and `Port`.
## Main Fields
| Field | Capability |
@@ -5,10 +5,6 @@ eyebrow: Detailed Configuration
lead: Configure the CCR system tray icon, balance progress, and tray window widgets.
---
## Basic Concept
Tray settings live under Settings. They control the system tray icon and the tray window. The tray window can show providers, account balance, token trends, activity, metrics, and model share so you can check CCR status without opening the main window.
## Top Fields
| Field | Capability |
-13
View File
@@ -5,19 +5,6 @@ eyebrow: Product Documentation
lead: Learn what CCR is for, where its boundaries are, and how the docs are organized. Start with Quick Start when you want to configure it; use Detailed Configuration for fields, Bots, or Fusion.
---
## What CCR Can Do
**Claude Code Router (CCR) is a local model gateway.** It sits between agents such as Claude Code, Codex, and ZCode and upstream model services, then centralizes model management, API keys, routing rules, logs, observability, and Bot relay.
CCR is useful when:
- you do not want to maintain the same models and keys separately in every agent.
- you want different tasks to automatically use different models: fast models for lightweight background work, stronger models for complex tasks, and Fusion for image or web-search work.
- you need request logs showing which provider and model handled a request, whether it succeeded, how long it took, and roughly how much it cost.
- you want to forward long-running agent messages to Slack, Telegram, Feishu, WeCom, or other IM platforms.
CCR listens on the local default address `http://localhost:8080`. Once an agent points to this address, CCR can take over the request and forward it to upstream providers according to routing rules.
## Documentation Structure
The top navigation is split into four standalone pages:
@@ -13,7 +13,7 @@ LINE 适合把 Agent 消息接入已有的 LINE 好友、群聊或 LINE Official
## 你会用到哪些字段
CCR 里 LINE 的认证方式叫 **Bot Token**但填的不是 Telegram 那种 token,而是下面这两个 channel 字段:
CCR 里 LINE 的认证方式叫 **Bot Token**这里需要填写下面这两个 channel 字段:
| LINE 后台里的名字 | CCR 字段 | 是否必填 | 说明 |
| --- | --- | --- | --- |
+1 -1
View File
@@ -16,7 +16,7 @@ lead: 深入配置概览仪表盘、API 密钥、服务、供应商、路由、A
| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 |
| 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 |
| 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 |
| 路由配置 | 默认路由、条件规则、fallback 和请求改写 |
| 路由配置 | 条件规则、fallback 和请求改写 |
| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 |
| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 |
| Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 |
@@ -5,12 +5,6 @@ eyebrow: 详细配置
lead: 管理客户端访问 CCR 网关时使用的 API Key,并为每个 Key 设置过期时间和本地限额。
---
## 基本概念
“API 密钥”页面管理的是客户端访问 CCR 时使用的访问 Key。它不同于供应商配置里的 `API 密钥``凭据池`:这里的 Key 用来控制谁能调用 CCR;供应商 Key 用来控制 CCR 调用上游供应商。
如果配置了 API Key,客户端请求需要带 `Authorization: Bearer <key>``x-api-key: <key>`
## 列表字段
| 字段 | 代表的能力 |
@@ -5,10 +5,6 @@ eyebrow: 详细配置
lead: 通过 IM Bot 转发 Agent 消息,或在桌面空闲后把任务接力到手机。
---
## 能做什么
Bot 能把 Agent 消息转发到 IM 平台,也可以在桌面空闲后把任务接力到手机上继续看。
## 常见模式
- **转发 Agent 消息**:把消息同步到 IM。
@@ -5,7 +5,7 @@ eyebrow: 详细配置
lead: 了解 CCR 扩展如何加载、能注册哪些能力,并从零创建、安装和调试自己的扩展。
---
## 扩展是什么
## 扩展类型
CCR 的扩展分为两层:
@@ -177,7 +177,7 @@ module.exports = {
4. 保存配置。
5. 打开 **Server** 页面,重启网关。
CCR 的运行配置存储在 SQLite 中,因此推荐通过 UI 添加扩展,而不是编辑旧版 JSON 配置文件。扩展条目的配置结构如下:
CCR 的运行配置存储在 SQLite 中。请通过 UI 添加扩展旧版 JSON 配置文件仅用于参考。扩展条目的配置结构如下:
```json
{
@@ -9,7 +9,7 @@ lead: 把基础模型和能力模型组合成新的可选模型,让你已经
Fusion 的价值在于保留基础模型的手感,同时补齐它缺少的能力。基础模型继续负责推理、写作和代码生成;CCR 在需要时调用图像、搜索或 MCP 工具,把结果整理进上下文,再交给基础模型完成回答。
保存后的 Fusion 模型会像普通模型一样出现在路由和配置中。它不是额外的一次手动流程,而是一个可复用的新模型:把强文本模型升级成视觉模型,把稳定代码模型升级成可联网模型,也可以把 Agent 模型接入团队内部工具。
保存后的 Fusion 模型会像普通模型一样出现在路由和配置中。保存结果是一个可复用的新模型:把强文本模型升级成视觉模型,把稳定代码模型升级成可联网模型,也可以把 Agent 模型接入团队内部工具。
## 适用能力
@@ -5,12 +5,6 @@ eyebrow: 详细配置
lead: 自定义 CCR 首页组件,查看系统状态、账户余额、请求量、tokens、成本、模型分布和可分享卡片。
---
## 基本概念
“概览”是 CCR 的仪表盘页面。它把网关运行状态、供应商账户、请求用量和分析结果做成可拖拽组件。你可以切换时间范围、编辑组件、调整大小、切换样式,也可以把部分数据生成分享卡片。
概览依赖请求日志、用量统计和供应商账户用量。某些组件需要先在“设置”里开启请求日志或 Agent 观测,账户余额组件需要在供应商配置中开启“获取用量”。
## 顶部控件
| 字段 | 代表的能力 |
@@ -5,11 +5,16 @@ eyebrow: 详细配置
lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。
---
## Agent配置是什么
## 配置流程
Agent配置是桌面 App 中管理 Claude Code、Codex、ZCode 启动入口的能力。它不是供应商或路由规则,而是一次 Agent 启动所需的完整入口:Agent 类型、打开方式、模型、作用范围、配置文件位置,以及可选的 Bot 绑定
1. 先在 **供应商配置** 中添加至少一个可用供应商和模型,或先创建需要使用的 Fusion 模型
2. 打开 **Agent配置**,点击 **添加配置**
3. 选择 Agent 类型,填写配置名称,并选择作用范围和入口模式。
4. 选择模型。模型值通常是 `供应商名称/模型名称`,也可以选择 Fusion 模型。
5. 如果入口模式包含 App,可以绑定 Bot,并选择是否转发 Agent 消息或开启接力。
6. 保存后,从 Agent配置卡片打开:终端图标会复制 CLI 命令,播放图标会启动 App 实例。
因此详细配置里会有这个页面。它用于解释“用哪个配置打开哪个 Agent 实例”,而不是继续拆分供应商、路由或 Fusion 的字段
试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex 或 ZCode
## 多开机制
@@ -26,20 +31,80 @@ Agent配置是桌面 App 中管理 Claude Code、Codex、ZCode 启动入口的
## 常用选项
| 选项 | 说明 |
| 选项 | 适用范围 | 说明 |
| --- | --- | --- |
| Agent | 全部 | 选择 Claude Code、Codex 或 ZCode。ZCode 只支持 App。 |
| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 |
| 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 |
| 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 |
| 入口模式 | Claude Code、Codex | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。 |
| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 留空表示保留 Claude Code 默认模型。 |
| Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 |
| 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 |
## 各 Agent 的配置项
### Claude Code
| 配置项 | 作用 |
| --- | --- |
| Agent | 选择 Claude Code、Codex 或 ZCode |
| 配置名称 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标 |
| 作用范围 | “仅从 CCR 打开时生效”会使用 CCR 管理的独立配置;“系统默认”会写入对应 Agent 的默认配置 |
| 入口模式 | `CLI & APP``CLI only``App only`ZCode 只支持 App |
| 模型 | 该 Agent 打开后的默认模型,可选择普通供应商模型或 Fusion 模型 |
| Bot | App 入口可以绑定 Bot,用于 IM 消息转发或接力 |
| 模型覆盖 | 写入 Claude Code 使用的 `ANTHROPIC_MODEL`。留空时不覆盖 Claude Code 自己的默认模型。 |
| 小模型 | 写入 `ANTHROPIC_SMALL_FAST_MODEL`,供 Claude Code 的轻量任务使用。留空时保留 Claude Code 默认值。 |
| 设置文件 | 系统默认模式使用 Claude Code 默认设置文件;仅从 CCR 打开时生效会在 CCR 配置目录下按 Agent配置 `id` 生成独立设置文件。 |
| 环境变量 | 会合并到 Claude Code 设置文件的 `env` 中。CCR 同时写入网关地址、API Key helper 和启动包装器。 |
| Bot | 只在 Claude App 入口生效,可选择已保存 Bot,并配置转发 Agent 消息或接力。 |
Claude Code CLI 从 CCR 打开后,会通过 CCR 网关获取模型发现信息。进入 Claude Code CLI 后可以输入 `/model` 查看并切换 CCR 暴露的模型列表,包括普通供应商模型和可见的 Fusion 模型。
Claude App 是 **零配置(zero-config**:从 CCR 桌面 App 打开 Claude App 时,CCR 会自动写入 Claude App 网关配置、API Key、模型发现列表和独立用户数据目录。用户不需要增加额外操作,直接使用 CCR 打开就会自动完成所有必要配置;如果 Claude App 已经打开,按提示重启或从 CCR 重新打开即可。
Claude App 和 Claude Code CLI 的模型列表适配方式不同:
| 入口 | 模型列表来源 | 说明 |
| --- | --- | --- |
| Claude Code CLI | CCR 网关模型发现 | CLI 内使用 `/model` 查看列表;选择后请求仍走 CCR 的供应商、路由和 Fusion。 |
| Claude App | CCR 生成的 Claude App inference models | Claude App 需要 Claude 兼容的模型名。CCR 会把 `供应商/模型` 和 Fusion 模型映射成 Claude App 可识别的模型项,并用显示名称保留真实模型含义。 |
### Codex
| 配置项 | 作用 |
| --- | --- |
| Provider ID | 写入 Codex 的 `model_provider`,默认是 `claude-code-router`。建议保持稳定,只使用字母、数字、点、下划线或短横线。 |
| Provider name | Codex 中展示的供应商名称,默认是 `Claude Code Router`。 |
| Codex model | 写入 Codex 默认模型。可以选择普通供应商模型或 Fusion 模型;留空时 CCR 使用可用模型中的默认值。 |
| Show all sessions | 让 Codex 显示所有会话。ZCode 不提供该项。 |
| 配置文件 | 默认是 `~/.codex/config.toml`。仅从 CCR 打开时生效会写入 CCR 管理的独立配置目录。 |
| 环境变量 | 注入 Codex CLI 或 Codex App。Claude Code 专用的模型发现变量不会传给 Codex。 |
| Bot | 只在 Codex App 入口生效。 |
保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。Codex App 使用播放图标打开。CCR 会生成 `config.toml`、模型目录文件和中间层启动器,让 Codex CLI 与 Codex App 都使用同一套 CCR 模型和供应商信息。
### ZCode
| 配置项 | 作用 |
| --- | --- |
| Provider ID | 写入 ZCode 供应商引用,默认是 `claude-code-router`。 |
| Provider name | ZCode 中展示的供应商名称,默认是 `Claude Code Router`。 |
| ZCode model | ZCode App 打开后的默认模型。可以选择普通供应商模型或 Fusion 模型。 |
| 配置文件 | 默认是 `~/.zcode/cli/config.json`CCR 还会写入 ZCode v2 配置和模型缓存。 |
| 环境变量 | 注入 ZCode App 和中间层启动器。 |
| Bot | 只在 ZCode App 入口生效。 |
ZCode 只支持 App 打开,因此入口模式固定为 `App only`,也不会显示 `Show all sessions`
## CLI 与 App 模式区别
| 模式 | 如何打开 | 适合场景 | 主要差异 |
| --- | --- | --- | --- |
| CLI | 点击终端图标复制命令,然后在终端运行 `ccr <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 |
| App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、多实例并存、Bot 消息转发或接力 | 每个 Agent配置使用独立用户数据目录;同一配置重复打开会激活已有窗口,不同配置可以并行打开。 |
| CLI & APP | 同一个配置同时提供 CLI 和 App 入口 | 同一套模型配置既用于终端,也用于桌面 App | 两个入口共用配置名称、模型、作用范围和环境变量,但启动方式不同。 |
## 各 Agent 的差异
### Claude Code
Claude Code 配置会写入设置文件。选择“仅从 CCR 打开时生效”时,CCR 会在自己的配置目录下为这个 Agent配置生成独立设置文件,并通过独立启动包装器打开 Claude Code。
Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生效”时,CCR 会在自己的配置目录下为这个 Agent配置生成独立设置文件,并通过独立启动包装器打开 Claude Code。
从桌面 App 打开 Claude App 时,CCR 还会为该配置准备独立用户数据目录。不同 Agent配置使用不同目录,因此可以同时打开多个 Claude App 实例。
@@ -70,6 +70,10 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少
<span class="provider-import-icon-shell"><img src="../../provider-icons/siliconflow.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">硅基流动</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-runapi" href="ccr://provider?name=RunAPI&amp;base_url=https%3A%2F%2Frunapi.co%2Fv1&amp;protocol=openai_responses" aria-label="导入 RunAPI 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/runapi.jpg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">RunAPI</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
</a>
</div>
## 嵌入式按钮组件
@@ -5,20 +5,54 @@ eyebrow: 详细配置
lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列表和凭据。
---
## 基本概念
## 导入本机 Agent 登录态
供应商CCR 转发请求的上游模型服务。一个供应商配置同时描述了上游地址、协议能力、模型列表、认证方式、可选的多 Key 轮换,以及可选的账号用量读取能力
添加供应商时,CCR 会扫描本机已有的 Agent 登录状态。检测到可复用凭据后,添加弹窗会显示对应导入入口。导入会创建一个普通供应商和配套 provider plugin,让 CCR 复用本机 Agent 授权访问上游服务,不需要再手动粘贴常规 API Key
每个供应商至少需要名称、API 地址、可用协议、模型列表和一条可用凭据。添加或编辑供应商时,弹窗中的字段会根据“预设供应商 / 自定义 API 地址 / 本机 Agent 登录态导入 / 高级设置”动态显示。
### Claude Code
预设供应商会自动填入常见 API 地址、协议、模型和用量读取配置,适合优先使用。自定义供应商需要手动填写 API 地址,CCR 会根据 API 地址和 API 密钥做协议探测,帮助判断当前上游是否兼容 OpenAI Chat、OpenAI Responses、Anthropic Messages 或 Gemini Generate
Claude Code 导入会读取本机 Claude Code OAuth 凭据。检测到可用 access token 时,可以导入为 `Claude Code API` 供应商
导入后:
1. 协议使用 `anthropic_messages`
2. 默认模型包含 `claude-sonnet-4-20250514`,后续可以在供应商模型列表中增减模型。
3. CCR 会创建 OAuth provider plugin,把请求认证转换为 Claude Code 登录态。
4. 账号用量会使用 Anthropic OAuth 用量接口,适合在供应商列表、托盘或账号面板里查看额度状态。
如果只检测到登录痕迹但没有可用 access token,导入入口会显示不可导入原因。此时先在 Claude Code 中重新登录,再回到 CCR 添加供应商。
### Codex
Codex 导入会读取本机 Codex 登录文件和模型缓存。检测到 Codex access token 或 refresh token 时,可以导入为 `Codex API` 供应商。
导入后:
1. 协议使用 `openai_responses`
2. API 地址指向 Codex 后端,默认模型至少包含 `gpt-5-codex`,也会合并本机模型缓存中的模型和显示名。
3. CCR 会创建 Codex OAuth provider plugin,并在需要时刷新访问凭据。
4. 账号用量会读取 Codex 额度、余额和 token 统计接口。
导入后可以直接在路由或 Agent配置中选择 `Codex API/模型名`。如果模型缓存较旧,可以先打开 Codex 让它刷新模型列表,再回到 CCR 重新导入或编辑模型。
### ZCode
ZCode 导入会读取本机 ZCode 配置中的供应商 API Key、API 地址和模型列表。只有检测到可用供应商 Key 和 Base URL 时,才能导入为 `ZCode API` 供应商。
导入后:
1. 协议使用 `anthropic_messages`
2. 模型优先来自 ZCode 本机配置;没有配置时会使用 ZCode 运行缓存或默认模型。
3. CCR 会创建 API Key provider plugin,把 ZCode 本机配置中的 Key 用于请求认证。
4. 如果 API 地址命中 CCR 内置预设,账号用量配置会复用对应预设。
如果只检测到 ZCode 登录态,但没有检测到可用供应商 API Key,导入入口会显示不可导入。此时需要先在 ZCode 中配置可用模型供应商,再回到 CCR 添加供应商。
## 主字段
| 字段 | 代表的能力 |
| --- | --- |
| 选择 预设供应商 | 套用 CCR 内置供应商模板,包括默认 API 地址、可用协议、默认模型、图标、官网链接和部分供应商的用量读取配置。选择 `其他 / 自定义 API 地址` 时,可以接入任意兼容 OpenAI、Anthropic 或 Gemini 协议的上游服务。 |
| 导入本机 Agent 登录态 | 添加供应商时,如果 CCR 在本机发现 Claude Code、Codex 或 ZCode 的可用登录状态,会显示导入入口。导入后 CCR 会生成供应商和对应 provider plugin,用本机已有登录凭据访问上游,不需要手动粘贴常规 API Key。 |
| 名称 | CCR 内部显示名,也是路由、模型选择、日志和配置中识别供应商的名字。名称必须唯一,建议短且稳定。 |
| API 地址 | 上游 API Base URL。它决定请求实际发往哪里,也用于协议探测、模型列表探测、图标探测和安全校验。预设供应商添加时默认隐藏该字段,可在高级设置里覆盖。自定义供应商必须填写。 |
| API 密钥 | 默认供应商凭据。没有配置凭据池时,模型请求会使用这条 Key;协议探测、模型探测、连通性检测和默认用量读取也会使用它。只填写由当前 API 地址对应供应商签发的 Key。 |
@@ -46,7 +80,7 @@ lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列
| 添加 Key | 新增一条上游 API Key。 |
| 启用 | 控制单条凭据是否参与请求转发和用量读取。关闭后保留配置,但不会被选中。 |
| 名称 | 单条凭据的显示名。会出现在账号用量、日志和内部诊断里,建议写成可识别的用途或额度来源。 |
| API 密钥 | 该凭据实际发送给上游的 Key。配置了凭据池后,CCR 会把启用的凭据展开成多个内部上游目标,并优先使用凭据池,而不是主表单的默认 `API 密钥`。 |
| API 密钥 | 该凭据实际发送给上游的 Key。配置了凭据池后,CCR 会把启用的凭据展开成多个内部上游目标,并优先使用凭据池中的 Key。主表单的默认 `API 密钥` 只作为单 Key 配置使用。 |
| 移除 | 删除当前凭据行。 |
| Key 高级选项 | 展开单条凭据的调度和限额字段。 |
| 优先级 | 凭据优先级,数字越小越优先。未填写时按凭据行顺序作为优先级。 |
@@ -90,7 +124,7 @@ lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列
### HTTP JSON 请求
该模式适合供应商已有自己的余额或额度接口,返回格式不是 CCR 标准格式的情况。
该模式适合供应商已有自己的余额或额度接口,返回自定义 JSON 格式的情况。
| 字段 | 代表的能力 |
| --- | --- |
+131 -28
View File
@@ -5,20 +5,141 @@ 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 可以继续用下面的标签机制自动选择不同模型。
## Fallback 是什么
#### Subagent / Workflow 自动路由
Fallback 是请求失败后的降级策略。它不负责第一次选模型,而是在当前模型或上游失败时决定是否继续尝试。
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 处理
Fallback 处理请求失败后的降级。第一次选模型由路由完成;当前模型或上游失败时,Fallback 决定是否继续尝试。
路由页面顶部的 **默认失败处理** 是全局 Fallback。每条路由规则里的 **失败时** 是规则级 Fallback:当某条规则命中时,规则级配置会覆盖全局配置。
@@ -55,7 +176,7 @@ Fallback 是请求失败后的降级策略。它不负责第一次选模型,
2. 如果选择 **继续重试**,填写 `Retries`
3. 如果选择 **失败降级目标**,按优先级添加备用模型。
全局 Fallback 会应用到默认路由,以及没有单独配置 Fallback 的规则。
全局 Fallback 会应用到没有单独配置 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 模型。这样路由可以把特定请求导向视觉、搜索或工具增强模型。
## 验证方式
保存后发一次请求,到请求日志里检查:
@@ -5,10 +5,6 @@ eyebrow: 详细配置
lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MITM 劫持并代理到 CCR 的能力。
---
## 基本概念
“服务”页面控制 CCR 本地网关如何监听请求。普通模型请求通常只需要配置 `Host``Port`
## 主字段
| 字段 | 代表的能力 |
@@ -5,10 +5,6 @@ eyebrow: 详细配置
lead: 配置 CCR 系统托盘图标、余额进度条和托盘窗口组件。
---
## 基本概念
“托盘”位于“设置”里,用于配置系统托盘图标和托盘窗口。托盘窗口可以展示供应商、账户余额、token 趋势、活跃度、指标和模型占比等信息,适合在不打开主窗口时快速查看 CCR 状态。
## 顶部字段
| 字段 | 代表的能力 |
-13
View File
@@ -5,19 +5,6 @@ eyebrow: 产品文档
lead: 了解 CCR 的定位、能力边界和文档结构。需要动手配置时,从顶部的「快速开始」开始;需要查字段、Bot 或 Fusion 时,进入「详细配置」。
---
## CCR 能帮你做什么
**Claude Code RouterCCR)是本地运行的模型网关。** 它位于 Claude Code、Codex、ZCode 等 Agent 和上游模型服务之间,统一管理模型、API Key、路由规则、日志观测和 Bot 接力。
CCR 适合解决这些问题:
- 你不想在每个 Agent 里重复维护模型和 Key。
- 你希望不同任务自动走不同模型:轻量后台任务用快模型,复杂任务用强模型,看图或联网任务走 Fusion。
- 你需要在请求日志里看到请求实际去了哪个供应商、哪个模型、是否成功、延迟和成本大概是多少。
- 你想把长时间运行的 Agent 消息转发到 Slack、Telegram、飞书、企业微信等 IM 平台。
CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个地址,请求就可以被 CCR 接管并按路由规则转发到上游供应商。
## 文档结构
顶部栏现在对应四个独立页面:
+2 -2
View File
@@ -13,9 +13,9 @@ A: 服务运行状态、Agent 启动方式、配置应用状态和作用范围
### Q: 请求命中了错误模型怎么办?
A: 请求日志会展示 `request model``resolved provider``resolved model`。路由配置页包含默认路由、规则顺序、匹配条件和 fallback。
A: 请求日志会展示 `request model``resolved provider``resolved model`。路由配置页包含规则顺序、匹配条件和 fallback。
### Q: 供应商返回 401 或 403 是什么原因
### Q: 供应商返回 401 或 403 怎么处理
A: 相关字段包括 API Key、凭据启用状态、基础 URL、协议和额外请求头。供应商页面提供模型连通性检查。
+15 -8
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>
+47 -1
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;
@@ -1280,6 +1300,12 @@ h1 {
--provider-brand-3: #d9e3f1;
}
.doc-markdown a.provider-import-button.provider-runapi {
--provider-brand: #070707;
--provider-brand-2: #747474;
--provider-brand-3: #f3f3f3;
}
.doc-markdown a.provider-import-button.provider-deepseek {
--provider-brand: #173aa8;
--provider-brand-2: #4e69ff;
@@ -1552,6 +1578,7 @@ pre code span {
}
.toc a {
--toc-dot-left: 0px;
position: relative;
display: block;
padding: 5px 0 5px 21px;
@@ -1559,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;
@@ -1566,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;
+6 -6
View File
@@ -1,15 +1,15 @@
{
"name": "claude-code-router",
"version": "3.0.4",
"version": "3.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-code-router",
"version": "3.0.4",
"version": "3.0.5",
"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",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "claude-code-router",
"version": "3.0.4",
"version": "3.0.5",
"license": "MIT",
"description": "Local Claude Code Router gateway with CLI and web management UI.",
"repository": {
@@ -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",
+3 -1
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import type { AppConfig, ProfileConfig } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "./claude-app-cdp";
import { claudeCodeUtcTimezoneEnvOverride } from "./claude-environment";
import { resolveClaudeCodeSettingsFile } from "./profile-launch-core";
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "./windows-app-discovery";
@@ -59,7 +60,8 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
CLAUDE_USER_DATA_DIR: userDataDir,
CCR_CLAUDE_APP_USER_DATA_PATH: userDataDir,
CCR_PROFILE_SURFACE: "app",
ELECTRON_ENABLE_LOGGING: "1"
ELECTRON_ENABLE_LOGGING: "1",
...claudeCodeUtcTimezoneEnvOverride()
};
delete env.ELECTRON_RUN_AS_NODE;
+27
View File
@@ -0,0 +1,27 @@
const chinaTimeZones = new Set([
"asia/chongqing",
"asia/chungking",
"asia/harbin",
"asia/kashgar",
"asia/shanghai",
"asia/urumqi",
"china standard time",
"prc"
]);
export function claudeCodeUtcTimezoneEnvOverride(timeZone = currentTimeZone()): Record<string, string> {
return isChinaTimeZone(timeZone) ? { TZ: "UTC" } : {};
}
export function currentTimeZone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return undefined;
}
}
export function isChinaTimeZone(timeZone: string | undefined): boolean {
const normalized = timeZone?.trim().toLowerCase();
return Boolean(normalized && chinaTimeZones.has(normalized));
}
+29 -5
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(
+32 -1
View File
@@ -18,8 +18,35 @@ const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60
const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000);
const CONFIG_DIR = resolveConfigDir();
const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || "";
const CLAUDE_CODE_CHINA_TIME_ZONES = new Set([
"asia/chongqing",
"asia/chungking",
"asia/harbin",
"asia/kashgar",
"asia/shanghai",
"asia/urumqi",
"china standard time",
"prc"
]);
let BOT_BRIDGE_INSTANCE = null;
function claudeCodeUtcTimezoneEnvOverride() {
return isClaudeCodeChinaTimeZone(currentTimeZone()) ? { TZ: "UTC" } : {};
}
function currentTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return "";
}
}
function isClaudeCodeChinaTimeZone(timeZone) {
const normalized = String(timeZone || "").trim().toLowerCase();
return Boolean(normalized && CLAUDE_CODE_CHINA_TIME_ZONES.has(normalized));
}
function resolveConfigDir() {
const configured = nonEmptyEnv("CODEXL_HOME") || nonEmptyEnv("CCR_CONFIG_DIR");
if (configured) {
@@ -70,7 +97,10 @@ async function runClaudeCodeCliWrapper(args) {
});
const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN");
const child = childProcess.spawn(realCli, args, {
env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
env: {
...withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
...claudeCodeUtcTimezoneEnvOverride()
},
stdio: [injectRemoteStdin ? "pipe" : "inherit", captureStdout ? "pipe" : "inherit", "inherit"]
});
if (injectRemoteStdin && child.stdin) {
@@ -1815,6 +1845,7 @@ function claudeCommand(work) {
const env = withoutKeys({
...process.env,
...settingsEnv,
...claudeCodeUtcTimezoneEnvOverride(),
CODEX_SESSION_ID: work.threadId,
CODEX_THREAD_ID: work.threadId,
CODEX_TURN_ID: work.turnId
+23 -7
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;
+45 -9
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,9 @@ 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 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 +1207,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;
+3 -1
View File
@@ -7,6 +7,7 @@ import { mistralProviderPreset } from "./mistral";
import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "./moonshot";
import { openaiProviderPreset } from "./openai";
import { openRouterProviderPreset } from "./openrouter";
import { runApiProviderPreset } from "./runapi";
import { siliconFlowProviderPreset } from "./siliconflow";
import { zaiGlobalCodingProviderPreset } from "./zai-global-coding";
import { zaiGlobalGeneralProviderPreset } from "./zai-global-general";
@@ -38,7 +39,8 @@ export const providerPresets: ProviderPreset[] = [
moonshotChinaProviderPreset,
moonshotGlobalProviderPreset,
bailianProviderPreset,
siliconFlowProviderPreset
siliconFlowProviderPreset,
runApiProviderPreset
];
export function getProviderPresets(): ProviderPreset[] {
+15
View File
@@ -0,0 +1,15 @@
import { defaultProviderAccountConfig, type ProviderPreset } from "../../../shared/provider-presets";
export const runApiProviderPreset: ProviderPreset = {
account: defaultProviderAccountConfig,
aliases: ["runapi"],
endpoints: [
{
baseUrl: "https://runapi.co/v1",
protocols: ["openai_responses", "openai_chat_completions"]
}
],
id: "runapi",
name: "RunAPI",
websiteUrl: "https://runapi.co/register?aff=IX1t"
};
+3 -1
View File
@@ -1,5 +1,6 @@
import path from "node:path";
import type { AppConfig, ProfileConfig, ProfileOpenSurface } from "../shared/app";
import { claudeCodeUtcTimezoneEnvOverride } from "./claude-environment";
import { resolveZcodeConfigFile } from "./zcode-profile-config";
export type ProfileLaunchPlan = {
@@ -180,7 +181,8 @@ function buildClaudeCodeLaunchPlan(
command: launcher,
env: {
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
CCR_PROFILE_SURFACE: surface
CCR_PROFILE_SURFACE: surface,
...claudeCodeUtcTimezoneEnvOverride()
},
profile,
surface
+6 -7
View File
@@ -6,6 +6,7 @@ import { assertAvailableGatewayModels, type AppConfig, type ProfileOpenCommandRe
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "./claude-app-gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
import { claudeCodeUtcTimezoneEnvOverride } from "./claude-environment";
import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "./codex-app-launch";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { CONFIGDIR } from "./constants";
@@ -76,7 +77,8 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen
env: {
...process.env,
...plan.env,
...botGatewayProfileEnv(config, profile, surface)
...botGatewayProfileEnv(config, profile, surface),
...(profile.agent === "claude-code" ? claudeCodeUtcTimezoneEnvOverride() : {})
},
stdio: "ignore"
});
@@ -512,11 +514,7 @@ function profileGatewayConfigWithToken(config: AppConfig, profile: ReturnType<ty
key: token,
name: `Profile: ${profile.name?.trim() || profile.id || profile.agent}`
}
],
Router: {
...config.Router,
...(profile.model.trim() ? { default: profile.model.trim() } : {})
}
]
};
}
@@ -961,7 +959,8 @@ function startClaudeAppBotWorker(config: AppConfig, profile: ReturnType<typeof f
CCR_CODEX_WORKSPACE_NAME: profile.name || profile.id,
CCR_PROFILE_SURFACE: "app",
CODEXL_CODEX_WORKSPACE_NAME: profile.name || profile.id,
CODEXL_PROFILE_SURFACE: "app"
CODEXL_PROFILE_SURFACE: "app",
...claudeCodeUtcTimezoneEnvOverride()
};
delete env.ELECTRON_NO_ATTACH_CONSOLE;
+20 -5
View File
@@ -5,6 +5,8 @@ import path from "node:path";
import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "../shared/app";
import { replacePersistedApiKeys } from "./api-key-store";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { 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";
@@ -98,6 +100,7 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
} else {
delete env.ANTHROPIC_SMALL_FAST_MODEL;
}
Object.assign(env, claudeCodeUtcTimezoneEnvOverride());
const helperResult = writeClaudeCodeApiKeyHelper(profile, token);
const wrapperResult = writeClaudeCodeWrapper(config, profile, helperResult.file);
@@ -151,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,
@@ -178,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);
@@ -531,6 +540,7 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
return [
"#!/bin/sh",
...envExports,
...shellEnvExports(claudeCodeUtcTimezoneEnvOverride()),
`: "\${CCR_PROFILE_SURFACE:=${surface}}"`,
"export CCR_PROFILE_SURFACE",
...botEnvExports,
@@ -559,6 +569,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r
return [
"@echo off",
...envExports,
...cmdEnvExports(claudeCodeUtcTimezoneEnvOverride()),
`if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", surface)}`,
...botEnvExports,
cmdSetLine("CCR_CLAUDE_CODE_WRAPPER", "1"),
@@ -850,6 +861,10 @@ function shellBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): s
];
}
function shellEnvExports(env: Record<string, string>): string[] {
return Object.entries(env).map(([key, value]) => `export ${key}=${shellQuote(value)}`);
}
function cmdBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): string[] {
return [
`if /I "%CCR_PROFILE_SURFACE%"=="app" (`,
@@ -860,6 +875,10 @@ function cmdBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): str
];
}
function cmdEnvExports(env: Record<string, string>): string[] {
return Object.entries(env).map(([key, value]) => cmdSetLine(key, value));
}
function withoutBotGatewayEnv(values: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(values).filter(([key]) => !isBotGatewayEnvKey(key)));
}
@@ -1285,10 +1304,6 @@ function gatewayEndpoint(config: AppConfig): string {
}
function defaultClientModel(config: AppConfig): string {
const configuredDefault = normalizeClientModel(config.Router.default);
if (configuredDefault) {
return configuredDefault;
}
const preferred = config.Providers.find((provider) => provider.name === config.preferredProvider) ?? config.Providers[0];
if (preferred?.name && preferred.models[0]) {
return `${preferred.name}/${preferred.models[0]}`;
-4
View File
@@ -282,10 +282,6 @@ function gatewayEndpoint(config: AppConfig): string {
}
function defaultClientModel(config: AppConfig): string {
const configuredDefault = normalizeClientModel(config.Router.default);
if (configuredDefault) {
return configuredDefault;
}
const preferred = config.Providers.find((provider) => provider.name === config.preferredProvider) ?? config.Providers[0];
if (preferred?.name && preferred.models[0]) {
return `${preferred.name}/${preferred.models[0]}`;
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+1 -1
View File
@@ -22,7 +22,7 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
type="checkbox"
{...props}
/>
<Check className="pointer-events-none absolute left-0.5 top-0.5 h-3 w-3 text-primary-foreground opacity-0 transition-opacity peer-checked:opacity-100" />
<Check className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-3 w-3 -translate-x-1/2 -translate-y-1/2 text-primary-foreground opacity-0 transition-opacity peer-checked:opacity-100" />
</span>
)
);
+3 -2
View File
@@ -6,8 +6,8 @@ export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputEle
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ checked = false, className, disabled, onChange, onCheckedChange, ...props }, ref) => (
<span className={cn("relative inline-flex h-[30px] w-[54px] shrink-0", className)}>
({ checked = false, className, disabled, onChange, onCheckedChange, title, ...props }, ref) => (
<span className={cn("relative inline-flex h-[30px] w-[54px] shrink-0", className)} title={title}>
<input
aria-checked={checked}
checked={checked}
@@ -19,6 +19,7 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
}}
ref={ref}
role="switch"
title={title}
type="checkbox"
{...props}
/>
+43 -3
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;
@@ -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>
);
@@ -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,
+135 -48
View File
@@ -4,10 +4,11 @@ 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,
AppI18nContext, appCopy, ExternalLink, 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
Trash2, uniqueStrings, useAppText, useContext, useMemo, useState, X
} from "../shared";
import { ROUTER_FALLBACK_MAX_RETRY_COUNT } from "../../../../shared/app";
export function RoutingView({
@@ -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,99 @@ 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;
const toggleDisabledReason = row.toggleDisabledReason ? t(row.toggleDisabledReason) : undefined;
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 agent={row.builtInAgent} /> : 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 })} />
<span
aria-label={toggleDisabledReason}
className="group relative inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
tabIndex={toggleDisabledReason ? 0 : undefined}
>
<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 });
}
}}
/>
{toggleDisabledReason ? (
<span className="pointer-events-none absolute right-full top-1/2 z-[80] mr-2 hidden w-[240px] -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 group-focus-within:block">
{toggleDisabledReason}
</span>
) : null}
</span>
</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);
}
}}
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>
{!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>
))}
</AnimatedListItem>
);
})}
</AnimatePresence>
</div>
</div>
@@ -161,6 +197,57 @@ export function RoutingView({
);
}
function BuiltInRouteInfoIcon({ agent }: { agent: RouterBuiltInAgentRuleId }) {
const t = useAppText();
const copy = useContext(AppI18nContext);
const description = builtInRouteDescription(agent, t);
const docsUrl = builtInRouteDocsUrl(agent, copy === appCopy.zh ? "zh" : "en");
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}>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
<span className="absolute left-full top-1/2 z-[80] hidden w-[232px] -translate-y-1/2 pl-2 group-hover:block group-focus:block group-focus-within:block">
<span className="block 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">
<span>{description}</span>
<a
className="ml-1 inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
href={docsUrl}
onClick={(event) => {
event.preventDefault();
openExternalUrl(docsUrl);
}}
rel="noreferrer"
target="_blank"
>
{t("Docs")}
<ExternalLink className="h-3 w-3" />
</a>
</span>
</span>
</span>
);
}
function builtInRouteDescription(agent: RouterBuiltInAgentRuleId, t: (value: string) => string): string {
return agent === "claude-code"
? t("Identifies the Claude Code user-agent to provide deep Claude Code integration.")
: t("Identifies the Codex user-agent to provide deep Codex integration.");
}
function builtInRouteDocsUrl(agent: RouterBuiltInAgentRuleId, language: "en" | "zh"): string {
const path = language === "zh" ? "/configuration/routing" : "/en/configuration/routing";
const hash = agent === "claude-code" ? "claude-code" : "codex";
return `https://ccrdesk.top${path}#${hash}`;
}
function openExternalUrl(url: string) {
if (window.ccr?.openExternal) {
void window.ccr.openExternal(url).catch(() => undefined);
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
function RouterFallbackControl({
className,
fallback,
+2 -2
View File
@@ -529,8 +529,8 @@ export function KeyValueRowsControl({
);
}
export function Toggle({ checked, disabled = false, onChange }: { checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
return <Switch checked={checked} disabled={disabled} onCheckedChange={onChange} />;
export function Toggle({ checked, disabled = false, onChange, title }: { checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void; title?: string }) {
return <Switch checked={checked} disabled={disabled} onCheckedChange={onChange} title={title} />;
}
export type MetricTone = "amber" | "blue" | "indigo" | "rose" | "slate" | "teal";
+14
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": "内置",
"Identifies the Claude Code user-agent to provide deep Claude Code integration.": "通过识别 Claude Code 的 user-agent,实现对 Claude Code 的深度适配。",
"Identifies the Codex user-agent to provide deep Codex integration.": "通过识别 Codex 的 user-agent,实现对 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": "网络",
@@ -854,6 +859,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"None": "无",
"Not configured": "未配置",
"Not running": "未运行",
"Agent profiles are disabled.": "Agent 配置档案已关闭。",
"Open": "打开",
"Open Agent": "打开Agent",
"Open CA": "打开 CA",
@@ -877,6 +883,11 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Phone Bluetooth target": "手机蓝牙目标",
"Phone Wi-Fi target": "手机 Wi-Fi 目标",
"Plugin": "插件",
"Profile model unset": "未设置配置档案模型",
"Enable a Claude Code profile before enabling this built-in route.": "请先启用一个 Claude Code 配置档案,再启用此内置路由。",
"Enable a Codex profile before enabling this built-in route.": "请先启用一个 Codex 配置档案,再启用此内置路由。",
"Set a model on the Claude Code profile before enabling this built-in route.": "请先为 Claude Code 配置档案设置模型,再启用此内置路由。",
"Set a model on the Codex profile before enabling this built-in route.": "请先为 Codex 配置档案设置模型,再启用此内置路由。",
"Plugin apps must be a JSON array.": "插件 App 必须是 JSON 数组。",
"Plugin config JSON": "插件配置 JSON",
"Plugin config must be a JSON object.": "插件配置必须是 JSON 对象。",
@@ -1348,6 +1359,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Detecting icon": "正在检测图标",
"Detecting provider": "正在检测供应商",
"Disabled": "已禁用",
"Docs": "文档",
"Edit description": "编辑描述",
"Expand": "展开",
"Expand models": "展开模型",
"Expires": "过期",
@@ -1367,6 +1380,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"More": "更多",
"Provider models": "供应商模型",
"Runtime provider": "运行时供应商",
"Used in Agent routing prompts": "用于 Agent 路由提示词",
"Read only": "只读",
"Search all models": "搜索全部模型",
"Source": "来源",
+1
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";
@@ -49,6 +49,7 @@ import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp";
import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico";
import openaiProviderIconUrl from "@/assets/provider-icons/openai.png";
import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico";
import runapiProviderIconUrl from "@/assets/provider-icons/runapi.jpg";
import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png";
import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg";
import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg";
@@ -322,6 +323,7 @@ export const providerPresetIconUrls: Record<string, string> = {
"moonshot-global": moonshotProviderIconUrl,
openai: openaiProviderIconUrl,
openrouter: openrouterProviderIconUrl,
runapi: runapiProviderIconUrl,
siliconflow: siliconflowProviderIconUrl,
"zai-global-coding": zaiGlobalCodingProviderIconUrl,
"zai-global-general": zaiGlobalGeneralProviderIconUrl,
@@ -389,10 +389,6 @@ export function gatewayEndpointFromConfig(config: AppConfig): string {
}
export function defaultProfileClientModel(config: AppConfig): string {
const configuredDefault = normalizeProfileClientModel(config.Router.default);
if (configuredDefault) {
return configuredDefault;
}
const preferred = config.Providers.find((provider) => provider.name === config.preferredProvider) ?? config.Providers[0];
if (preferred?.name && preferred.models[0]) {
return `${preferred.name}/${preferred.models[0]}`;
+74 -21
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,
@@ -1638,9 +1657,11 @@ export function providerDraftSafetyIssue(draft: AddProviderDraft, baseUrl = draf
export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeCandidate[] {
const preset = findProviderPreset(draft.presetId);
const protocols = providerProtocolOptions.map((option) => option.value);
if (preset) {
return preset.endpoints.map((endpoint) => ({
...endpoint,
protocols,
source: "preset"
}));
}
@@ -1648,7 +1669,7 @@ export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeC
return [
{
baseUrl: draft.baseUrl.trim(),
protocols: providerProtocolOptions.map((option) => option.value),
protocols,
source: "custom"
}
];
@@ -1746,17 +1767,38 @@ export function selectedProviderProtocolsFromCapabilities(
export function selectedProviderProtocolsForProbe(
selectedProtocols: GatewayProviderProtocol[],
probe: GatewayProviderProbeResult,
fallback: GatewayProviderProtocol
fallback: GatewayProviderProtocol,
presetId?: string
): GatewayProviderProtocol[] {
const selectable = providerSelectableProtocolsFromProbe(probe);
if (selectable.length === 0) {
return selectedProtocols.length > 0 ? uniqueProviderProtocols(selectedProtocols) : [fallback];
}
if (selectedProtocolsMatchPresetDefault(selectedProtocols, presetId)) {
return selectable;
}
const selected = uniqueProviderProtocols(selectedProtocols).filter((protocol) => selectable.includes(protocol));
return selected.length > 0 ? selected : selectable;
}
function selectedProtocolsMatchPresetDefault(
selectedProtocols: GatewayProviderProtocol[],
presetId: string | undefined
): boolean {
const preset = findProviderPreset(presetId);
if (!preset) {
return false;
}
const selected = uniqueProviderProtocols(selectedProtocols);
const defaults = uniqueProviderProtocols(preset.endpoints.flatMap((endpoint) => endpoint.protocols));
return defaults.length > 0 &&
selected.length === defaults.length &&
defaults.every((protocol, index) => selected[index] === protocol);
}
export function uniqueProviderProtocols(values: Array<GatewayProviderProtocol | string | undefined>): GatewayProviderProtocol[] {
const allowed = new Set(providerProtocolOptions.map((option) => option.value));
const seen = new Set<GatewayProviderProtocol>();
@@ -1804,7 +1846,7 @@ export function mergeProviderCapabilities(...groups: GatewayProviderCapability[]
export function applyProviderProbeResult(draft: AddProviderDraft, probe: GatewayProviderProbeResult): AddProviderDraft {
const protocol = probe.detectedProtocol ?? draft.protocol;
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol);
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
if (probe.models.length === 0) {
@@ -1869,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;
}
+109 -17
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";
@@ -384,16 +386,35 @@ export function normalizeRouterConfig(value: Partial<RouterConfig> | undefined):
const router = {
...fallbackConfig.Router,
...(value || {})
};
} as RouterConfig & { default?: unknown };
const { default: _legacyDefault, ...routerWithoutLegacyDefault } = router;
const rules = normalizeRouterRules((value as Record<string, unknown> | undefined)?.rules) ?? [];
return {
...router,
...routerWithoutLegacyDefault,
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 +827,90 @@ 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 toggleDisabledReason = routerBuiltInAgentRuleDisabledReason(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: Boolean(toggleDisabledReason),
toggleDisabledReason,
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() || "";
}
export function routerBuiltInAgentRuleDisabledReason(config: AppConfig, agent: RouterBuiltInAgentRuleId): string | undefined {
if (config.profile.enabled === false) {
return "Agent profiles are disabled.";
}
const agentName = routerBuiltInAgentRuleName(agent);
const profile = routerBuiltInAgentProfile(config, agent);
if (!profile) {
return `Enable a ${agentName} profile before enabling this built-in route.`;
}
if (!profile.model.trim()) {
return `Set a model on the ${agentName} profile before enabling this built-in route.`;
}
return undefined;
}
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[] {
+8
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,8 @@ export type RoutingRuleRow = {
ruleId: string;
sourceLabel: string;
target: string;
toggleDisabled?: boolean;
toggleDisabledReason?: string;
typeLabel: string;
};
@@ -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),
+30 -2
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;
+10
View File
@@ -8,6 +8,16 @@ declare module "*.ico" {
export default src;
}
declare module "*.jpg" {
const src: string;
export default src;
}
declare module "*.jpeg" {
const src: string;
export default src;
}
declare module "*.svg" {
const src: string;
export default src;
+401 -6
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);
@@ -194,7 +210,374 @@ function resolveConfiguredRouteDecision(
}
}
return { fallback: router.fallback, model: normalizeRouteSelector(router.default) ?? explicitModel, reason: "default" };
return { fallback: router.fallback, model: 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);
}
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(
@@ -617,6 +1000,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 +1144,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;
}
+541 -31
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)) : [];
}
+11 -7
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,18 @@ 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;
default?: string;
builtInRules: RouterBuiltInRulesConfig;
fallback: RouterFallbackConfig;
image?: string;
longContext?: string;
longContextThreshold: number;
rules: RouterRule[];
think?: string;
webSearch?: string;
};
export type GatewayRuntimeConfig = {
+6 -7
View File
@@ -25,14 +25,13 @@ export type ClaudeAppGatewayInferenceModel = {
supports1m?: true;
};
export function inferClaudeAppGatewayTargetModel(config: Pick<AppConfig, "Router" | "profile">): string {
return config.Router.default?.trim() ||
inferGlobalClaudeProfileModel(config) ||
export function inferClaudeAppGatewayTargetModel(config: Pick<AppConfig, "profile">): string {
return inferGlobalClaudeProfileModel(config) ||
CLAUDE_APP_FALLBACK_MODEL;
}
export function buildClaudeAppGatewayModelRoutes(
config: Pick<AppConfig, "Providers" | "Router" | "profile" | "virtualModelProfiles">,
config: Pick<AppConfig, "Providers" | "profile" | "virtualModelProfiles">,
options: ClaudeAppGatewayModelRouteOptions = {}
): ClaudeAppGatewayModelRoute[] {
const targetModels = claudeAppGatewayTargetModels(config);
@@ -74,7 +73,7 @@ export function buildClaudeAppGatewayModelRoutes(
export function resolveClaudeAppGatewayRouteModel(
model: string,
config: Pick<AppConfig, "Providers" | "Router" | "profile" | "virtualModelProfiles">,
config: Pick<AppConfig, "Providers" | "profile" | "virtualModelProfiles">,
options: ClaudeAppGatewayModelRouteOptions = {}
): string | undefined {
const normalized = model.trim().toLowerCase();
@@ -99,7 +98,7 @@ export function resolveClaudeAppGatewayRouteModel(
}
export function buildClaudeAppGatewayInferenceModels(
config: Pick<AppConfig, "Providers" | "Router" | "profile" | "virtualModelProfiles">,
config: Pick<AppConfig, "Providers" | "profile" | "virtualModelProfiles">,
options: ClaudeAppGatewayModelRouteOptions = {}
): ClaudeAppGatewayInferenceModel[] {
const routes = buildClaudeAppGatewayModelRoutes(config, options);
@@ -129,7 +128,7 @@ function inferGlobalClaudeProfileModel(config: Pick<AppConfig, "profile">): stri
)?.model.trim() ?? "";
}
function claudeAppGatewayTargetModels(config: Pick<AppConfig, "Providers" | "Router" | "profile" | "virtualModelProfiles">): string[] {
function claudeAppGatewayTargetModels(config: Pick<AppConfig, "Providers" | "profile" | "virtualModelProfiles">): string[] {
const baseEntries = config.Providers.flatMap((provider) => {
const providerName = provider.name?.trim();
if (!providerName || !Array.isArray(provider.models)) {
+42
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;
+8 -1
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: {
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { claudeCodeUtcTimezoneEnvOverride, isChinaTimeZone } from "../../src/main/claude-environment.ts";
test("detects China time zones used by Claude Code", () => {
assert.equal(isChinaTimeZone("Asia/Shanghai"), true);
assert.equal(isChinaTimeZone("Asia/Urumqi"), true);
assert.equal(isChinaTimeZone("PRC"), true);
assert.equal(isChinaTimeZone("UTC"), false);
assert.equal(isChinaTimeZone("Asia/Singapore"), false);
});
test("overrides Claude Code timezone only for China time zones", () => {
assert.deepEqual(claudeCodeUtcTimezoneEnvOverride("Asia/Shanghai"), { TZ: "UTC" });
assert.deepEqual(claudeCodeUtcTimezoneEnvOverride("UTC"), {});
assert.deepEqual(claudeCodeUtcTimezoneEnvOverride("America/Los_Angeles"), {});
});
@@ -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 });
}
});
+36
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: [],
+149
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);
});
+9 -2
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");
+490
View File
@@ -0,0 +1,490 @@
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 }
},
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 stays inactive when profile model is unset", async () => {
const plugin = createRouterPlugin({
agent: "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, "gpt-5");
assert.equal(result.decision.reason, "default");
});
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",
profileModel: "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");
});
+20
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") {