Compare commits

..

No commits in common. "main" and "v3.0.9" have entirely different histories.
main ... v3.0.9

82 changed files with 476 additions and 3373 deletions

View file

@ -9,44 +9,12 @@
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
</p>
<div align="center">
<table width="100%">
<tr>
<td align="center">
<a href="https://www.kimi.com/code?aff=ccr">
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
</a>
<br />
<sub>
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.com?aff=ccr">API China</a>
</sub>
</td>
</tr>
<tr>
<td align="left">
<p>
<strong>Thanks to Kimi for sponsoring this project!</strong> Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
</p>
<p align="center">
CCR already supports Kimi. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">cost-effective Coding Plan</a>.
</p>
</td>
</tr>
</table>
</div>
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.
<p align="center">
<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.
## Why Use CCR
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
@ -210,36 +178,20 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co
</a>
</td>
<td align="center" width="330">
<a href="https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
<a href="https://code0.ai?source=claudecoderouter">
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai icon" />
<br />
<strong>code0.ai</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
<a href="https://www.claudeapi.com?source=claudecoderouter">
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi icon" />
<br />
<strong>claudeapi</strong>
</a>
</td>
</tr>
<tr>
<td align="center" width="330">
<a href="https://s.qiniu.com/AVjMVf">
<img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="Qiniu Cloud AI icon" />
<br />
<strong>Qiniu Cloud AI</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=9HHHAB5QLAES">
<img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai icon" />
<br />
<strong>Fenno.ai</strong>
</a>
</td>
</tr>
</table>
<h4>Community Sponsors</h4>

View file

@ -9,44 +9,12 @@
<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>
<div align="center">
<table width="100%">
<tr>
<td align="center">
<a href="https://www.kimi.com/code?aff=ccr">
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
</a>
<br />
<sub>
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
</sub>
</td>
</tr>
<tr>
<td align="left">
<p>
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi订阅端点原生直通、无需协议转换API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
</p>
<p align="center">
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a><a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API或了解高性价比 <a href="https://www.kimi.com/code?aff=ccr">Coding Plan</a> 套餐。
</p>
</td>
</tr>
</table>
</div>
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
<p align="center">
<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
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
@ -209,36 +177,20 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
</a>
</td>
<td align="center" width="330">
<a href="https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
<a href="https://code0.ai?source=claudecoderouter">
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai 图标" />
<br />
<strong>code0.ai</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
<a href="https://www.claudeapi.com?source=claudecoderouter">
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi 图标" />
<br />
<strong>claudeapi</strong>
</a>
</td>
</tr>
<tr>
<td align="center" width="330">
<a href="https://s.qiniu.com/AVjMVf">
<img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="七牛云 AI 图标" />
<br />
<strong>七牛云 AI</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=9HHHAB5QLAES">
<img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai 图标" />
<br />
<strong>Fenno.ai</strong>
</a>
</td>
</tr>
</table>
<h4>社区赞助者</h4>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -24,7 +24,7 @@ Every Agent Config has its own `id` and name. When CCR opens an agent, it finds
| --- | --- |
| Separate config files | With **Only opened from CCR**, Claude Code and Codex write CCR-managed config files in directories separated by config `id` |
| Separate launchers | Claude Code uses a separate launch wrapper; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name |
| Separate app data directories | When opening App mode, Claude App, ChatGPT (the renamed Codex desktop app), and ZCode App use user-data directories separated by config `id` |
| Separate app data directories | When opening App mode, Claude App, Codex App, and ZCode App use user-data directories separated by config `id` |
| Runtime state | CCR tracks running app instances by entry mode and config `id`; reopening the same config activates the existing window, while a different config can open a separate instance |
This lets you create multiple configs for the same agent, such as "Claude Code - Work Project", "Claude Code - Test Model", or "Codex - Fusion Vision". They can use different models, scopes, and Bots, then open as separate agent instances.
@ -74,12 +74,10 @@ Claude App and Claude Code CLI use different model-list adapters:
| 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 ChatGPT. Claude Code-specific model discovery variables are not passed to Codex. |
| Bot | Applies only to the ChatGPT app entry. |
| 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 ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display: an existing valid ChatGPT token is shown as the real ChatGPT account, while a profile without credentials uses a tokenless ChatGPT-shaped workspace identity so the desktop renderer keeps model selection available without storing a real user login. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported.
Model and public plugin listings are not synthesized by the middleware. The native Codex app-server reads the generated `model_catalog_json` and handles `model/list` plus public `plugin/list` requests unchanged. This lets Codex refresh the official public [`openai/plugins`](https://github.com/openai/plugins) Git marketplace over the network. In a virtual workspace, only account-private marketplace requests are answered with an explicit empty result because the native service requires real ChatGPT authentication for those sections; they are never replaced with local plugins. Any downloaded Git checkout is owned only by Codex as its normal last-known-good data, not used by CCR as a replacement catalog.
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
@ -114,7 +112,7 @@ When opening Claude App from the desktop app, CCR also prepares a separate user-
Codex config writes `config.toml` and a model catalog file. With **Only opened from CCR**, CCR stores those files in a directory separated by config `id`.
Codex supports CLI and App. CLI opens through the launcher for the selected config; App launches ChatGPT, uses a separate user-data directory, and passes the selected model and provider into the app.
Codex supports CLI and App. CLI opens through the launcher for the selected config; App uses a separate user-data directory and passes the selected model and provider into Codex App.
### ZCode

View file

@ -78,22 +78,14 @@ 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/teamorouter.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">TeamoRouter</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
</a>
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&amp;base_url=https%3A%2F%2Fconsole.code0.ai&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fcode0.ai%2Fagent%2Fregister%2F9n9jOsSnYQoemIVL%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" aria-label="Import code0.ai provider">
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&amp;base_url=https%3A%2F%2Fconsole.code0.ai&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="Import code0.ai provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/code0.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
</a>
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&amp;base_url=https%3A%2F%2Fgw.claudeapi.com&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fconsole.claudeapi.com%2Fagent%2Fregister%2FLbmB7Y9kPloyzhwF%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" aria-label="Import claudeapi provider">
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&amp;base_url=https%3A%2F%2Fgw.claudeapi.com&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="Import claudeapi provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
</a>
<a class="provider-import-button provider-qiniu-ai" href="ccr://provider?name=%E4%B8%83%E7%89%9B%E4%BA%91+AI&amp;base_url=https%3A%2F%2Fapi.qnaigc.com&amp;protocol=openai_chat_completions&amp;source=https%3A%2F%2Fs.qiniu.com%2FAVjMVf" aria-label="Import Qiniu Cloud AI provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/qiniu-ai.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Qiniu Cloud AI</span><span class="provider-import-meta">Chat / Responses / Anthropic / Gemini Generate</span></span>
</a>
<a class="provider-import-button provider-fenno" href="ccr://provider?name=Fenno.ai&amp;base_url=https%3A%2F%2Fapi.fenno.ai&amp;protocol=openai_chat_completions&amp;source=https%3A%2F%2Fapi.fenno.ai%2Fregister%3Fredirect%3D%2Fpurchase%3Ftab%3Dsubscription%2526group%3D16%26aff%3D9HHHAB5QLAES" aria-label="Import Fenno.ai provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/fenno.jpg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Fenno.ai</span><span class="provider-import-meta">Chat / Responses / Anthropic</span></span>
</a>
</div>
## Embeddable Button Component

View file

@ -162,7 +162,7 @@ Network errors move to the next attempt. Status-code fallback depends on the mod
| Retry | `408`, `409`, `429`, `5xx` |
| Fallback targets | Any `4xx` or `5xx` |
Before moving to the next attempt, CCR waits for every fallback-triggering failure, including network errors. It honors a positive `Retry-After` header when the upstream provides one; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt.
For `429` rate-limit responses, CCR waits before the next attempt. It honors `Retry-After` when the upstream provides it; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt.
**Fallback targets** also switches on `4xx` because model-not-found, auth, or provider-side rejection errors may only affect the current target. If the fallback model works, the request can still succeed.

View file

@ -24,7 +24,7 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过
| --- | --- |
| 独立配置文件 | 选择“仅从 CCR 打开时生效”时Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 |
| 独立启动器 | Claude Code 使用独立启动包装器Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 |
| 独立 App 数据目录 | 从 App 打开时Claude App、ChatGPTCodex 桌面端的新名称)、ZCode App 都会使用按配置 `id` 区分的用户数据目录 |
| 独立 App 数据目录 | 从 App 打开时Claude App、Codex App、ZCode App 都会使用按配置 `id` 区分的用户数据目录 |
| 运行状态 | CCR 按打开入口和配置 `id` 记录运行中的 App 实例;同一个配置再次打开会激活已有窗口,不同配置可以打开不同实例 |
这意味着你可以为同一个 Agent 建多个配置例如“Claude Code - 工作项目”“Claude Code - 测试模型”“Codex - Fusion 图像能力”。它们可以选择不同模型、不同作用范围和不同 Bot打开后就是不同的 Agent 实例。
@ -74,12 +74,10 @@ Claude App 和 Claude Code CLI 的模型列表适配方式不同:
| Codex model | 写入 Codex 默认模型。可以选择普通供应商模型或 Fusion 模型;留空时 CCR 使用可用模型中的默认值。 |
| Show all sessions | 让 Codex 显示所有会话。ZCode 不提供该项。 |
| 配置文件 | 默认是 `~/.codex/config.toml`。仅从 CCR 打开时生效会写入 CCR 管理的独立配置目录。 |
| 环境变量 | 注入 Codex CLI 或 ChatGPT。Claude Code 专用的模型发现变量不会传给 Codex。 |
| Bot | 只在 ChatGPT App 入口生效。 |
| 环境变量 | 注入 Codex CLI 或 Codex App。Claude Code 专用的模型发现变量不会传给 Codex。 |
| Bot | 只在 Codex App 入口生效。 |
保存后Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI只适配账号展示隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplaceCCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。
模型和公共插件列表不再由中间层合成。原生 Codex app-server 读取生成的 `model_catalog_json`,并原样处理 `model/list` 与公共 `plugin/list` 请求,因此 Codex 可以自行联网刷新官方公开 [`openai/plugins`](https://github.com/openai/plugins) Git marketplace。虚拟 workspace 中,只有必须使用真实 ChatGPT 鉴权的账号私有 marketplace 请求会得到明确空结果,绝不会用本地插件替代。下载后的 Git checkout 只作为 Codex 自己的常规 last-known-good 数据CCR 不会拿它替代远端目录。
保存后Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。Codex App 使用播放图标打开。CCR 会生成 `config.toml`、模型目录文件和中间层启动器,让 Codex CLI 与 Codex App 都使用同一套 CCR 模型和供应商信息。
### ZCode
@ -114,7 +112,7 @@ Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生
Codex 配置会写入 `config.toml`,并生成模型目录文件。选择“仅从 CCR 打开时生效”时CCR 会把这些文件放在按配置 `id` 区分的目录中。
Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开App 会启动 ChatGPT、使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 App。
Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开App 会使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 Codex App。
### ZCode

View file

@ -78,22 +78,14 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少
<span class="provider-import-icon-shell"><img src="../../provider-icons/teamorouter.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">TeamoRouter</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
</a>
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&amp;base_url=https%3A%2F%2Fconsole.code0.ai&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fcode0.ai%2Fagent%2Fregister%2F9n9jOsSnYQoemIVL%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" aria-label="导入 code0.ai 供应商">
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&amp;base_url=https%3A%2F%2Fconsole.code0.ai&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="导入 code0.ai 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/code0.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
</a>
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&amp;base_url=https%3A%2F%2Fgw.claudeapi.com&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fconsole.claudeapi.com%2Fagent%2Fregister%2FLbmB7Y9kPloyzhwF%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" aria-label="导入 claudeapi 供应商">
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&amp;base_url=https%3A%2F%2Fgw.claudeapi.com&amp;protocol=anthropic_messages&amp;source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="导入 claudeapi 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
</a>
<a class="provider-import-button provider-qiniu-ai" href="ccr://provider?name=%E4%B8%83%E7%89%9B%E4%BA%91+AI&amp;base_url=https%3A%2F%2Fapi.qnaigc.com&amp;protocol=openai_chat_completions&amp;source=https%3A%2F%2Fs.qiniu.com%2FAVjMVf" aria-label="导入七牛云 AI 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/qiniu-ai.png" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">七牛云 AI</span><span class="provider-import-meta">Chat / Responses / Anthropic / Gemini Generate</span></span>
</a>
<a class="provider-import-button provider-fenno" href="ccr://provider?name=Fenno.ai&amp;base_url=https%3A%2F%2Fapi.fenno.ai&amp;protocol=openai_chat_completions&amp;source=https%3A%2F%2Fapi.fenno.ai%2Fregister%3Fredirect%3D%2Fpurchase%3Ftab%3Dsubscription%2526group%3D16%26aff%3D9HHHAB5QLAES" aria-label="导入 Fenno.ai 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/fenno.jpg" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Fenno.ai</span><span class="provider-import-meta">Chat / Responses / Anthropic</span></span>
</a>
</div>
## 嵌入式按钮组件

View file

@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成;
| 继续重试 | `408``409``429``5xx` |
| 失败降级目标 | 任意 `4xx``5xx` |
进入下一次尝试前CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
对于 `429` 限流响应CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
**失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。

20
package-lock.json generated
View file

@ -1,18 +1,18 @@
{
"name": "claude-code-router-monorepo",
"version": "3.0.11",
"version": "3.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-code-router-monorepo",
"version": "3.0.11",
"version": "3.0.9",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.7",
"@the-next-ai/ai-gateway": "^1.0.5",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
@ -2310,9 +2310,9 @@
}
},
"node_modules/@the-next-ai/ai-gateway": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.7.tgz",
"integrity": "sha512-HJtfjpBgjFEPQ6ub7nFTtEyxzzbrz6V5vQZfJOoHi3Jhpjob3W4h0UenTW6loHanJYucFIEh01SvB//Qj3ulcw==",
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.5.tgz",
"integrity": "sha512-lpVgWpoVUS6QiL5ypowyOycx30aZ2RqLmGG1sanUbOZ69DZzdd/EMNE16IXYMVS3V7LvUqJI2teV4goijXpVpw==",
"license": "MIT",
"dependencies": {
"diff": "^8.0.3",
@ -9542,7 +9542,7 @@
},
"packages/cli": {
"name": "@musistudio/claude-code-router",
"version": "3.0.3",
"version": "3.0.9",
"license": "MIT",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.4",
@ -9560,7 +9560,7 @@
},
"packages/core": {
"name": "@claude-code-router/core",
"version": "3.0.3",
"version": "3.0.9",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.4",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
@ -9578,7 +9578,7 @@
},
"packages/electron": {
"name": "@claude-code-router/electron",
"version": "3.0.11",
"version": "3.0.9",
"dependencies": {
"better-sqlite3": "^12.11.1"
},
@ -9588,7 +9588,7 @@
},
"packages/ui": {
"name": "@claude-code-router/ui",
"version": "3.0.11",
"version": "3.0.9",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "claude-code-router-monorepo",
"version": "3.0.11",
"version": "3.0.9",
"private": true,
"license": "MIT",
"description": "Local Claude Code Router gateway with CLI and web management UI.",
@ -56,7 +56,7 @@
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.7",
"@the-next-ai/ai-gateway": "^1.0.5",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",

View file

@ -8,44 +8,12 @@
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
</p>
<div align="center">
<table width="100%">
<tr>
<td align="center">
<a href="https://www.kimi.com/code?aff=ccr">
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
</a>
<br />
<sub>
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.com?aff=ccr">API China</a>
</sub>
</td>
</tr>
<tr>
<td align="left">
<p>
<strong>Thanks to Kimi for sponsoring this project!</strong> Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
</p>
<p align="center">
CCR already supports Kimi. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">cost-effective Coding Plan</a>.
</p>
</td>
</tr>
</table>
</div>
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.
<p align="center">
<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.
## Why Use CCR
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
@ -208,20 +176,6 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co
<strong>TeamoRouter</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://s.qiniu.com/AVjMVf">
<img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="Qiniu Cloud AI icon" />
<br />
<strong>Qiniu Cloud AI</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=9HHHAB5QLAES">
<img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai icon" />
<br />
<strong>Fenno.ai</strong>
</a>
</td>
</tr>
</table>

View file

@ -8,44 +8,12 @@
<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>
<div align="center">
<table width="100%">
<tr>
<td align="center">
<a href="https://www.kimi.com/code?aff=ccr">
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
</a>
<br />
<sub>
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
&nbsp;·&nbsp;
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
</sub>
</td>
</tr>
<tr>
<td align="left">
<p>
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi订阅端点原生直通、无需协议转换API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
</p>
<p align="center">
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a><a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API或了解高性价比 <a href="https://www.kimi.com/code?aff=ccr">Coding Plan</a> 套餐。
</p>
</td>
</tr>
</table>
</div>
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
<p align="center">
<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
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
@ -207,20 +175,6 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
<strong>TeamoRouter</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://s.qiniu.com/AVjMVf">
<img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="七牛云 AI 图标" />
<br />
<strong>七牛云 AI</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=9HHHAB5QLAES">
<img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai 图标" />
<br />
<strong>Fenno.ai</strong>
</a>
</td>
</tr>
</table>

View file

@ -1,6 +1,6 @@
{
"name": "@musistudio/claude-code-router",
"version": "3.0.3",
"version": "3.0.9",
"license": "MIT",
"description": "Local Claude Code Router gateway with CLI and web management UI.",
"repository": {

View file

@ -6,7 +6,7 @@ import path from "node:path";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch";
import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
import { launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
import { loadAppConfig } from "@ccr/core/config/config";
import { CONFIGDIR } from "@ccr/core/config/constants";
import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service";
@ -150,9 +150,9 @@ async function main(): Promise<void> {
const launch = launchCodexAppProfile(configDir, profile, launchConfig);
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
if (spawnError) {
throw new Error(`Failed to open ${codexDesktopAppName}: ${spawnError}`);
throw new Error(`Failed to open Codex App: ${spawnError}`);
}
process.stdout.write(`Opened ${codexDesktopAppName} with ${profile.name || profile.id}.\n`);
process.stdout.write(`Opened Codex App with ${profile.name || profile.id}.\n`);
}
return;
}
@ -238,7 +238,7 @@ function profileAppName(profile: Pick<ProfileConfig, "agent">): string {
if (profile.agent === "zcode") {
return "ZCode App";
}
return codexDesktopAppName;
return "Codex App";
}
function parseStopArgs(args: string[]): StopCliOptions {

View file

@ -1,6 +1,6 @@
{
"name": "@claude-code-router/core",
"version": "3.0.3",
"version": "3.0.9",
"private": true,
"description": "Claude Code Router core gateway, routing, provider, and storage services.",
"main": "dist/main/server.js",

View file

@ -36,12 +36,8 @@ const windowsClaudeExeNames = [
];
const windowsClaudePackageKeywords = ["claude", "anthropic"];
type ClaudeAppCandidateOptions = {
allowGenericExecutable?: boolean;
};
export async function launchClaudeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): Promise<ClaudeAppLaunchResult> {
const lookup = findInstalledClaudeAppExecutable(profile.appPath);
const lookup = findInstalledClaudeAppExecutable();
if (!lookup.executable) {
throw new Error([
"Claude App was not found. Install Claude App or set CLAUDE_APP_PATH to its executable, then try again.",
@ -228,14 +224,9 @@ function claudeElectronUserDataDir(settingsDir: string, profile: ProfileConfig):
);
}
export function findInstalledClaudeAppExecutable(profileAppPath?: string): ClaudeAppLookupResult {
function findInstalledClaudeAppExecutable(): ClaudeAppLookupResult {
const checked: string[] = [];
const profileCandidate = findFirstExecutable(profileClaudeAppPathCandidates(profileAppPath), checked, { allowGenericExecutable: true });
if (profileCandidate) {
return { checked, executable: profileCandidate };
}
const envCandidate = findFirstExecutable(envClaudeAppPathCandidates(), checked, { allowGenericExecutable: true });
const envCandidate = findFirstExecutable(envClaudeAppPathCandidates(), checked);
if (envCandidate) {
return { checked, executable: envCandidate };
}
@ -249,13 +240,13 @@ export function findInstalledClaudeAppExecutable(profileAppPath?: string): Claud
return { checked, executable: findFirstExecutable(linuxClaudeAppCandidates(), checked) };
}
function findFirstExecutable(candidates: string[], checked: string[], options: ClaudeAppCandidateOptions = {}): string | undefined {
function findFirstExecutable(candidates: string[], checked: string[]): string | undefined {
for (const candidate of candidates) {
if (!candidate || checked.includes(candidate)) {
continue;
}
checked.push(candidate);
const executable = normalizeClaudeAppCandidate(candidate, options);
const executable = normalizeClaudeAppCandidate(candidate);
if (executable) {
return executable;
}
@ -270,11 +261,6 @@ function envClaudeAppPathCandidates(): string[] {
.map(resolveUserPath);
}
function profileClaudeAppPathCandidates(value: string | undefined): string[] {
const trimmed = value?.trim() || "";
return trimmed ? [resolveUserPath(trimmed)] : [];
}
function macClaudeAppCandidates(): string[] {
const roots = [
"/Applications",
@ -303,20 +289,13 @@ function windowsClaudeAppCandidates(): string[] {
function linuxClaudeAppCandidates(): string[] {
return [
"/usr/bin/claude",
"/usr/bin/claude-desktop",
"/usr/local/bin/claude",
"/usr/local/bin/claude-desktop",
"/opt/Claude/claude",
"/opt/Claude/Claude",
"/opt/Claude Desktop/claude",
"/opt/Claude Desktop/Claude",
"/opt/Claude Desktop/claude-desktop",
"/opt/ClaudeDesktop/ClaudeDesktop",
"/opt/AnthropicClaude/AnthropicClaude"
"/opt/Claude/Claude"
];
}
export function normalizeClaudeAppCandidate(candidate: string, options: ClaudeAppCandidateOptions = {}): string | undefined {
function normalizeClaudeAppCandidate(candidate: string): string | undefined {
if (process.platform === "darwin") {
if (candidate.endsWith(".app")) {
return executableFromMacAppBundle(candidate);
@ -324,10 +303,9 @@ export function normalizeClaudeAppCandidate(candidate: string, options: ClaudeAp
return isFile(candidate) ? candidate : undefined;
}
if (process.platform === "win32") {
const executable = normalizeWindowsClaudeAppCandidate(candidate);
return executable && isAllowedClaudeAppExecutable(executable, options) ? executable : undefined;
return normalizeWindowsClaudeAppCandidate(candidate);
}
return isFile(candidate) && isAllowedClaudeAppExecutable(candidate, options) ? candidate : undefined;
return isFile(candidate) ? candidate : undefined;
}
function executableFromMacAppBundle(appPath: string): string | undefined {
@ -380,25 +358,6 @@ function normalizeWindowsClaudeAppCandidate(candidate: string): string | undefin
});
}
function isAllowedClaudeAppExecutable(executable: string, options: ClaudeAppCandidateOptions): boolean {
if (options.allowGenericExecutable || !isGenericClaudeExecutableName(executable)) {
return true;
}
return hasElectronDesktopAppResources(executable);
}
function isGenericClaudeExecutableName(executable: string): boolean {
const name = path.basename(executable).toLowerCase();
return name === "claude" || name === "claude.exe";
}
function hasElectronDesktopAppResources(executable: string): boolean {
const resourcesDir = path.join(path.dirname(executable), "resources");
return isFile(path.join(resourcesDir, "app.asar")) ||
isDirectory(path.join(resourcesDir, "app")) ||
isDirectory(path.join(resourcesDir, "app.asar.unpacked"));
}
function profileEnv(profile: ProfileConfig): Record<string, string> {
return Object.entries(profile.env ?? {}).reduce<Record<string, string>>((result, [key, value]) => {
if (isEnvName(key) && typeof value === "string") {

View file

@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
@ -9,7 +9,7 @@ import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profil
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config";
export type CodexAppLookupResult = {
type CodexAppLookupResult = {
checked: string[];
executable?: string;
};
@ -47,21 +47,13 @@ export type CodexCompatibleAppModelCatalogWriteResult = {
userDataDir: string;
};
export const codexDesktopAppName = "ChatGPT";
const codexAppSpec: CodexCompatibleAppSpec = {
bundledCliNames: ["codex", "Codex", "OpenAI Codex"],
defaultCliCommand: "codex",
displayName: codexDesktopAppName,
envPathKeys: ["CCR_CHATGPT_APP_PATH", "CHATGPT_APP_PATH", "CODEXL_CHATGPT_PATH", "CCR_CODEX_APP_PATH", "CODEX_APP_PATH", "CODEXL_CODEX_PATH"],
displayName: "Codex App",
envPathKeys: ["CCR_CODEX_APP_PATH", "CODEX_APP_PATH", "CODEXL_CODEX_PATH"],
kind: "codex",
linuxCandidates: [
"/opt/ChatGPT/chatgpt",
"/opt/ChatGPT/ChatGPT",
"/opt/OpenAI ChatGPT/chatgpt",
"/opt/OpenAI ChatGPT/ChatGPT",
"/usr/local/bin/chatgpt-app",
"/usr/bin/chatgpt-app",
"/opt/Codex/codex",
"/opt/Codex/Codex",
"/opt/OpenAI Codex/codex",
@ -69,18 +61,11 @@ const codexAppSpec: CodexCompatibleAppSpec = {
"/usr/local/bin/codex-app",
"/usr/bin/codex-app"
],
macAppNames: ["ChatGPT.app", "OpenAI ChatGPT.app", "Codex.app", "OpenAI Codex.app"],
macAppNames: ["Codex.app", "OpenAI Codex.app"],
modelCatalogFilename: "ccr-codex-model-catalog.json",
userDataDirName: "codex-app-user-data",
windowsAppDirs: ["ChatGPT", "OpenAI ChatGPT", "OpenAIChatGPT", "Codex", "OpenAI Codex", "OpenAICodex"],
windowsAppDirs: ["Codex", "OpenAI Codex", "OpenAICodex"],
windowsExeNames: [
"ChatGPT.exe",
"chatgpt.exe",
"OpenAI ChatGPT.exe",
"OpenAIChatGPT.exe",
"OpenAIChatGPTApp.exe",
"chatgpt-app.exe",
"openai-chatgpt.exe",
"Codex.exe",
"codex.exe",
"OpenAI Codex.exe",
@ -89,16 +74,9 @@ const codexAppSpec: CodexCompatibleAppSpec = {
"codex-app.exe",
"openai-codex.exe"
],
windowsPackageKeywords: ["chatgpt", "openaichatgpt", "codex", "openaicodex"],
windowsPackageKeywords: ["codex", "openaicodex"],
windowsVendorDirs: ["OpenAI"],
windowsWhereNames: [
"ChatGPT",
"chatgpt",
"OpenAI ChatGPT",
"OpenAIChatGPT",
"OpenAIChatGPTApp",
"chatgpt-app",
"openai-chatgpt",
"Codex",
"codex",
"OpenAI Codex",
@ -157,10 +135,6 @@ export function launchCodexAppProfile(configDir: string, profile: ProfileConfig,
return launchCodexCompatibleAppProfile(configDir, profile, codexAppSpec, config);
}
export function findInstalledCodexAppExecutable(profileAppPath?: string): CodexAppLookupResult {
return findInstalledCodexCompatibleAppExecutable(codexAppSpec, profileAppPath);
}
export function launchZcodeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult {
return launchCodexCompatibleAppProfile(configDir, profile, zcodeAppSpec, config);
}
@ -190,9 +164,6 @@ export function writeCodexCompatibleAppModelCatalog(
const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec;
const configFile = resolveCodexConfigFile(configDir, profile);
const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile);
if (spec.kind === "codex") {
removeLegacyCodexVirtualAuthMarker(codexHome);
}
const userDataDir = codexElectronUserDataDir(codexHome, profile, spec);
mkdirSync(userDataDir, { recursive: true });
const file = codexAppModelCatalogFile(userDataDir, spec);
@ -204,37 +175,13 @@ export function writeCodexCompatibleAppModelCatalog(
return { changed: previous !== content, file, userDataDir };
}
export function removeLegacyCodexVirtualAuthMarker(codexHome: string): boolean {
const authFile = path.join(codexHome, "auth.json");
if (!isFile(authFile)) {
return false;
}
try {
const value = JSON.parse(readFileSync(authFile, "utf8")) as Record<string, unknown>;
const keys = Object.keys(value).sort();
if (
keys.length !== 2 ||
keys[0] !== "OPENAI_API_KEY" ||
keys[1] !== "auth_mode" ||
value.auth_mode !== "apikey" ||
value.OPENAI_API_KEY !== "ccr-local-profile"
) {
return false;
}
unlinkSync(authFile);
return true;
} catch {
return false;
}
}
function launchCodexCompatibleAppProfile(
configDir: string,
profile: ProfileConfig,
spec: CodexCompatibleAppSpec,
config?: AppConfig
): CodexAppLaunchResult {
const lookup = findInstalledCodexCompatibleAppExecutable(spec, profile.appPath);
const lookup = findInstalledCodexAppExecutable(spec);
if (!lookup.executable) {
throw new Error([
`${spec.displayName} was not found. Install ${spec.displayName} or set ${spec.envPathKeys[1]} to its executable, then try again.`,
@ -271,7 +218,7 @@ function launchCodexCompatibleAppProfile(
delete env.CODEXL_ZCODE_MODEL_CATALOG_B64;
sanitizeCodexCompatibleAppEnv(env, spec.kind);
const launch = codexAppLaunchCommand(lookup.executable, userDataDir);
const launch = codexAppLaunchCommand(lookup.executable, userDataDir, appEnv);
const child = spawn(launch.command, launch.args, {
detached: true,
env,
@ -308,9 +255,6 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co
}
return {
...(profile.model.trim() ? { CCR_CODEX_MODEL: profile.model.trim() } : {}),
...(process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG?.trim()
? { CCR_CODEX_CLI_MIDDLEWARE_LOG: process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG.trim() }
: {}),
CCR_CODEX_MODEL_PROVIDER: providerId,
CCR_CODEX_PROFILE: providerId,
CCR_CODEX_REMOTE_FRONTEND_MODE: remoteFrontendMode,
@ -407,13 +351,34 @@ function codexElectronArgs(userDataDir: string): string[] {
];
}
function codexAppLaunchCommand(executable: string, userDataDir: string): { args: string[]; command: string; pidIsLauncher?: boolean } {
function codexAppLaunchCommand(executable: string, userDataDir: string, env: Record<string, string>): { args: string[]; command: string; pidIsLauncher?: boolean } {
const appBundle = process.platform === "darwin" ? macAppBundleFromExecutable(executable) : undefined;
if (appBundle) {
return {
command: "/usr/bin/open",
pidIsLauncher: true,
args: [
"-W",
"-n",
...macOpenEnvArgs(env),
appBundle,
"--args",
...codexElectronArgs(userDataDir)
]
};
}
return {
command: executable,
args: codexElectronArgs(userDataDir)
};
}
function macOpenEnvArgs(env: Record<string, string>): string[] {
return Object.entries(env)
.filter(([key, value]) => isEnvName(key) && typeof value === "string")
.flatMap(([key, value]) => ["--env", `${key}=${value}`]);
}
function macAppBundleFromExecutable(executable: string): string | undefined {
const marker = ".app/Contents/MacOS/";
const index = executable.indexOf(marker);
@ -424,6 +389,10 @@ function macAppBundleFromExecutable(executable: string): string | undefined {
return isDirectory(appBundle) ? appBundle : undefined;
}
function isEnvName(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function codexElectronUserDataDir(codexHome: string, profile: ProfileConfig, spec: CodexCompatibleAppSpec): string {
return path.join(
codexHome,
@ -441,13 +410,8 @@ function codexCompatibleHomeFromConfigFile(spec: CodexCompatibleAppSpec, configF
return spec.kind === "zcode" ? zcodeHomeFromConfigFile(configFile) : path.dirname(configFile);
}
function findInstalledCodexCompatibleAppExecutable(spec: CodexCompatibleAppSpec, profileAppPath?: string): CodexAppLookupResult {
function findInstalledCodexAppExecutable(spec: CodexCompatibleAppSpec): CodexAppLookupResult {
const checked: string[] = [];
const profileCandidate = findFirstExecutable(profileCodexAppPathCandidates(profileAppPath), checked, spec);
if (profileCandidate) {
return { checked, executable: profileCandidate };
}
const envCandidate = findFirstExecutable(envCodexAppPathCandidates(spec), checked, spec);
if (envCandidate) {
return { checked, executable: envCandidate };
@ -483,11 +447,6 @@ function envCodexAppPathCandidates(spec: CodexCompatibleAppSpec): string[] {
.map(resolveUserPath);
}
function profileCodexAppPathCandidates(value: string | undefined): string[] {
const trimmed = value?.trim() || "";
return trimmed ? [resolveUserPath(trimmed)] : [];
}
function macCodexAppCandidates(spec: CodexCompatibleAppSpec): string[] {
const roots = [
"/Applications",

View file

@ -30,11 +30,6 @@ const CLAUDE_CODE_CHINA_TIME_ZONES = new Set([
"china standard time",
"prc"
]);
const ACCOUNT_REMOTE_PLUGIN_MARKETPLACE_KINDS = new Set([
"created-by-me-remote",
"shared-with-me",
"workspace-directory"
]);
let BOT_BRIDGE_INSTANCE = null;
function claudeCodeUtcTimezoneEnvOverride() {
@ -289,19 +284,16 @@ async function runCodexCliMiddleware(args) {
return;
}
const cleanupAuthBootstrap = createEphemeralCodexApiKeyBootstrap(runtimeAgent);
const child = childProcess.spawn(realCli, realArgs, {
env: childEnvForAgent(runtimeAgent),
stdio: ["pipe", "pipe", "inherit"]
});
child.on("error", (error) => {
cleanupAuthBootstrap();
log("codex_cli_spawn_error", { error: formatError(error) });
});
const requestMap = new Map();
const current = { cwd: "" };
const chatGptAuth = loadChatGptAuth();
const stdinRl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
stdinRl.on("line", (line) => {
const custom = customAppServerLineResponse(line);
@ -311,16 +303,13 @@ async function runCodexCliMiddleware(args) {
}
const rewritten = rewriteCodexStdinLine(line);
trackRequestLine(rewritten, requestMap, current);
if (!child.stdin.destroyed) child.stdin.write(rewritten + "\n");
});
stdinRl.on("close", () => {
if (!child.stdin.destroyed) child.stdin.end();
child.stdin.write(rewritten + "\n");
});
stdinRl.on("close", () => child.stdin.end());
const stdoutRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity, terminal: false });
stdoutRl.on("line", (line) => {
cleanupAuthBootstrap();
const rewritten = rewriteCodexStdoutLine(line, requestMap, chatGptAuth);
const rewritten = rewriteCodexStdoutLine(line, requestMap);
botBridge().handleJsonRpcLine(rewritten);
if (!shouldSuppressBotBridgeLine(rewritten)) {
process.stdout.write(rewritten + "\n");
@ -328,65 +317,10 @@ async function runCodexCliMiddleware(args) {
});
const exit = await waitForChildResult(child);
cleanupAuthBootstrap();
log("codex_cli_exit", { code: exit.code, signal: exit.signal, exitCode: exit.exitCode });
process.exitCode = exit.exitCode;
}
function createEphemeralCodexApiKeyBootstrap(runtimeAgent) {
if (runtimeAgent !== "codex") return () => {};
const scope = nonEmptyEnv("CCR_PROFILE_SCOPE");
if (scope !== "ccr" && scope !== "custom") return () => {};
const authFile = path.join(codexRuntimeHome(), "auth.json");
if (fs.existsSync(authFile)) return () => {};
const temporary = authFile + ".tmp-" + process.pid;
let active = false;
try {
fs.mkdirSync(path.dirname(authFile), { recursive: true, mode: 0o700 });
fs.writeFileSync(temporary, JSON.stringify({
auth_mode: "apikey",
OPENAI_API_KEY: "ccr-local-profile"
}, null, 2) + "\n", { mode: 0o600 });
fs.renameSync(temporary, authFile);
active = true;
log("codex_auth_bootstrap_created", { authFile });
} catch (error) {
try {
fs.unlinkSync(temporary);
} catch {
}
log("codex_auth_bootstrap_create_error", { authFile, error: formatError(error) });
}
return () => {
if (!active) return;
try {
if (!fs.existsSync(authFile)) {
active = false;
return;
}
const value = readJsonFile(authFile);
const keys = value && typeof value === "object" ? Object.keys(value).sort() : [];
if (
keys.length === 2 &&
keys[0] === "OPENAI_API_KEY" &&
keys[1] === "auth_mode" &&
value.auth_mode === "apikey" &&
value.OPENAI_API_KEY === "ccr-local-profile"
) {
fs.unlinkSync(authFile);
active = false;
log("codex_auth_bootstrap_removed", { authFile });
return;
}
active = false;
log("codex_auth_bootstrap_preserved_changed_file", { authFile });
} catch (error) {
log("codex_auth_bootstrap_remove_error", { authFile, error: formatError(error) });
}
};
}
async function runDirectCodexCli(realCli, realArgs) {
const runtimeAgent = codexRuntimeAgent();
const child = childProcess.spawn(realCli, realArgs, {
@ -462,7 +396,7 @@ function cliConfigString(key, value) {
return key + "=\"" + tomlEscape(value) + "\"";
}
function rewriteCodexStdoutLine(line, requestMap, chatGptAuth) {
function rewriteCodexStdoutLine(line, requestMap) {
let value;
try {
value = JSON.parse(line);
@ -473,37 +407,15 @@ function rewriteCodexStdoutLine(line, requestMap, chatGptAuth) {
if (!id || !requestMap.has(id)) return line;
const request = requestMap.get(id);
requestMap.delete(id);
if (value.error) {
if (request.method === "model/list" || request.method === "plugin/list") {
log("app_server_list_error", { method: request.method, error: value.error });
}
return line;
}
if (value.error) return line;
if (request.method === "account/read") {
value.result = codexAppAccountRead(chatGptAuth);
value.result = mockAccountRead();
} else if (request.method === "getAuthStatus") {
value.result = codexAppAuthStatus(chatGptAuth, request.includeToken);
value.result = mockAuthStatus(request.includeToken);
} else if (request.method === "thread/list") {
value = mergeForeignThreadList(value, request.params);
} else if (request.method === "model/list") {
log("app_server_model_list_response", {
count: extractModelListItems(value.result).length,
nextCursor: value.result && value.result.nextCursor
});
return line;
} else if (request.method === "plugin/list") {
const marketplaces = value.result && Array.isArray(value.result.marketplaces) ? value.result.marketplaces : [];
log("app_server_plugin_list_response", {
marketplaceCount: marketplaces.length,
marketplaces: marketplaces.map((marketplace) => ({
name: marketplace && marketplace.name,
path: marketplace && marketplace.path,
pluginCount: marketplace && Array.isArray(marketplace.plugins) ? marketplace.plugins.length : 0
}))
});
return line;
} else {
return line;
value.result = modelList(request.params, value.result);
}
return JSON.stringify(value);
}
@ -521,9 +433,6 @@ function rewriteCodexStdinLine(line) {
if (!value || typeof value !== "object" || typeof value.method !== "string") {
return line;
}
if (value.method === "model/list" || value.method === "plugin/list") {
log("app_server_list_request", { method: value.method, params: value.params || {} });
}
let changed = false;
if (normalizeCliAppServerRequest(value)) {
changed = true;
@ -868,7 +777,7 @@ function trackRequestLine(line, requestMap, current) {
if (!id || !method) return;
const cwd = requestWorkspaceCwd(value, method);
if (cwd) current.cwd = cwd;
if (!["account/read", "getAuthStatus", "thread/list", "config/read", "model/list", "plugin/list"].includes(method)) return;
if (!["account/read", "getAuthStatus", "thread/list", "config/read", "model/list"].includes(method)) return;
const params = clone(value.params || {});
if (method === "thread/list" && current.cwd && !params.codexlWorkspaceCwd) {
params.codexlWorkspaceCwd = current.cwd;
@ -887,13 +796,6 @@ function customAppServerLineResponse(line) {
} catch {
return undefined;
}
if (value && typeof value.method === "string") {
log("app_server_request", {
id: jsonRpcIdKey(value.id),
method: value.method,
params: value.params || {}
});
}
if (value && value.type === "fetch" && String(value.method || "").toUpperCase() === "POST" && fetchUrlIsTranscribe(value.url)) {
return {
requestId: value.requestId || value.id || uuid(),
@ -903,25 +805,9 @@ function customAppServerLineResponse(line) {
headers: { "content-type": "application/json" }
};
}
if (value && value.method === "plugin/list" && jsonRpcIdKey(value.id) && accountRemoteOnlyPluginList(value.params)) {
log("app_server_account_remote_plugin_list_empty", {
marketplaceKinds: value.params.marketplaceKinds
});
return {
id: value.id,
result: { marketplaces: [], marketplaceLoadErrors: [], featuredPluginIds: [] }
};
}
return undefined;
}
function accountRemoteOnlyPluginList(params) {
const kinds = params && Array.isArray(params.marketplaceKinds)
? params.marketplaceKinds.map((kind) => String(kind || "")).filter(Boolean)
: [];
return kinds.length > 0 && kinds.every((kind) => ACCOUNT_REMOTE_PLUGIN_MARKETPLACE_KINDS.has(kind));
}
function fetchUrlIsTranscribe(url) {
const text = String(url || "").trim();
if (text === "/transcribe") return true;
@ -2577,83 +2463,15 @@ function configWriteResponse(params) {
return { config: params.config || null, ok: true };
}
function loadChatGptAuth() {
const workspaceName = nonEmptyEnv("CCR_CODEX_WORKSPACE_NAME") ||
nonEmptyEnv("CODEXL_CODEX_WORKSPACE_NAME") ||
nonEmptyEnv("CODEXL_CODEX_INSTANCE_NAME") ||
agentEnv("codex", "PROFILE");
const fallback = {
authToken: "",
email: "",
planType: "",
workspaceName
};
const value = readJsonFile(path.join(codexRuntimeHome(), "auth.json"));
if (!value || !isPlainObject(value)) return fallback;
if (typeof value.auth_mode === "string" && value.auth_mode !== "chatgpt") return fallback;
if (!isPlainObject(value.tokens)) return fallback;
const authToken = stringValue(value.tokens.access_token);
const idToken = stringValue(value.tokens.id_token);
const claims = jwtPayloadClaims(authToken) || jwtPayloadClaims(idToken) || {};
const profileClaims = isPlainObject(claims["https://api.openai.com/profile"])
? claims["https://api.openai.com/profile"]
: {};
const authClaims = isPlainObject(claims["https://api.openai.com/auth"])
? claims["https://api.openai.com/auth"]
: {};
return {
authToken,
email: stringValue(profileClaims.email) || stringValue(claims.email) || stringValue(value.email),
planType: stringValue(authClaims.chatgpt_plan_type) || stringValue(claims.chatgpt_plan_type),
workspaceName
};
}
function jwtPayloadClaims(token) {
if (!token) return undefined;
const payload = String(token).split(".")[1];
if (!payload) return undefined;
try {
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
const value = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
return isPlainObject(value) ? value : undefined;
} catch {
return undefined;
}
}
function codexAppAccountRead(auth) {
return {
account: {
type: "chatgpt",
email: auth.email || auth.workspaceName || "codex",
planType: auth.planType || "unknown"
},
requiresOpenaiAuth: true
};
}
function codexAppAuthStatus(auth, includeToken) {
const result = {
authMethod: "chatgpt",
requiresOpenaiAuth: true
};
if (includeToken) result.authToken = auth.authToken || null;
return result;
}
function mockAccountRead() {
return {
account: { type: "amazonBedrock", credentialSource: "codexManaged" },
requiresOpenaiAuth: false
};
const runtimeAgent = codexRuntimeAgent();
const email = agentEnv(runtimeAgent, "WORKSPACE_NAME") || (runtimeAgent === "zcode" ? "ZCode" : "Claude Code");
return { account: { type: "chatgpt", email, planType: "unknown" }, requiresOpenaiAuth: false };
}
function mockAuthStatus(includeToken) {
const result = { authMethod: "amazonBedrock", authToken: null, requiresOpenaiAuth: false };
if (includeToken) result.authToken = "ccr-local-profile";
const result = { authMethod: "chatgpt", account: mockAccountRead().account, requiresOpenaiAuth: false };
if (includeToken) result.authToken = null;
return result;
}
@ -2701,7 +2519,7 @@ function claudeControlPermissionResponse(message, requestId, approval) {
const allows = permissionResponseAllows(approval);
const response = allows
? { behavior: "allow", updatedInput: pointer(message, "/request/input") || pointer(message, "/params/input") || {} }
: { behavior: "deny", message: "Denied in ChatGPT" };
: { behavior: "deny", message: "Denied in Codex App" };
const toolUseId = firstString(message, ["/request/tool_use_id", "/request/toolUseId", "/params/tool_use_id"]);
if (toolUseId) response.toolUseID = toolUseId;
return { type: "control_response", response: { subtype: "success", request_id: requestId, response } };
@ -2721,7 +2539,7 @@ async function waitForAppResponse(map, requestId, timeoutMs) {
}
await sleep(100);
}
throw new Error("Timed out waiting for ChatGPT response: " + requestId);
throw new Error("Timed out waiting for Codex App response: " + requestId);
}
function permissionResponseAllows(value) {

View file

@ -1,14 +1,11 @@
import { BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "@ccr/core/contracts/app";
import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, ProviderModelMetadata, ProviderReasoningLevel, VirtualModelProfileConfig } from "@ccr/core/contracts/app";
import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "@ccr/core/contracts/app";
import {
findModelCatalogEntry,
modelCatalogMaxInputTokens,
readCatalogCapability,
type ModelCatalogEntry
} from "@ccr/core/gateway/model-catalog";
import { codexDefaultBaseUrl, readCodexLocalModelCatalog } from "@ccr/core/agents/local-providers/codex";
import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared";
import { normalizeProviderBaseUrl } from "@ccr/core/providers/url";
const fusionModelProviderName = "Fusion";
const codexDefaultContextWindow = 128_000;
@ -118,13 +115,13 @@ function codexModelCatalogItem(
const profile = codexModelCapabilityProfile(model, config);
const contextWindow = codexModelContextWindow(model, profile.catalogEntry);
return {
additional_speed_tiers: profile.additionalSpeedTiers,
additional_speed_tiers: [],
apply_patch_tool_type: profile.applyPatchToolType,
availability_nux: null,
base_instructions: "You are Codex, a coding agent.",
context_window: contextWindow,
default_reasoning_level: profile.defaultReasoningLevel,
default_reasoning_summary: profile.defaultReasoningSummary,
default_reasoning_level: profile.supportsReasoning ? "medium" : null,
default_reasoning_summary: "none",
description: `CCR gateway model ${model}`,
display_name: model,
effective_context_window_percent: codexEffectiveContextWindowPercent,
@ -132,7 +129,7 @@ function codexModelCatalogItem(
input_modalities: profile.inputModalities,
max_context_window: contextWindow,
priority,
service_tiers: profile.serviceTiers,
service_tiers: [],
shell_type: "shell_command",
slug: model,
support_verbosity: true,
@ -150,14 +147,10 @@ function codexModelCatalogItem(
}
type CodexCapabilityProfile = {
additionalSpeedTiers: unknown[];
applyPatchToolType: string | null;
catalogEntry?: ModelCatalogEntry;
defaultReasoningLevel: string | null;
defaultReasoningSummary: string;
inputModalities: string[];
supportedReasoningLevels: Array<{ description: string; effort: string }>;
serviceTiers: unknown[];
supportsImageInput: boolean;
supportsParallelToolCalls: boolean;
supportsReasoning: boolean;
@ -169,18 +162,13 @@ function codexModelCapabilityProfile(
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>
): CodexCapabilityProfile {
const selector = parseModelSelector(model);
const provider = selector?.provider ? findConfiguredProvider(config, selector.provider) : findConfiguredProviderForModel(config, model);
const providerModel = selector?.model ?? model;
const providerModelMetadata = provider
? providerModelMetadataFor(provider, providerModel) ?? localCodexModelMetadataFor(provider, providerModel)
: undefined;
const provider = selector?.provider ? findConfiguredProvider(config, selector.provider) : undefined;
const catalogEntry = findModelCatalogEntry(model);
const capabilities = catalogEntry?.capabilities ?? {};
const providerProtocol = provider ? codexProviderProtocol(provider) : undefined;
const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false;
const supportsFusionWebSearch = codexVirtualModelSupportsFusionWebSearch(model, config);
const metadataReasoningLevels = normalizeProviderReasoningLevels(providerModelMetadata?.supportedReasoningLevels);
const supportsReasoning = providerModelMetadata?.supportsReasoningSummaries ?? (metadataReasoningLevels ? true : readCatalogCapability(capabilities, "reasoning"));
const supportsReasoning = readCatalogCapability(capabilities, "reasoning");
const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry);
const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling");
const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config)
@ -198,18 +186,10 @@ function codexModelCapabilityProfile(
);
return {
additionalSpeedTiers: providerModelMetadata?.additionalSpeedTiers ?? [],
applyPatchToolType,
catalogEntry,
defaultReasoningLevel: providerModelMetadata && providerModelMetadata.defaultReasoningLevel !== undefined
? providerModelMetadata.defaultReasoningLevel
: supportsReasoning
? "medium"
: null,
defaultReasoningSummary: providerModelMetadata?.defaultReasoningSummary ?? "none",
inputModalities: supportsImageInput ? ["text", "image"] : ["text"],
serviceTiers: providerModelMetadata?.serviceTiers ?? [],
supportedReasoningLevels: metadataReasoningLevels ?? (supportsReasoning ? supportedReasoningLevels(capabilities) : []),
supportedReasoningLevels: supportsReasoning ? supportedReasoningLevels(capabilities) : [],
supportsImageInput,
supportsParallelToolCalls,
supportsReasoning,
@ -217,64 +197,6 @@ function codexModelCapabilityProfile(
};
}
function providerModelMetadataFor(provider: GatewayProviderConfig, model: string): ProviderModelMetadata | undefined {
const metadata = provider.modelMetadata ?? {};
const direct = metadata[model];
if (direct) {
return direct;
}
const normalized = model.trim().toLowerCase();
const match = Object.entries(metadata).find(([candidate]) => candidate.trim().toLowerCase() === normalized);
return match?.[1];
}
function localCodexModelMetadataFor(provider: GatewayProviderConfig, model: string): ProviderModelMetadata | undefined {
if (!isLocalCodexProvider(provider)) {
return undefined;
}
return readCodexLocalModelCatalog().modelMetadata?.[model];
}
function isLocalCodexProvider(provider: GatewayProviderConfig): boolean {
const baseUrl = providerBaseUrl(provider).trim().replace(/\/+$/g, "");
const normalizedBaseUrl = normalizeProviderBaseUrl(baseUrl);
const normalizedCodexBaseUrl = normalizeProviderBaseUrl(codexDefaultBaseUrl);
return (
providerApiKey(provider) === localAgentProviderApiKey &&
(
baseUrl.toLowerCase() === codexDefaultBaseUrl.toLowerCase() ||
baseUrl.toLowerCase().includes("chatgpt.com/backend-api/codex") ||
normalizedBaseUrl === normalizedCodexBaseUrl
)
);
}
function providerBaseUrl(provider: GatewayProviderConfig): string {
return provider.api_base_url || provider.baseUrl || provider.baseurl || "";
}
function providerApiKey(provider: GatewayProviderConfig): string {
return provider.api_key || provider.apiKey || provider.apikey || "";
}
function normalizeProviderReasoningLevels(levels: ProviderReasoningLevel[] | undefined): Array<{ description: string; effort: string }> | undefined {
const normalized = (levels ?? [])
.map((level) => ({
description: level.description.trim() || effortDescription(level.effort),
effort: level.effort.trim()
}))
.filter((level) => level.effort);
return normalized.length > 0 ? normalized : undefined;
}
function effortDescription(effort: string): string {
const normalized = effort.trim().toLowerCase();
if (normalized === "xhigh") {
return "Extra high reasoning";
}
return `${effort.slice(0, 1).toUpperCase()}${effort.slice(1)} reasoning`;
}
function codexModelContextWindow(model: string, entry = findModelCatalogEntry(model)): number {
return modelCatalogMaxInputTokens(entry) || codexDefaultContextWindow;
}
@ -311,19 +233,6 @@ function findConfiguredProvider(
return (config?.Providers ?? []).find((provider) => provider.name.trim().toLowerCase() === normalized);
}
function findConfiguredProviderForModel(
config: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">> | undefined,
model: string
): GatewayProviderConfig | undefined {
const normalized = model.trim().toLowerCase();
if (!normalized) {
return undefined;
}
return (config?.Providers ?? []).find((provider) =>
provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized)
);
}
function codexProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol | undefined {
const capabilityProtocols = uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type)));
for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content", "gemini_interactions"] as GatewayProviderProtocol[]) {

View file

@ -1,4 +1,3 @@
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import type {
@ -10,7 +9,6 @@ import type {
import {
bearerAuthPlugin,
findOauthTokenSet,
isRecord,
missingCandidate,
providerInternalNamePlaceholder,
providerPayload,
@ -20,8 +18,7 @@ import {
type OAuthTokenSet
} from "@ccr/core/agents/local-providers/shared";
const claudeDefaultModels = ["claude-sonnet-5"];
const claudeCodeKeychainService = "Claude Code-credentials";
const claudeDefaultModels = ["claude-sonnet-4-20250514"];
const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({
id,
@ -151,19 +148,6 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined {
sourceFile
};
}
const keychainRecord = readClaudeCodeKeychainRecord();
if (keychainRecord) {
const credential = findOauthTokenSet(keychainRecord);
if (credential) {
return {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
sourceFile: `keychain:${claudeCodeKeychainService}`
};
}
}
return undefined;
}
@ -174,24 +158,3 @@ function claudeCredentialFiles(): string[] {
path.join(os.homedir(), ".config", "claude", "credentials.json")
]);
}
// Newer macOS builds of the Claude Code CLI store credentials in the
// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the
// standard macOS keychain access prompt (Allow / Always Allow); the user
// declining or the item not existing both surface as a non-zero exit here.
function readClaudeCodeKeychainRecord(): Record<string, unknown> | undefined {
if (process.platform !== "darwin") {
return undefined;
}
try {
const output = execFileSync(
"security",
["find-generic-password", "-s", claudeCodeKeychainService, "-w"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
);
const parsed = JSON.parse(output.trim()) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}

View file

@ -3,23 +3,18 @@ import path from "node:path";
import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportResult,
LocalAgentProviderProbeResult,
GatewayProviderConfig,
ProviderAccountConfig,
ProviderAccountConnectorConfig,
ProviderAccountMappingConfig,
ProviderAccountMeter,
ProviderAccountMeterDetail,
ProviderModelMetadata,
ProviderReasoningLevel
ProviderAccountMeterDetail
} from "@ccr/core/contracts/app";
import { normalizeProviderBaseUrl } from "@ccr/core/providers/url";
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
import {
isRecord,
localAgentProviderApiKey,
missingCandidate,
modelMetadataForModels,
modelDisplayNamesForModels,
providerInternalNamePlaceholder,
providerNamePlaceholder,
@ -37,11 +32,9 @@ export const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex";
const codexAccountBaseUrl = "https://chatgpt.com/backend-api";
const codexDefaultModels = ["gpt-5-codex"];
const codexProbeTimeoutMs = 8_000;
export type LocalAgentModelCatalog = {
type LocalAgentModelCatalog = {
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
models: string[];
};
@ -240,7 +233,7 @@ const codexAccountTokenUsageMapping: ProviderAccountMappingConfig = {
export function codexCandidate(): LocalAgentProviderCandidate {
const auth = readCodexAuth();
const catalog = readCodexLocalModelCatalog();
const catalog = readCodexModelCatalog();
if (auth?.refreshToken || auth?.accessToken) {
return {
detail: "ChatGPT login detected. Click Import to add it as a gateway provider.",
@ -248,7 +241,6 @@ export function codexCandidate(): LocalAgentProviderCandidate {
importable: true,
kind: "codex",
modelDisplayNames: catalog.modelDisplayNames,
modelMetadata: catalog.modelMetadata,
models: catalog.models,
name: "Codex API",
protocol: "openai_responses",
@ -259,15 +251,14 @@ export function codexCandidate(): LocalAgentProviderCandidate {
return missingCandidate("codex", "codex-api", "Codex API", "openai_responses", catalog.models, catalog.modelDisplayNames);
}
export async function importCodexProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): Promise<LocalAgentProviderImportResult> {
export function importCodexProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
const auth = readCodexAuth();
if (!auth?.refreshToken && !auth?.accessToken) {
throw new Error("Codex login token was not found.");
}
const probedCandidate = await codexCandidateWithProbedModels(candidate).catch(() => candidate);
const provider = providerPayload(probedCandidate, uniqueProviderName(providerNames, "Codex API"), codexDefaultBaseUrl, codexProviderAccountConfig());
const provider = providerPayload(candidate, uniqueProviderName(providerNames, "Codex API"), codexDefaultBaseUrl, codexProviderAccountConfig());
return {
candidate: probedCandidate,
candidate,
provider,
providerPlugins: [
codexOauthPlugin("codex-oauth"),
@ -286,16 +277,8 @@ export async function importCodexProvider(candidate: LocalAgentProviderCandidate
};
}
export async function probeCodexProvider(candidate: LocalAgentProviderCandidate): Promise<LocalAgentProviderProbeResult> {
const probedCandidate = await codexCandidateWithProbedModels(candidate).catch(() => candidate);
return {
candidate: probedCandidate,
probe: codexProviderProbe(probedCandidate)
};
}
export function readCodexAuth(): OAuthTokenSet | undefined {
const sourceFile = path.join(codexHomeDir(), ".codex", "auth.json");
const sourceFile = path.join(os.homedir(), ".codex", "auth.json");
const record = readJsonRecord(sourceFile);
if (!record) {
return undefined;
@ -605,234 +588,33 @@ function codexBackendRequestTransform(): Record<string, unknown> {
};
}
export function readCodexLocalModelCatalog(): LocalAgentModelCatalog {
const modelsFile = path.join(codexHomeDir(), ".codex", "models_cache.json");
function readCodexModelCatalog(): LocalAgentModelCatalog {
const modelsFile = path.join(os.homedir(), ".codex", "models_cache.json");
const record = readJsonRecord(modelsFile);
const catalog = codexModelCatalogFromPayload(record);
const uniqueModels = uniqueStrings([...catalog.models, ...codexDefaultModels]);
return {
modelDisplayNames: modelDisplayNamesForModels(catalog.modelDisplayNames, uniqueModels),
modelMetadata: modelMetadataForModels(catalog.modelMetadata, uniqueModels),
models: uniqueModels
};
}
async function codexCandidateWithProbedModels(candidate: LocalAgentProviderCandidate): Promise<LocalAgentProviderCandidate> {
const catalog = await fetchCodexModelCatalog();
if (catalog.models.length === 0) {
return candidate;
}
return {
...candidate,
modelDisplayNames: catalog.modelDisplayNames,
modelMetadata: catalog.modelMetadata,
models: catalog.models
};
}
async function fetchCodexModelCatalog(): Promise<LocalAgentModelCatalog> {
const auth = readCodexAuth();
if (!auth?.accessToken) {
throw new Error("Codex access token was not found.");
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), codexProbeTimeoutMs);
try {
const response = await fetchWithSystemProxy(`${codexDefaultBaseUrl}/models`, {
headers: {
accept: "application/json",
authorization: `Bearer ${auth.accessToken}`,
"User-Agent": "codex-cli",
...(auth.accountId ? { "ChatGPT-Account-Id": auth.accountId } : {}),
...(auth.isFedrampAccount ? { "X-OpenAI-Fedramp": "true" } : {})
},
method: "GET",
signal: controller.signal
});
const text = await response.text();
if (!response.ok) {
throw new Error(`Codex model probe returned HTTP ${response.status}.`);
}
const payload = parseJson(text);
const catalog = codexModelCatalogFromPayload(payload);
if (catalog.models.length === 0) {
throw new Error("Codex model probe returned no models.");
}
return catalog;
} finally {
clearTimeout(timer);
}
}
function codexProviderProbe(candidate: LocalAgentProviderCandidate) {
return {
capabilities: [
{
baseUrl: codexDefaultBaseUrl,
source: "detected" as const,
type: "openai_responses" as const
}
],
detectedProtocol: "openai_responses" as const,
modelDisplayNames: candidate.modelDisplayNames,
modelMetadata: candidate.modelMetadata,
modelSource: "openai" as const,
models: candidate.models,
normalizedBaseUrl: codexDefaultBaseUrl,
protocols: [
{
baseUrl: codexDefaultBaseUrl,
endpoint: `${codexDefaultBaseUrl}/responses`,
message: "",
protocol: "openai_responses" as const,
supported: true
}
]
};
}
function codexModelCatalogFromPayload(payload: unknown): LocalAgentModelCatalog {
const models: string[] = [];
const modelDisplayNames: Record<string, string> = {};
const modelMetadata: Record<string, ProviderModelMetadata> = {};
for (const item of codexModelCatalogItems(payload)) {
for (const item of Array.isArray(record?.models) ? record.models : []) {
const model = isRecord(item)
? readString(item.slug) || readString(item.id) || readString(item.model) || readString(item.name)
? readString(item.slug) || readString(item.id) || readString(item.name)
: readString(item);
if (!model) {
continue;
}
models.push(model);
if (isRecord(item)) {
const displayName = readString(item.display_name) || readString(item.displayName) || readString(item.label) || readString(item.title) || readString(item.name);
const displayName = readString(item.display_name) || readString(item.displayName) || readString(item.label) || readString(item.name);
if (displayName && displayName !== model) {
modelDisplayNames[model] = displayName;
}
const metadata = codexModelMetadataFromItem(item);
if (metadata) {
modelMetadata[model] = metadata;
}
}
}
const uniqueModels = uniqueStrings(models);
const uniqueModels = uniqueStrings([...models, ...codexDefaultModels]);
return {
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels),
modelMetadata: modelMetadataForModels(modelMetadata, uniqueModels),
models: uniqueModels
};
}
function codexModelMetadataFromItem(item: Record<string, unknown>): ProviderModelMetadata | undefined {
const additionalSpeedTiers = readArray(item.additional_speed_tiers) ?? readArray(item.additionalSpeedTiers) ?? readArray(item.speed_tiers) ?? readArray(item.speedTiers);
const serviceTiers = readArray(item.service_tiers) ?? readArray(item.serviceTiers);
const supportedReasoningLevels =
readReasoningLevels(item.supported_reasoning_levels) ??
readReasoningLevels(item.supportedReasoningLevels) ??
readReasoningEfforts(item.supported_reasoning_efforts) ??
readReasoningEfforts(item.supportedReasoningEfforts) ??
readReasoningEfforts(item.reasoning_efforts) ??
readReasoningEfforts(item.reasoningEfforts);
const defaultReasoningLevel = readNullableString(item.default_reasoning_level) ?? readNullableString(item.defaultReasoningLevel);
const defaultReasoningSummary = readString(item.default_reasoning_summary) || readString(item.defaultReasoningSummary);
const supportsReasoningSummaries = readBoolean(item.supports_reasoning_summaries) ?? readBoolean(item.supportsReasoningSummaries);
const metadata: ProviderModelMetadata = {
...(additionalSpeedTiers ? { additionalSpeedTiers } : {}),
...(defaultReasoningLevel !== undefined ? { defaultReasoningLevel } : {}),
...(defaultReasoningSummary ? { defaultReasoningSummary } : {}),
...(serviceTiers ? { serviceTiers } : {}),
...(supportedReasoningLevels ? { supportedReasoningLevels } : {}),
...(supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries } : {})
};
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
function readArray(value: unknown): unknown[] | undefined {
return Array.isArray(value) ? value : undefined;
}
function readNullableString(value: unknown): string | null | undefined {
if (value === null) {
return null;
}
const text = readString(value);
return text || undefined;
}
function readReasoningLevels(value: unknown): ProviderReasoningLevel[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const levels = value
.map((item): ProviderReasoningLevel | undefined => {
if (!isRecord(item)) {
const effort = readString(item);
return effort ? { description: effortDescription(effort), effort } : undefined;
}
const effort = readString(item.effort) || readString(item.name) || readString(item.id) || readString(item.value);
if (!effort) {
return undefined;
}
return {
description: readString(item.description) || readString(item.label) || effortDescription(effort),
effort
};
})
.filter((item): item is ProviderReasoningLevel => Boolean(item));
return levels.length > 0 ? levels : undefined;
}
function readReasoningEfforts(value: unknown): ProviderReasoningLevel[] | undefined {
if (Array.isArray(value)) {
return readReasoningLevels(value);
}
if (!isRecord(value)) {
return undefined;
}
return readReasoningLevels(Object.values(value));
}
function effortDescription(effort: string): string {
const normalized = effort.trim().toLowerCase();
if (normalized === "xhigh") {
return "Extra high reasoning";
}
return `${effort.slice(0, 1).toUpperCase()}${effort.slice(1)} reasoning`;
}
function codexModelCatalogItems(payload: unknown): unknown[] {
if (Array.isArray(payload)) {
return payload;
}
if (!isRecord(payload)) {
return [];
}
const items: unknown[] = [];
for (const candidate of [payload.data, payload.models]) {
if (Array.isArray(candidate)) {
items.push(...candidate);
}
}
return items;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
export function codexModelCatalogFromPayloadForTest(payload: unknown): LocalAgentModelCatalog {
return codexModelCatalogFromPayload(payload);
}
function codexHomeDir(): string {
return process.env.CCR_INTERNAL_HOME_DIR?.trim() || process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
}
function readCodexIdTokenClaims(idToken: string | undefined): { accountId?: string; isFedrampAccount?: boolean } {
const payload = readJwtPayload(idToken);
const auth = isRecord(payload?.["https://api.openai.com/auth"])

View file

@ -1,12 +1,10 @@
import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportRequest,
LocalAgentProviderImportResult,
LocalAgentProviderProbeRequest,
LocalAgentProviderProbeResult
LocalAgentProviderImportResult
} from "@ccr/core/contracts/app";
import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code";
import { codexCandidate, importCodexProvider, probeCodexProvider } from "@ccr/core/agents/local-providers/codex";
import { codexCandidate, importCodexProvider } from "@ccr/core/agents/local-providers/codex";
import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode";
export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex";
@ -20,7 +18,7 @@ export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[]
].filter((candidate) => candidate.status !== "missing");
}
export async function importLocalAgentProvider(request: LocalAgentProviderImportRequest): Promise<LocalAgentProviderImportResult> {
export function importLocalAgentProvider(request: LocalAgentProviderImportRequest): LocalAgentProviderImportResult {
const candidate = getLocalAgentProviderCandidates().find((item) => item.id === request.id);
if (!candidate) {
throw new Error("Local agent provider was not found.");
@ -37,14 +35,3 @@ export async function importLocalAgentProvider(request: LocalAgentProviderImport
}
return importZcodeProvider(candidate, request.providerNames ?? []);
}
export async function probeLocalAgentProvider(request: LocalAgentProviderProbeRequest): Promise<LocalAgentProviderProbeResult> {
const candidate = getLocalAgentProviderCandidates().find((item) => item.id === request.id);
if (!candidate) {
throw new Error("Local agent provider was not found.");
}
if (candidate.kind === "codex") {
return probeCodexProvider(candidate);
}
throw new Error("Local agent provider model probing is not supported.");
}

View file

@ -4,8 +4,7 @@ import type {
LocalAgentProviderCandidate,
LocalAgentProviderKind,
ProviderAccountConfig,
ProviderDeepLinkPayload,
ProviderModelMetadata
ProviderDeepLinkPayload
} from "@ccr/core/contracts/app";
export type OAuthTokenSet = {
@ -40,8 +39,8 @@ export function missingCandidate(
importable: false,
kind,
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, models),
models,
name,
models,
name,
protocol,
status: "missing"
};
@ -59,24 +58,12 @@ export function providerPayload(
apiKey: localAgentProviderApiKey,
baseUrl,
modelDisplayNames: modelDisplayNamesForModels(candidate.modelDisplayNames, models),
modelMetadata: modelMetadataForModels(candidate.modelMetadata, models),
models,
name,
protocol: candidate.protocol
};
}
export function modelMetadataForModels(
value: Record<string, ProviderModelMetadata> | undefined,
models: string[]
): Record<string, ProviderModelMetadata> | undefined {
const modelIds = new Set(models);
const entries = Object.entries(value ?? {})
.map(([rawModel, metadata]) => [rawModel.trim(), metadata] as const)
.filter(([model, metadata]) => model && modelIds.has(model) && metadata && typeof metadata === "object");
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function modelDisplayNamesForModels(
value: Record<string, string> | undefined,
models: string[]

View file

@ -33,8 +33,6 @@ import type {
ProviderAccountConfig,
ProviderAccountConnectorConfig,
ProviderCredentialConfig,
ProviderModelMetadata,
ProviderReasoningLevel,
ProfileConfig,
ProfileRuntimeConfig,
ProxyRouteTarget,
@ -1037,7 +1035,6 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
: [];
const modelDescriptions = parseModelDescriptions(item.modelDescriptions ?? item.model_descriptions, models);
const modelDisplayNames = parseModelDisplayNames(item.modelDisplayNames ?? item.model_display_names, models);
const modelMetadata = parseModelMetadata(item.modelMetadata ?? item.model_metadata, models);
if (!name) {
return undefined;
@ -1060,7 +1057,6 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
id: readString(item.id),
modelDescriptions,
modelDisplayNames,
modelMetadata,
models,
name,
provider: readString(item.provider),
@ -1106,68 +1102,6 @@ function parseModelDisplayNames(value: unknown, models: string[]): Record<string
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function parseModelMetadata(value: unknown, models: string[]): Record<string, ProviderModelMetadata> | undefined {
if (!isObject(value)) {
return undefined;
}
const modelIds = new Set(models);
const entries = Object.entries(value)
.map(([rawModel, rawMetadata]) => [rawModel.trim(), parseProviderModelMetadata(rawMetadata)] as const)
.filter((entry): entry is [string, ProviderModelMetadata] => {
const [model, metadata] = entry;
return Boolean(model && metadata && modelIds.has(model));
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function parseProviderModelMetadata(value: unknown): ProviderModelMetadata | undefined {
if (!isObject(value)) {
return undefined;
}
const supportedReasoningLevels = parseProviderReasoningLevels(value.supportedReasoningLevels ?? value.supported_reasoning_levels);
const metadata: ProviderModelMetadata = {
...(Array.isArray(value.additionalSpeedTiers) ? { additionalSpeedTiers: value.additionalSpeedTiers } : {}),
...(Array.isArray(value.additional_speed_tiers) ? { additionalSpeedTiers: value.additional_speed_tiers } : {}),
...(value.defaultReasoningLevel === null ? { defaultReasoningLevel: null } : {}),
...(readString(value.defaultReasoningLevel) ? { defaultReasoningLevel: readString(value.defaultReasoningLevel) } : {}),
...(value.default_reasoning_level === null ? { defaultReasoningLevel: null } : {}),
...(readString(value.default_reasoning_level) ? { defaultReasoningLevel: readString(value.default_reasoning_level) } : {}),
...(readString(value.defaultReasoningSummary) ? { defaultReasoningSummary: readString(value.defaultReasoningSummary) } : {}),
...(readString(value.default_reasoning_summary) ? { defaultReasoningSummary: readString(value.default_reasoning_summary) } : {}),
...(Array.isArray(value.serviceTiers) ? { serviceTiers: value.serviceTiers } : {}),
...(Array.isArray(value.service_tiers) ? { serviceTiers: value.service_tiers } : {}),
...(supportedReasoningLevels ? { supportedReasoningLevels } : {}),
...(typeof value.supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries: value.supportsReasoningSummaries } : {}),
...(typeof value.supports_reasoning_summaries === "boolean" ? { supportsReasoningSummaries: value.supports_reasoning_summaries } : {})
};
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
function parseProviderReasoningLevels(value: unknown): ProviderReasoningLevel[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const levels = value
.map((item): ProviderReasoningLevel | undefined => {
if (!isObject(item)) {
const effort = readString(item);
return effort ? { description: effort, effort } : undefined;
}
const effort = readString(item.effort);
if (!effort) {
return undefined;
}
return {
description: readString(item.description) || effort,
effort
};
})
.filter((item): item is ProviderReasoningLevel => Boolean(item));
return levels.length > 0 ? levels : undefined;
}
function withProviderIds(providers: GatewayProviderConfig[]): GatewayProviderConfig[] {
const counts = new Map<string, number>();
return providers.map((provider) => {
@ -2263,10 +2197,8 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
const botGateway = surface !== "cli" && parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined;
if (agent === "claude-code") {
const appPath = readProfileAppPath(item, agent);
return {
agent,
...(appPath ? { appPath } : {}),
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
enabled,
@ -2281,10 +2213,8 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
};
}
const appPath = readProfileAppPath(item, agent);
return {
agent,
...(appPath ? { appPath } : {}),
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
cliMiddleware: true,
@ -2314,18 +2244,6 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
.filter((item): item is ProfileConfig => Boolean(item));
}
function readProfileAppPath(item: Record<string, unknown>, agent: ProfileConfig["agent"]): string | undefined {
return readString(item.appPath) ||
readString(item.app_path) ||
readString(item.appExecutablePath) ||
readString(item.app_executable_path) ||
(agent === "claude-code"
? readString(item.claudeAppPath) || readString(item.claude_app_path)
: agent === "codex"
? readString(item.chatgptAppPath) || readString(item.chatgpt_app_path) || readString(item.codexAppPath) || readString(item.codex_app_path)
: readString(item.zcodeAppPath) || readString(item.zcode_app_path));
}
function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined {
if (typeof value !== "string") {
return undefined;

View file

@ -133,7 +133,6 @@ export type GatewayProviderConfig = {
id?: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
models: string[];
name: string;
provider?: string;
@ -141,20 +140,6 @@ export type GatewayProviderConfig = {
type?: GatewayProviderProtocol | string;
};
export type ProviderReasoningLevel = {
description: string;
effort: string;
};
export type ProviderModelMetadata = {
additionalSpeedTiers?: unknown[];
defaultReasoningLevel?: string | null;
defaultReasoningSummary?: string;
serviceTiers?: unknown[];
supportedReasoningLevels?: ProviderReasoningLevel[];
supportsReasoningSummaries?: boolean;
};
export type ProviderCredentialConfig = {
account?: ProviderAccountConfig;
api_key?: string;
@ -308,7 +293,6 @@ export type ProviderDeepLinkPayload = {
icon?: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
models: string[];
name?: string;
protocol?: GatewayProviderProtocol;
@ -339,7 +323,6 @@ export type LocalAgentProviderCandidate = {
importable: boolean;
kind: LocalAgentProviderKind;
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
models: string[];
name: string;
protocol: GatewayProviderProtocol;
@ -358,16 +341,6 @@ export type LocalAgentProviderImportResult = {
providerPlugins: unknown[];
};
export type LocalAgentProviderProbeRequest = {
forceRefresh?: boolean;
id: string;
};
export type LocalAgentProviderProbeResult = {
candidate: LocalAgentProviderCandidate;
probe: GatewayProviderProbeResult;
};
export type ProviderCatalogModelsRequest = {
baseUrl?: string;
name?: string;
@ -379,7 +352,6 @@ export type ProviderCatalogModelsResult = {
loadedFrom?: string;
matchedBy?: "base-url" | "provider-id" | "provider-name";
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
models: string[];
provider?: string;
providerName?: string;
@ -448,7 +420,6 @@ export type GatewayProviderProbeRequest = {
export type GatewayProviderProbeCandidate = {
baseUrl: string;
declaredProtocols?: GatewayProviderProtocol[];
label?: string;
protocols: GatewayProviderProtocol[];
source: "custom" | "preset";
@ -491,7 +462,6 @@ export type GatewayProviderProbeResult = {
detectedProvider?: GatewayProviderDetectedProvider;
detectedProtocol?: GatewayProviderProtocol;
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
modelSource?: "anthropic" | "gemini" | "openai";
models: string[];
normalizedBaseUrl: string;
@ -1137,7 +1107,6 @@ export type CodexProfileConfig = {
export type ProfileConfig = {
agent: ProfileClientKind;
appPath?: string;
botConfigId?: string;
botGateway?: BotGatewayRuntimeConfig;
configFile?: string;

View file

@ -273,8 +273,8 @@ const zhPatternErrorMessages: PatternTranslator[] = [
translate: (endpoint, status) => `CDP ${endpoint} 返回 HTTP ${status}`
},
{
pattern: /^Timed out waiting for (?:ChatGPT|Codex App) response: (.+)$/,
translate: (requestId) => `等待 ChatGPT 响应超时:${requestId}`
pattern: /^Timed out waiting for Codex App response: (.+)$/,
translate: (requestId) => `等待 Codex App 响应超时:${requestId}`
},
{
pattern: /^No active turn for thread (.+)$/,

View file

@ -43,7 +43,6 @@ export const IPC_CHANNELS = {
appBotHandoffBluetoothTargetsScan: "ccr:app:bot-handoff-bluetooth-targets-scan",
appBotHandoffWifiTargetsScan: "ccr:app:bot-handoff-wifi-targets-scan",
appCheckProviderConnectivity: "ccr:app:check-provider-connectivity",
appProbeLocalAgentProvider: "ccr:app:probe-local-agent-provider",
appProbeProvider: "ccr:app:probe-provider",
appProbeProviderCandidates: "ccr:app:probe-provider-candidates",
appProviderDeepLink: "ccr:app:provider-deep-link",

View file

@ -346,12 +346,11 @@ class GatewayService {
async start(config: AppConfig): Promise<GatewayStatus> {
const coreHostError = loopbackCoreHostError(config.gateway.coreHost);
if (coreHostError) {
this.status = {
return {
...this.getStatus(),
lastError: coreHostError,
state: "error"
};
return this.status;
}
await this.stop();
this.config = config;
@ -707,28 +706,28 @@ class GatewayService {
let responseCompleted = false;
let onClientDisconnect: (() => void) | undefined;
let onResponseFinish: (() => void) | undefined;
const handleClientDisconnect = () => {
if (responseCompleted || response.writableEnded) {
return;
}
if (!clientDisconnected) {
clientDisconnected = true;
upstreamAbortController.abort(new Error(clientDisconnectMessage));
}
onClientDisconnect?.();
};
response.once("finish", () => {
responseCompleted = true;
onResponseFinish?.();
});
response.once("close", handleClientDisconnect);
response.on("error", () => {
response.once("close", () => {
if (responseCompleted || response.writableEnded) {
return;
}
clientDisconnected = true;
upstreamAbortController.abort(new Error(clientDisconnectMessage));
onClientDisconnect?.();
});
response.on("error", (error) => {
// Client-side write failures (EPIPE / ECONNRESET when the client closes
// mid-stream, common during tool execution) must not crash the main
// process as an Uncaught Exception. Swallow them here; the close handler
// above already records the disconnect via writeStreamLog.
handleClientDisconnect();
if (!clientDisconnected) {
clientDisconnected = true;
upstreamAbortController.abort(new Error(clientDisconnectMessage));
}
});
const writeRequestLog = (
@ -977,11 +976,6 @@ class GatewayService {
this.config
);
const upstreamResponse = upstreamResult.response;
if (clientDisconnected || upstreamAbortController.signal.aborted) {
await cancelResponseBody(upstreamResponse);
writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage);
return;
}
if (codexApplyPatchBridgeActive) {
responseHeaders.delete("content-length");
}
@ -995,11 +989,6 @@ class GatewayService {
responseHeaders.delete("content-length");
}
recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders);
if (clientDisconnected || response.destroyed) {
await cancelResponseBody(upstreamResponse);
writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage);
return;
}
response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders)));
if (!upstreamResponse.body) {
if (shouldCaptureUsage) {
@ -1034,7 +1023,6 @@ class GatewayService {
this.browserWebSearchMcpIntegration
)
: patchedResponseBody;
const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, responseBody]);
const sampler = createBodySampler();
const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined);
let streamDetectedError: string | undefined;
@ -1046,7 +1034,7 @@ class GatewayService {
}
logRecorded = true;
writeRequestLog(
clientDisconnected ? clientClosedRequestStatusCode : upstreamResponse.status,
upstreamResponse.status,
responseHeaders,
sampler.read(),
sampler.isTruncated(),
@ -1055,21 +1043,13 @@ class GatewayService {
};
onClientDisconnect = () => {
writeStreamLog(clientDisconnectMessage);
responseBody.unpipe(response);
destroyResponseStreams(responseStreams);
responseBody.destroy(new Error(clientDisconnectMessage));
};
onResponseFinish = () => {
if (upstreamStreamEnded) {
writeStreamLog();
}
};
const onResponseStreamError = (error: Error) => {
streamDetectedError ??= sseErrorDetector.finish();
writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error));
};
for (const stream of responseStreams) {
stream.on("error", onResponseStreamError);
}
responseBody.on("data", (chunk) => {
sampler.append(chunk);
streamDetectedError ??= sseErrorDetector.append(chunk);
@ -1081,6 +1061,10 @@ class GatewayService {
writeStreamLog();
}
});
responseBody.on("error", (error) => {
streamDetectedError ??= sseErrorDetector.finish();
writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error));
});
if (shouldCaptureUsage) {
responseBody.once("end", () => {
void recordGatewayUsageCapture({
@ -1098,10 +1082,6 @@ class GatewayService {
});
});
}
if (clientDisconnected || response.destroyed) {
onClientDisconnect();
return;
}
responseBody.pipe(response);
}
@ -1177,8 +1157,8 @@ async function writeCoreGatewayConfig(
mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true });
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
const providerPlugins = withCodexOauthRuntimeDefaults([
...(config.providerPlugins ?? []).filter(providerPluginEnabled),
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
...(config.providerPlugins ?? []),
...pluginService.getCoreProviderPlugins()
]);
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
@ -1264,10 +1244,6 @@ function writePrivateTextFile(file: string, content: string): void {
}
}
function providerPluginEnabled(plugin: unknown): boolean {
return !isRecord(plugin) || plugin.enabled !== false;
}
export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] {
return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config));
}
@ -5701,7 +5677,7 @@ async function fetchUpstreamWithFallback(input: {
});
if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) {
const delayMs = retryDelayAfterStatus(response.status, response.headers, failedAttempts.length);
const delayMs = retryDelayAfterStatus(response.status, response, failedAttempts.length);
failedAttempts.push({
credentialChain: attempt.credentialChain,
credentialIds: attempt.credentialIds,
@ -5724,13 +5700,10 @@ async function fetchUpstreamWithFallback(input: {
};
} catch (error) {
const message = formatError(error);
const delayMs = hasNextAttempt && !input.signal?.aborted
? retryDelayAfterNetworkError(failedAttempts.length)
: 0;
failedAttempts.push({
credentialChain: attempt.credentialChain,
credentialIds: attempt.credentialIds,
delayMs,
delayMs: 0,
error: message,
model: attempt.model
});
@ -5742,9 +5715,6 @@ async function fetchUpstreamWithFallback(input: {
});
}
if (hasNextAttempt) {
if (delayMs > 0) {
await delay(delayMs);
}
continue;
}
throw new UpstreamRequestError(message, {
@ -6196,30 +6166,17 @@ function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode)
return false;
}
function retryDelayAfterStatus(_statusCode: number, headers: Headers, failedAttemptIndex: number): number {
const retryAfterMs = parseRetryAfterHeaderMs(headers.get("retry-after"));
if (retryAfterMs !== undefined && retryAfterMs > 0) {
return clampNumber(retryAfterMs, 1, upstreamRetryAfterMaxMs);
function retryDelayAfterStatus(statusCode: number, response: Response, failedAttemptIndex: number): number {
if (statusCode !== 429) {
return 0;
}
const retryAfterMs = parseRetryAfterHeaderMs(response.headers.get("retry-after"));
if (retryAfterMs !== undefined) {
return clampNumber(retryAfterMs, 0, upstreamRetryAfterMaxMs);
}
return exponentialRetryBackoffMs(failedAttemptIndex);
}
function retryDelayAfterNetworkError(failedAttemptIndex: number): number {
return exponentialRetryBackoffMs(failedAttemptIndex);
}
export function fallbackRetryDelayAfterStatusForTest(input: { failedAttemptIndex?: number; retryAfter?: string | null; statusCode: number }): number {
const headers = new Headers();
if (input.retryAfter !== undefined && input.retryAfter !== null) {
headers.set("retry-after", input.retryAfter);
}
return retryDelayAfterStatus(input.statusCode, headers, input.failedAttemptIndex ?? 0);
}
export function fallbackRetryDelayAfterNetworkErrorForTest(failedAttemptIndex = 0): number {
return retryDelayAfterNetworkError(failedAttemptIndex);
}
function parseRetryAfterHeaderMs(value: string | null): number | undefined {
const trimmed = value?.trim();
if (!trimmed) {
@ -6248,29 +6205,6 @@ async function drainResponseBody(response: Response): Promise<void> {
}
}
async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// The client already disconnected; best-effort upstream cleanup must not mask that expected path.
}
}
function uniqueStreams(streams: Readable[]): Readable[] {
return [...new Set(streams)];
}
function destroyResponseStreams(streams: Readable[]): void {
for (const stream of streams) {
if (!stream.destroyed) {
// A downstream client close is an expected abort path. Destroying with
// an Error would emit another error event on Readable/Transform stages,
// and intermediate stages may not be the final responseBody listener.
stream.destroy();
}
}
}
function parseJsonObjectSafe(buffer: Buffer | undefined): Record<string, unknown> | undefined {
if (!buffer || buffer.byteLength === 0) {
return undefined;

View file

@ -144,18 +144,6 @@ type LoadedPlugin = {
stop?: () => MaybePromise<void>;
};
type PluginServiceStateSnapshot = {
apps: InstalledBrowserApp[];
coreGatewayConfig: Record<string, unknown>;
coreProviderPlugins: unknown[];
gatewayRoutes: RegisteredGatewayRoute[];
providerAccountConnectors: Map<string, GatewayPluginProviderAccountConnector>;
proxyRoutes: RegisteredProxyRoute[];
resourceOwnerIds: Set<string>;
stopHooks: Array<() => MaybePromise<void>>;
virtualModelProfiles: unknown[];
};
const requireFromHere = createRequire(__filename);
const builtInMarketplacePluginModules = new Map<string, string>([
["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")],
@ -184,14 +172,8 @@ class GatewayPluginService {
if (pluginConfig.enabled === false) {
continue;
}
const snapshot = this.createStateSnapshot();
this.resourceOwnerIds.add(pluginConfig.id);
try {
await this.loadConfiguredPlugin(pluginConfig);
} catch (error) {
await this.rollbackConfiguredPluginLoad(pluginConfig.id, snapshot);
console.warn(`[plugin:${pluginConfig.id}] Disabled after startup failure: ${formatError(error)}`);
}
await this.loadConfiguredPlugin(pluginConfig);
}
}
@ -531,47 +513,6 @@ class GatewayPluginService {
): Promise<PluginSqliteStore> {
return backendService.openSqliteStore(pluginId, pluginDataDir, options);
}
private createStateSnapshot(): PluginServiceStateSnapshot {
return {
apps: [...this.apps],
coreGatewayConfig: { ...this.coreGatewayConfig },
coreProviderPlugins: [...this.coreProviderPlugins],
gatewayRoutes: [...this.gatewayRoutes],
providerAccountConnectors: new Map(this.providerAccountConnectors),
proxyRoutes: [...this.proxyRoutes],
resourceOwnerIds: new Set(this.resourceOwnerIds),
stopHooks: [...this.stopHooks],
virtualModelProfiles: [...this.virtualModelProfiles]
};
}
private async rollbackConfiguredPluginLoad(pluginId: string, snapshot: PluginServiceStateSnapshot): Promise<void> {
const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse();
this.apps = snapshot.apps;
this.coreGatewayConfig = snapshot.coreGatewayConfig;
this.coreProviderPlugins = snapshot.coreProviderPlugins;
this.gatewayRoutes = snapshot.gatewayRoutes;
this.providerAccountConnectors = snapshot.providerAccountConnectors;
this.proxyRoutes = snapshot.proxyRoutes;
this.resourceOwnerIds = snapshot.resourceOwnerIds;
this.stopHooks = snapshot.stopHooks;
this.virtualModelProfiles = snapshot.virtualModelProfiles;
for (const stopHook of newStopHooks) {
try {
await stopHook();
} catch (error) {
console.warn(`[plugin:${pluginId}] Rollback stop hook failed: ${formatError(error)}`);
}
}
try {
await backendService.stopOwner(pluginId);
} catch (error) {
console.warn(`[plugin:${pluginId}] Rollback resource cleanup failed: ${formatError(error)}`);
}
}
}
export const pluginService = new GatewayPluginService();

View file

@ -7,7 +7,7 @@ import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "@ccr/core/agents/claude-app/gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch";
import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment";
import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch";
import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch";
import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime";
import { CONFIGDIR } from "@ccr/core/config/constants";
import { gatewayService } from "@ccr/core/gateway/service";
@ -108,7 +108,7 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen
}
async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): Promise<ProfileOpenResult> {
const appName = profile.agent === "zcode" ? "ZCode App" : codexDesktopAppName;
const appName = profile.agent === "zcode" ? "ZCode App" : "Codex App";
const profileGatewayConfig = await ensureProfileGateway(config, profile, appName);
const existing = runningProfileApp(profile.id, "app");
if (existing) {

View file

@ -11,5 +11,5 @@ export const claudeApiProviderPreset: ProviderPreset = {
],
id: "claudeapi",
name: "claudeapi",
websiteUrl: "https://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default"
websiteUrl: "https://www.claudeapi.com?source=claudecoderouter"
};

View file

@ -11,5 +11,5 @@ export const code0ProviderPreset: ProviderPreset = {
],
id: "code0",
name: "code0.ai",
websiteUrl: "https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default"
websiteUrl: "https://code0.ai?source=claudecoderouter"
};

View file

@ -1,15 +0,0 @@
import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types";
export const fennoProviderPreset: ProviderPreset = {
account: defaultProviderAccountConfig,
aliases: ["fenno", "fenno.ai", "fenno ai"],
endpoints: [
{
baseUrl: "https://api.fenno.ai",
protocols: ["openai_chat_completions", "openai_responses", "anthropic_messages"]
}
],
id: "fenno",
name: "Fenno.ai",
websiteUrl: "https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=9HHHAB5QLAES"
};

View file

@ -3,15 +3,12 @@ import { bailianProviderPreset } from "@ccr/core/providers/presets/bailian/index
import { claudeApiProviderPreset } from "@ccr/core/providers/presets/claudeapi/index";
import { code0ProviderPreset } from "@ccr/core/providers/presets/code0/index";
import { deepSeekProviderPreset } from "@ccr/core/providers/presets/deepseek/index";
import { fennoProviderPreset } from "@ccr/core/providers/presets/fenno/index";
import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index";
import { kimiCodingProviderPreset } from "@ccr/core/providers/presets/kimi-coding/index";
import { minimaxChinaProviderPreset, minimaxGlobalProviderPreset } from "@ccr/core/providers/presets/minimax/index";
import { mistralProviderPreset } from "@ccr/core/providers/presets/mistral/index";
import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "@ccr/core/providers/presets/moonshot/index";
import { openaiProviderPreset } from "@ccr/core/providers/presets/openai/index";
import { openRouterProviderPreset } from "@ccr/core/providers/presets/openrouter/index";
import { qiniuAiProviderPreset } from "@ccr/core/providers/presets/qiniu-ai/index";
import { runApiProviderPreset } from "@ccr/core/providers/presets/runapi/index";
import { siliconFlowProviderPreset } from "@ccr/core/providers/presets/siliconflow/index";
import { teamoRouterProviderPreset } from "@ccr/core/providers/presets/teamorouter/index";
@ -41,15 +38,11 @@ export const providerPresets: ProviderPreset[] = [
zhipuCnGeneralProviderPreset,
zaiGlobalCodingProviderPreset,
zaiGlobalGeneralProviderPreset,
minimaxGlobalProviderPreset,
minimaxChinaProviderPreset,
mistralProviderPreset,
moonshotChinaProviderPreset,
moonshotGlobalProviderPreset,
bailianProviderPreset,
siliconFlowProviderPreset,
qiniuAiProviderPreset,
fennoProviderPreset,
runApiProviderPreset,
teamoRouterProviderPreset,
code0ProviderPreset,

View file

@ -1,44 +0,0 @@
import type { ProviderPreset } from "@ccr/core/providers/presets/types";
import { standardProviderAccountConfig } from "@ccr/core/providers/presets/types";
export const minimaxGlobalProviderPreset: ProviderPreset = {
account: standardProviderAccountConfig,
aliases: ["minimax", "minimax global"],
defaultModels: ["MiniMax-M3"],
endpoints: [
{
baseUrl: "https://api.minimax.io/v1",
protocols: ["openai_chat_completions"],
websiteUrl: "https://platform.minimax.io/docs"
},
{
baseUrl: "https://api.minimax.io/anthropic/v1",
protocols: ["anthropic_messages"],
websiteUrl: "https://platform.minimax.io/docs"
}
],
id: "minimax-global",
name: "MiniMax (Global)",
websiteUrl: "https://platform.minimax.io/docs"
};
export const minimaxChinaProviderPreset: ProviderPreset = {
account: standardProviderAccountConfig,
aliases: ["minimax", "minimaxi", "minimax china"],
defaultModels: ["MiniMax-M3"],
endpoints: [
{
baseUrl: "https://api.minimaxi.com/v1",
protocols: ["openai_chat_completions"],
websiteUrl: "https://platform.minimaxi.com/docs"
},
{
baseUrl: "https://api.minimaxi.com/anthropic/v1",
protocols: ["anthropic_messages"],
websiteUrl: "https://platform.minimaxi.com/docs"
}
],
id: "minimax-cn",
name: "MiniMax (China)",
websiteUrl: "https://platform.minimaxi.com/docs"
};

View file

@ -1,35 +0,0 @@
import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types";
export const qiniuAiProviderPreset: ProviderPreset = {
account: defaultProviderAccountConfig,
aliases: ["qiniu", "qiniu ai", "qiniu cloud ai", "qiniu yun ai", "qiniu yun", "七牛云", "七牛云ai", "七牛云 ai", "modelink"],
endpoints: [
{
baseUrl: "https://api.qnaigc.com",
label: "China mainland OpenAI",
protocols: ["openai_chat_completions"],
websiteUrl: "https://s.qiniu.com/AVjMVf"
},
{
baseUrl: "https://api.qnaigc.com/bypass/openai/v1",
label: "China mainland OpenAI Responses",
protocols: ["openai_responses"],
websiteUrl: "https://s.qiniu.com/AVjMVf"
},
{
baseUrl: "https://api.qnaigc.com",
label: "China mainland Anthropic",
protocols: ["anthropic_messages"],
websiteUrl: "https://s.qiniu.com/AVjMVf"
},
{
baseUrl: "https://api.qnaigc.com/bypass/vertex/v1",
label: "China mainland Gemini Generate",
protocols: ["gemini_generate_content"],
websiteUrl: "https://s.qiniu.com/AVjMVf"
}
],
id: "qiniu-ai",
name: "七牛云 AI",
websiteUrl: "https://s.qiniu.com/AVjMVf"
};

View file

@ -37,19 +37,35 @@ export function primaryProviderPresetEndpoint(preset: ProviderPreset): ProviderP
}
export function providerIdentitySafetyIssueInList(
_presets: ProviderPreset[],
presets: ProviderPreset[],
input: {
baseUrl: string;
name?: string;
presetId?: string;
}
): ProviderIdentitySafetyIssue | undefined {
void input;
if (isLoopbackProviderBaseUrl(input.baseUrl)) {
return undefined;
}
const selectedPreset = findProviderPresetInList(presets, input.presetId);
if (selectedPreset && !providerPresetMatchesBaseUrl(selectedPreset, input.baseUrl)) {
return createProviderIdentitySafetyIssue(selectedPreset);
}
const namedPresets = findProviderPresetsByIdentity(presets, input.name);
if (
namedPresets.length > 0 &&
!namedPresets.some((preset) => providerPresetMatchesBaseUrl(preset, input.baseUrl))
) {
return createProviderIdentitySafetyIssue(namedPresets[0]);
}
return undefined;
}
export function providerApiKeySafetyIssueInList(
_presets: ProviderPreset[],
presets: ProviderPreset[],
input: {
apiKey?: string;
baseUrl: string;
@ -57,8 +73,15 @@ export function providerApiKeySafetyIssueInList(
presetId?: string;
}
): ProviderIdentitySafetyIssue | undefined {
void input;
return undefined;
const apiKey = input.apiKey?.trim();
if (apiKey) {
const officialKeyPreset = findProviderPresetByOfficialKey(presets, apiKey);
if (officialKeyPreset && !providerBaseUrlCanReceiveOfficialKey(officialKeyPreset, input.baseUrl)) {
return createProviderApiKeySafetyIssue(officialKeyPreset);
}
}
return providerIdentitySafetyIssueInList(presets, input);
}
export function providerPresetMatchesBaseUrl(preset: ProviderPreset, baseUrl: string): boolean {
@ -66,7 +89,7 @@ export function providerPresetMatchesBaseUrl(preset: ProviderPreset, baseUrl: st
}
export function providerEndpointCanReceiveProviderApiKeyInList(
_presets: ProviderPreset[],
presets: ProviderPreset[],
input: {
apiKey?: string;
endpoint: string;
@ -74,7 +97,28 @@ export function providerEndpointCanReceiveProviderApiKeyInList(
providerPresetId?: string;
}
): ProviderIdentitySafetyIssue | undefined {
void input;
const apiKey = input.apiKey?.trim();
if (!apiKey) {
return undefined;
}
const officialKeyPreset = findProviderPresetByOfficialKey(presets, apiKey);
if (officialKeyPreset && !providerBaseUrlCanReceiveOfficialKey(officialKeyPreset, input.endpoint)) {
return createProviderApiKeySafetyIssue(officialKeyPreset);
}
const selectedPreset = findProviderPresetInList(presets, input.providerPresetId);
if (selectedPreset && !providerBaseUrlCanReceiveOfficialKey(selectedPreset, input.endpoint)) {
return createProviderApiKeySafetyIssue(selectedPreset);
}
const namedPresets = findProviderPresetsByIdentity(presets, input.providerName);
if (
namedPresets.length > 0 &&
!namedPresets.some((preset) => providerBaseUrlCanReceiveOfficialKey(preset, input.endpoint))
) {
return createProviderApiKeySafetyIssue(namedPresets[0]);
}
return undefined;
}
@ -84,30 +128,52 @@ function findProviderPresetsByIdentity(presets: ProviderPreset[], name: string |
return [];
}
return presets
.map((preset) => ({
preset,
score: providerPresetIdentityMatchScore(preset, normalizedName)
}))
.filter((item) => item.score > 0)
.sort((left, right) => right.score - left.score)
.map((item) => item.preset);
return presets.filter((preset) => {
const identities = [preset.id, preset.name, ...preset.aliases]
.map(normalizeProviderIdentityText)
.filter(Boolean);
return identities.some((identity) =>
normalizedName === identity ||
(identity.length >= 4 && normalizedName.includes(identity))
);
});
}
function providerPresetIdentityMatchScore(preset: ProviderPreset, normalizedName: string): number {
const identities = [preset.id, preset.name, ...preset.aliases]
.map(normalizeProviderIdentityText)
.filter(Boolean);
function createProviderIdentitySafetyIssue(preset: ProviderPreset): ProviderIdentitySafetyIssue {
const hosts = uniqueStrings(preset.endpoints
.map((endpoint) => providerEndpointHost(endpoint.baseUrl))
.filter((host): host is string => Boolean(host)));
return {
message: `Provider identity looks like ${preset.name}, but the Base URL is not an official ${preset.name} endpoint (${hosts.join(", ")}). Use a neutral custom name for third-party gateways and never enter official provider keys into untrusted endpoints.`,
preset
};
}
return Math.max(0, ...identities.map((identity) => {
if (normalizedName === identity) {
return 10_000 + identity.length;
}
if (identity.length >= 4 && normalizedName.includes(identity)) {
return identity.length;
}
return 0;
}));
function createProviderApiKeySafetyIssue(preset: ProviderPreset): ProviderIdentitySafetyIssue {
const hosts = uniqueStrings(preset.endpoints
.map((endpoint) => providerEndpointHost(endpoint.baseUrl))
.filter((host): host is string => Boolean(host)));
return {
message: `The API key looks like an official ${preset.name} key, but the target endpoint is not an official ${preset.name} endpoint (${hosts.join(", ")}) or a local loopback endpoint. Official provider keys must not be sent to third-party gateways.`,
preset
};
}
function findProviderPresetByOfficialKey(presets: ProviderPreset[], apiKey: string): ProviderPreset | undefined {
const trimmedApiKey = apiKey.trim();
return presets.find((preset) =>
(preset.officialApiKeyPatterns ?? []).some((pattern) => {
try {
return new RegExp(pattern.source, pattern.flags).test(trimmedApiKey);
} catch {
return false;
}
})
);
}
function providerBaseUrlCanReceiveOfficialKey(preset: ProviderPreset, baseUrl: string): boolean {
return providerPresetMatchesHost(preset, baseUrl) || isLoopbackProviderBaseUrl(baseUrl);
}
function providerEndpointMatchesBaseUrl(endpointBaseUrl: string, baseUrl: string): boolean {
@ -129,6 +195,21 @@ function providerEndpointMatchesBaseUrl(endpointBaseUrl: string, baseUrl: string
endpointPath.startsWith(`${candidatePath}/`);
}
function providerEndpointHost(baseUrl: string): string | undefined {
return parseProviderPresetUrl(baseUrl)?.host;
}
function providerPresetMatchesHost(preset: ProviderPreset, baseUrl: string): boolean {
const candidate = parseProviderPresetUrl(baseUrl);
if (!candidate) {
return false;
}
return preset.endpoints.some((endpoint) => {
const parsed = parseProviderPresetUrl(endpoint.baseUrl);
return parsed?.protocol === candidate.protocol && parsed.host === candidate.host;
});
}
function parseProviderPresetUrl(value: string): URL | undefined {
try {
return new URL(providerUrlWithDefaultScheme(value.trim()));
@ -137,6 +218,15 @@ function parseProviderPresetUrl(value: string): URL | undefined {
}
}
function isLoopbackProviderBaseUrl(value: string): boolean {
try {
const hostname = new URL(providerUrlWithDefaultScheme(value.trim())).hostname.toLowerCase();
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
} catch {
return false;
}
}
function normalizeProviderPresetPath(value: string): string {
const trimmed = value.replace(/\/+$/, "");
return trimmed || "/";
@ -145,3 +235,16 @@ function normalizeProviderPresetPath(value: string): string {
function normalizeProviderIdentityText(value: string | undefined): string {
return value?.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "") ?? "";
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}

View file

@ -339,30 +339,21 @@ function providerProbeCapabilities(
probe: GatewayProviderProbeResult
): GatewayProviderCapability[] {
const detectedCapabilities = mergeProviderCapabilities(probe.capabilities ?? []);
const presetCapabilities = providerProbePresetCapabilities(candidate);
return mergeProviderCapabilities(detectedCapabilities, presetCapabilities);
}
if (detectedCapabilities.length > 0) {
return detectedCapabilities;
}
function providerProbePresetCapabilities(candidate: GatewayProviderProbeCandidate): GatewayProviderCapability[] {
if (candidate.source !== "preset") {
return [];
}
return uniqueProtocols(candidate.declaredProtocols ?? []).map((type) => ({
baseUrl: providerProbeCandidateBaseUrlForProtocol(candidate.baseUrl, type),
return candidate.protocols.map((type) => ({
baseUrl: probe.normalizedBaseUrl || candidate.baseUrl,
source: "preset" as const,
type
}));
}
function providerProbeCandidateBaseUrlForProtocol(baseUrl: string, protocol: GatewayProviderProtocol): string {
try {
return providerBaseUrlForProtocol(parseProviderBaseUrl(baseUrl), protocol);
} catch {
return baseUrl.trim();
}
}
function mergeProviderCapabilities(...groups: GatewayProviderCapability[][]): GatewayProviderCapability[] {
const seen = new Set<string>();
const capabilities: GatewayProviderCapability[] = [];
@ -465,7 +456,7 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour
};
}
const result = await requestJson(withGeminiKey(geminiApiEndpoint(parsed.geminiBaseUrl, "models"), apiKey), {
const result = await requestJson(withGeminiKey(`${parsed.geminiBaseUrl}/v1beta/models`, apiKey), {
headers: {
...geminiHeaders(apiKey)
},
@ -762,11 +753,11 @@ function endpointsForProtocol(
return [
{
baseUrl: parsed.geminiBaseUrl,
endpoint: geminiApiEndpoint(parsed.geminiBaseUrl, "interactions", "v1beta")
endpoint: `${parsed.geminiBaseUrl}/v1beta/interactions`
},
{
baseUrl: parsed.geminiBaseUrl,
endpoint: geminiApiEndpoint(parsed.geminiBaseUrl, "interactions", "v1")
endpoint: `${parsed.geminiBaseUrl}/v1/interactions`
}
];
}
@ -775,19 +766,11 @@ function endpointsForProtocol(
return [
{
baseUrl: parsed.geminiBaseUrl,
endpoint: geminiApiEndpoint(parsed.geminiBaseUrl, `models/${encodedModel}:generateContent`)
endpoint: `${parsed.geminiBaseUrl}/v1beta/models/${encodedModel}:generateContent`
}
];
}
function geminiApiEndpoint(baseUrl: string, path: string, defaultVersion: "v1" | "v1beta" = "v1beta"): string {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
if (/\/v1(?:beta)?$/i.test(normalizedBaseUrl)) {
return `${normalizedBaseUrl}/${path}`;
}
return `${normalizedBaseUrl}/${defaultVersion}/${path}`;
}
function withGeminiKey(url: string, apiKey: string | undefined): string {
if (!apiKey) {
return url;
@ -1093,7 +1076,18 @@ function isProtocolSupported(
if (/not found|unknown endpoint|unknown route|no route/.test(normalized)) {
return false;
}
return true;
if (protocol === "openai_responses") {
return /model|max_output|max output|input|required/.test(normalized);
}
if (protocol === "openai_chat_completions" || protocol === "anthropic_messages") {
return /model|max_tokens|messages|required/.test(normalized);
}
if (protocol === "gemini_generate_content") {
return /contents|generatecontentrequest|generationconfig/.test(normalized);
}
if (protocol === "gemini_interactions") {
return /model|input|required|interaction|generation_config|generationconfig|system_instruction/.test(normalized);
}
}
return false;

View file

@ -39,7 +39,7 @@ export function parseProviderBaseUrl(value: string): ParsedProviderBaseUrl {
return {
anthropicBaseUrl,
anthropicBaseUrlCandidates,
geminiBaseUrl: providerGeminiBaseUrl(normalizedInputBaseUrl, rootBaseUrl),
geminiBaseUrl: rootBaseUrl,
normalizedInputBaseUrl,
openaiBaseUrl,
openaiBaseUrlCandidates,
@ -126,27 +126,6 @@ function stripProviderApiVersion(value: string): string {
return compactProviderUrl(url);
}
function providerGeminiBaseUrl(normalizedInputBaseUrl: string, rootBaseUrl: string): string {
return isVersionedVertexBypassBaseUrl(normalizedInputBaseUrl)
? normalizedInputBaseUrl
: rootBaseUrl;
}
function isVersionedVertexBypassBaseUrl(value: string): boolean {
try {
const url = new URL(value);
const segments = url.pathname
.split("/")
.map((segment) => segment.trim().toLowerCase())
.filter(Boolean);
return segments.includes("bypass") &&
segments.includes("vertex") &&
/^(v1|v1beta)$/.test(segments[segments.length - 1] ?? "");
} catch {
return false;
}
}
function stripNestedProviderApiVersion(pathname: string): string {
return pathname.replace(/(\/v[0-9][a-z0-9-]*)\/v1$/i, "$1") || "/";
}

View file

@ -14,7 +14,7 @@ import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/confi
import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants";
import { detectProviderIcon } from "@ccr/core/providers/icons";
import { fetchProviderManifest } from "@ccr/core/providers/manifest-service";
import { getLocalAgentProviderCandidates, importLocalAgentProvider, probeLocalAgentProvider } from "@ccr/core/agents/local-providers/service";
import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "@ccr/core/agents/local-providers/service";
import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog";
import { getProviderPresets } from "@ccr/core/providers/presets/index";
import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe";
@ -47,7 +47,6 @@ import type {
GatewayProviderProbeRequest,
GatewayStatus,
LocalAgentProviderImportRequest,
LocalAgentProviderProbeRequest,
PluginDependency,
PluginDirectorySelection,
PluginMarketplaceEntry,
@ -341,7 +340,6 @@ const rpcHandlers: Record<string, RpcHandler> = {
logProfileApplyResult(await applyProfileConfig(config));
return openProfileFromCcr(config, request as ProfileOpenRequest);
},
probeLocalAgentProvider: (request) => probeLocalAgentProvider(request as LocalAgentProviderProbeRequest),
probeProvider: (request) => probeGatewayProvider(request as GatewayProviderProbeRequest),
probeProviderCandidates: (request) => probeGatewayProviderCandidates(request as GatewayProviderProbeCandidatesRequest),
quitApp: async () => {

View file

@ -1,6 +1,6 @@
{
"name": "@claude-code-router/electron",
"version": "3.0.11",
"version": "3.0.9",
"private": true,
"description": "Claude Code Router Electron desktop shell.",
"main": "dist/main/main.js",

View file

@ -70,13 +70,6 @@ type BrowserSessionRef = {
userId?: string;
};
type SnapshotOptions = {
limit?: number;
maxElements: number;
maxText?: number;
offset?: number;
};
type AttachedSession = {
attachedAt: number;
leaseId: string;
@ -147,8 +140,6 @@ const defaultSnapshotMaxElements = 80;
const defaultSnapshotMaxText = 3_000;
const defaultEventAwaitTimeoutMs = 10_000;
const defaultJavascriptTimeoutMs = 8_000;
const maxSnapshotTextLimit = 20_000;
const maxSnapshotTextOffset = 1_000_000_000;
const maxMcpRequestBytes = 2 * 1024 * 1024;
const maxSubscriptionEvents = 512;
const maxToolResultChars = 60_000;
@ -157,7 +148,7 @@ const maxToolResultStringChars = 2_000;
const maxToolResultObjectKeys = 120;
const maxSnapshotResultElements = 80;
const maxAxSnapshotResultNodes = 80;
const maxBrowserResultTextChars = maxSnapshotTextLimit;
const maxBrowserResultTextChars = 3_000;
const defaultWaitTimeoutMs = 10_000;
const sessionSchema = objectSchema({
@ -561,10 +552,8 @@ function browserLegacyAliasTools(): McpTool[] {
{
description: "Capture page text plus interactable element refs for browser automation. Use returned refs for browser_click, browser_type, browser_select, browser_press_key, or browser_scroll.",
inputSchema: objectSchema({
limit: { description: "Maximum page text characters to include. Overrides maxText when both are provided.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" },
maxElements: { description: "Maximum interactable elements to include.", maximum: 300, minimum: 1, type: "number" },
maxText: { description: "Legacy alias for limit.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" },
offset: { description: "Starting character offset into the normalized page text.", maximum: maxSnapshotTextOffset, minimum: 0, type: "number" },
maxText: { description: "Maximum page text characters to include.", maximum: 20000, minimum: 0, type: "number" },
tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }
}),
name: "browser_snapshot",
@ -1025,9 +1014,8 @@ class BrowserAutomationMcpService implements BrowserAutomationMcpIntegration {
builtInBrowserService.getAutomationWebContents(readString(args.tabId)),
readString(args.tabId),
{
limit: clampInteger(readNumber(args.limit) ?? readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, maxSnapshotTextLimit),
maxElements: clampInteger(readNumber(args.maxElements) ?? defaultSnapshotMaxElements, 1, 300),
offset: clampInteger(readNumber(args.offset) ?? 0, 0, maxSnapshotTextOffset)
maxText: clampInteger(readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, 20000)
}
);
case "browser_click":
@ -2351,7 +2339,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: s
}
}
async function captureSnapshot(webContents: WebContents, options: SnapshotOptions): Promise<unknown> {
async function captureSnapshot(webContents: WebContents, options: { maxElements: number; maxText: number }): Promise<unknown> {
return await executeJavaScriptWithTimeout(
webContents,
`(${snapshotScript})(${JSON.stringify(options)})`,
@ -2363,7 +2351,7 @@ async function captureSnapshot(webContents: WebContents, options: SnapshotOption
async function captureSnapshotWithHandoff(
webContents: WebContents,
tabId: string | undefined,
options: SnapshotOptions
options: { maxElements: number; maxText: number }
): Promise<unknown> {
const snapshot = await captureSnapshot(webContents, options);
const handoff = await maybeRequestPageHandoff(webContents, {
@ -2682,11 +2670,9 @@ async function waitForPageCondition(
};
}
const snapshotScript = function(options: { limit?: number; maxElements: number; maxText?: number; offset?: number }) {
const snapshotScript = function(options: { maxElements: number; maxText: number }) {
const maxElements = Math.max(1, Math.min(300, Math.floor(options.maxElements || 80)));
const textLimitInput = options.limit ?? options.maxText ?? 3000;
const textLimit = Math.max(0, Math.min(20000, Math.floor(textLimitInput)));
const requestedTextOffset = Math.max(0, Math.floor(options.offset || 0));
const maxText = Math.max(0, Math.min(20000, Math.floor(options.maxText || 3000)));
const refAttribute = "data-ccr-browser-ref";
const elementSelector = [
"a[href]",
@ -2874,11 +2860,6 @@ const snapshotScript = function(options: { limit?: number; maxElements: number;
};
});
const pageText = (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
const textOffset = Math.min(requestedTextOffset, pageText.length);
const textWindow = pageText.slice(textOffset, textOffset + textLimit);
const textNextOffset = textOffset + textWindow.length;
return {
activeElement: document.activeElement ? {
name: nameOf(document.activeElement).slice(0, 240),
@ -2887,15 +2868,7 @@ const snapshotScript = function(options: { limit?: number; maxElements: number;
tag: document.activeElement.tagName.toLowerCase()
} : undefined,
elements,
text: textWindow,
textHasMore: textNextOffset < pageText.length,
textLength: pageText.length,
textLimit,
textNextOffset,
textOffset,
textRemaining: Math.max(0, pageText.length - textNextOffset),
textReturned: textWindow.length,
textRequestedOffset: requestedTextOffset !== textOffset ? requestedTextOffset : undefined,
text: (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, maxText),
title: document.title,
url: location.href
};
@ -3464,7 +3437,7 @@ function truncateString(value: string, maxChars: number): string {
function largeResultGuidance(toolName: string): string {
if (toolName === "browser_snapshot") {
return "Result was compacted. Re-run browser_snapshot with lower maxElements/limit, page text with offset/limit, or use browser_ax_query to fetch targeted elements.";
return "Result was compacted. Re-run browser_snapshot with lower maxElements/maxText, or use browser_ax_query to fetch targeted elements.";
}
if (toolName === "browser_ax_snapshot") {
return "Result was compacted. Re-run browser_ax_snapshot with lower limit or scope=outline, or use browser_ax_query with role/name/text filters.";

View file

@ -17,7 +17,7 @@ import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/ru
import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, resetCodexRateLimitCredit, testProviderAccountConnector } from "@ccr/core/providers/account-service";
import { detectProviderIcon } from "@ccr/core/providers/icons";
import { fetchProviderManifest } from "@ccr/core/providers/manifest-service";
import { getLocalAgentProviderCandidates, importLocalAgentProvider, probeLocalAgentProvider } from "@ccr/core/agents/local-providers/service";
import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "@ccr/core/agents/local-providers/service";
import { isLaunchAtLoginSupported, syncLaunchAtLogin } from "./launch-at-login";
import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog";
import { getProviderPresets } from "@ccr/core/providers/presets/index";
@ -117,7 +117,6 @@ ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus
ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter));
ipcMain.handle(IPC_CHANNELS.appFetchProviderManifest, (_event, request: ProviderManifestFetchRequest) => fetchProviderManifest(request));
ipcMain.handle(IPC_CHANNELS.appImportLocalAgentProvider, (_event, request: LocalAgentProviderImportRequest) => importLocalAgentProvider(request));
ipcMain.handle(IPC_CHANNELS.appProbeLocalAgentProvider, (_event, request) => probeLocalAgentProvider(request));
ipcMain.handle(IPC_CHANNELS.appInstallProxyCertificate, () => proxyService.installCertificate());
ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, async (_event, serverName: string) => {
const name = typeof serverName === "string" ? serverName.trim() : "";

View file

@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, webUtils } from "electron";
import { contextBridge, ipcRenderer } from "electron";
import { browserErrorI18nLanguage, formatLocalizedErrorMessage } from "@ccr/core/contracts/i18n";
import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels";
import type {
@ -41,8 +41,6 @@ import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportRequest,
LocalAgentProviderImportResult,
LocalAgentProviderProbeRequest,
LocalAgentProviderProbeResult,
PluginDirectorySelection,
PluginMarketplaceEntry,
ProfileOpenCommandResult,
@ -108,7 +106,6 @@ contextBridge.exposeInMainWorld("ccr", {
getAgentTracePayload: (request: AgentAnalysisTracePayloadRequest) => invoke(IPC_CHANNELS.appGetAgentTracePayload, request) as Promise<AgentAnalysisTracePayloadFullResult>,
getAppInfo: () => invoke(IPC_CHANNELS.appGetInfo) as Promise<AppInfo>,
getConfig: () => invoke(IPC_CHANNELS.appGetConfig) as Promise<AppConfig>,
getFilePath: (file: File) => webUtils.getPathForFile(file),
getGatewayStatus: () => invoke(IPC_CHANNELS.appGetGatewayStatus) as Promise<GatewayStatus>,
getLocalAgentProviderCandidates: () => invoke(IPC_CHANNELS.appGetLocalAgentProviderCandidates) as Promise<LocalAgentProviderCandidate[]>,
getOnboardingFinished: () => invoke(IPC_CHANNELS.appGetOnboardingFinished) as Promise<boolean>,
@ -134,7 +131,6 @@ contextBridge.exposeInMainWorld("ccr", {
openExternal: (url: string) => invoke(IPC_CHANNELS.appOpenExternal, url) as Promise<void>,
openProfile: (request: ProfileOpenRequest) => invoke(IPC_CHANNELS.appOpenProfile, request) as Promise<ProfileOpenResult>,
prepareImageExportTarget: (request: AppImageExportTargetRequest) => invoke(IPC_CHANNELS.appPrepareImageExportTarget, request) as Promise<AppImageExportTargetResult>,
probeLocalAgentProvider: (request: LocalAgentProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeLocalAgentProvider, request) as Promise<LocalAgentProviderProbeResult>,
probeProviderCandidates: (request: GatewayProviderProbeCandidatesRequest) => invoke(IPC_CHANNELS.appProbeProviderCandidates, request) as Promise<GatewayProviderProbeCandidateResult | undefined>,
probeProvider: (request: GatewayProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeProvider, request) as Promise<GatewayProviderProbeResult>,
quitApp: () => invoke(IPC_CHANNELS.appQuit) as Promise<void>,

View file

@ -1,6 +1,6 @@
{
"name": "@claude-code-router/ui",
"version": "3.0.11",
"version": "3.0.9",
"private": true,
"description": "Claude Code Router web management UI.",
"dependencies": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -46,7 +46,7 @@ function Dialog({
return (
<motion.div
animate={{ opacity: 1 }}
className={cn("fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/28 p-3 sm:p-6", className)}
className={cn("fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-black/28 p-3 sm:p-6", className)}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
onMouseDown={(event) => {

View file

@ -17,7 +17,7 @@ import {
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
isTraySupportedPlatform,
isRoutingRewriteDraftRowValid,
LayoutGroup, mergeModelDisplayNames, mergeModelMetadata, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels, modelMetadataForModels,
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels,
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
@ -26,7 +26,7 @@ import {
persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft,
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage,
providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage,
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, ServerActionBusy, SettingsPageId,
routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig,
@ -56,38 +56,16 @@ const providerNamePlaceholder = "__CCR_PROVIDER_NAME__";
const providerNameSlugPlaceholder = "__CCR_PROVIDER_NAME_SLUG__";
const providerInternalNamePlaceholder = "__CCR_PROVIDER_INTERNAL_NAME__";
const localAgentProviderApiKey = "ccr-local-agent-login";
const localCodexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex";
const localCodexProviderId = "codex-api";
function isLocalCodexProviderDraft(draft: AddProviderDraft): boolean {
return (
draft.apiKey.trim() === localAgentProviderApiKey &&
normalizeProviderBaseUrl(draft.baseUrl) === normalizeProviderBaseUrl(localCodexDefaultBaseUrl)
);
}
function localCodexProviderDraftProbeKey(draft: AddProviderDraft): string {
return JSON.stringify([
draft.apiKey.trim(),
normalizeProviderBaseUrl(draft.baseUrl),
draft.protocol
]);
}
function materializeProviderPluginTemplates(
templates: unknown[],
providerName: string,
protocol: GatewayProviderConfig["type"],
providerId: string
protocol: GatewayProviderConfig["type"]
): unknown[] {
if (templates.length === 0) {
return [];
}
// The core gateway matches provider plugins against the provider's runtime
// identifier (provider.id, or its slug), not the human-readable display name
// — the internal name here must mirror providerCapabilityInternalName() in
// gateway/service.ts or the plugin's auth-header override silently never applies.
const internalName = protocol ? `${providerId}::${protocol}` : providerId;
const internalName = protocol ? `${providerName}::${protocol}` : providerName;
const replacements: Record<string, string> = {
[providerInternalNamePlaceholder]: internalName,
[providerNamePlaceholder]: providerName,
@ -668,9 +646,6 @@ function App() {
}, [draftConfig.plugins, pluginRoutingConfigTarget]);
const providers = useMemo(() => draftConfig.Providers.map((provider, index) => ({ provider, index })), [draftConfig.Providers]);
const gatewayEndpoint = gatewayStatus.endpoint || draftConfig.routerEndpoint;
const gatewayStartupError = gatewayStatus.state === "error"
? translateAppErrorMessage(copy, gatewayStatus.lastError || "Service did not start.")
: "";
const networkCaptureEnabled = draftConfig.proxy.enabled && draftConfig.proxy.captureNetwork;
const visibleNavigation = useMemo(
() => navigation.filter((item) =>
@ -852,23 +827,12 @@ function App() {
void checkForAppUpdate();
}
function openSidebarUpdateDialog() {
function openUpdateDownloadDialog() {
setUpdateDialogOpen(true);
setUpdateActionError("");
if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") {
void downloadAppUpdate();
return;
}
if (
updateDialogStatus.canInstall ||
updateDialogStatus.state === "checking" ||
updateDialogStatus.state === "downloading" ||
updateDialogStatus.state === "downloaded" ||
updateDialogStatus.state === "installing"
) {
return;
}
void checkForAppUpdate();
}
async function checkForAppUpdate() {
@ -1124,7 +1088,6 @@ function App() {
setProviderDeepLinkBusy(true);
let nextPayload = payload;
let catalogModelDisplayNames: Record<string, string> | undefined;
let catalogModelMetadata: ProviderDeepLinkPayload["modelMetadata"] | undefined;
let probe: GatewayProviderProbeResult | undefined;
if (nextPayload.models.length === 0) {
const catalogModels = await resolveProviderDeepLinkCatalogModels(nextPayload);
@ -1133,7 +1096,6 @@ function App() {
return;
}
catalogModelDisplayNames = catalogModels.modelDisplayNames;
catalogModelMetadata = catalogModels.modelMetadata;
if (catalogModels.models.length > 0) {
nextPayload = {
...nextPayload,
@ -1156,8 +1118,7 @@ function App() {
const initialDraftFromPayload = createProviderDraftFromDeepLinkPayload(nextPayload, draftConfig.Providers);
const initialDraft = {
...initialDraftFromPayload,
modelDisplayNames: mergeModelDisplayNames(initialDraftFromPayload.modelDisplayNames, catalogModelDisplayNames),
modelMetadata: mergeModelMetadata(initialDraftFromPayload.modelMetadata, catalogModelMetadata)
modelDisplayNames: mergeModelDisplayNames(initialDraftFromPayload.modelDisplayNames, catalogModelDisplayNames)
};
setProviderEditIndex(undefined);
setProviderImportOpen(true);
@ -1198,7 +1159,6 @@ function App() {
...next,
modelDescriptions: patch.modelDescriptions ?? current.modelDescriptions,
modelDisplayNames: patch.modelDisplayNames,
modelMetadata: patch.modelMetadata ?? current.modelMetadata,
modelsText: mergeProviderModelLists(current.selectedModels, splitLines(next.modelsText)).join("\n"),
selectedModels: [],
selectedProtocols: patch.selectedProtocols ?? current.selectedProtocols
@ -1222,54 +1182,6 @@ function App() {
if (!window.ccr || !providerFormVisible) {
return;
}
if (isLocalCodexProviderDraft(providerDraft)) {
providerProbeRequestId.current += 1;
const requestId = providerProbeRequestId.current;
const inputKey = localCodexProviderDraftProbeKey(providerDraft);
setProviderProbeError("");
if (!window.ccr.probeLocalAgentProvider) {
setProviderProbe(undefined);
setProviderProbeLoading(false);
return undefined;
}
setProviderProbeLoading(true);
const timer = window.setTimeout(() => {
void window.ccr?.probeLocalAgentProvider?.({ id: localCodexProviderId })
.then((result) => {
if (providerProbeRequestId.current !== requestId) {
return;
}
setProviderProbe(result.probe);
setProviderDraft((current) => {
if (!isLocalCodexProviderDraft(current) || localCodexProviderDraftProbeKey(current) !== inputKey) {
return current;
}
return applyProviderProbeResult(current, result.probe);
});
})
.catch((error) => {
if (providerProbeRequestId.current === requestId) {
setProviderProbe(undefined);
setProviderProbeError(formatError(error));
}
})
.finally(() => {
if (providerProbeRequestId.current === requestId) {
setProviderProbeLoading(false);
}
});
}, 350);
return () => {
window.clearTimeout(timer);
if (providerProbeRequestId.current === requestId) {
providerProbeRequestId.current += 1;
setProviderProbeLoading(false);
}
};
}
if (providerDraft.providerPlugins.length > 0) {
providerProbeRequestId.current += 1;
setProviderProbe(undefined);
@ -1445,6 +1357,8 @@ function App() {
setProviderProbeError(translateAppErrorMessage(copy, credentials));
return false;
}
const fallbackProtocol = probe?.detectedProtocol ?? providerDraft.protocol;
const fallbackBaseUrl = probe?.normalizedBaseUrl || providerDraft.baseUrl;
const selectableProtocols = providerSelectableProtocolsFromProbe(probe);
const selectedProtocols = providerDraft.selectedProtocols.length > 0
? providerDraft.selectedProtocols.filter((protocol) => !probe || selectableProtocols.includes(protocol))
@ -1454,20 +1368,25 @@ function App() {
return false;
}
const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [probe?.detectedProtocol ?? providerDraft.protocol];
const fallbackProtocol = protocolsToSave.includes(providerDraft.protocol)
? providerDraft.protocol
: protocolsToSave[0] ?? probe?.detectedProtocol ?? providerDraft.protocol;
const fallbackBaseUrl = providerGlobalBaseUrlForProbe(providerDraft.baseUrl, probe, protocolsToSave);
const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [fallbackProtocol];
const modelDescriptions = modelDescriptionsForModels(providerDraft.modelDescriptions, models);
const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models);
const modelMetadata = modelMetadataForModels(providerDraft.modelMetadata, models);
const capabilities = providerCapabilitiesForProtocols(providerDraft.baseUrl, protocolsToSave, probe, presetCapabilitiesFromDraft(providerDraft));
const selectedProtocolSet = new Set(protocolsToSave);
const capabilityCandidates = mergeProviderCapabilities(
presetCapabilitiesFromDraft(providerDraft),
probe?.capabilities ?? [],
protocolsToSave.map((type) => ({
baseUrl: fallbackBaseUrl,
source: probe?.detectedProtocol ? ("detected" as const) : ("preset" as const),
type
}))
);
const capabilities = capabilityCandidates.filter((capability) => selectedProtocolSet.has(capability.type));
const primaryCapability =
capabilities.find((capability) => capability.type === fallbackProtocol) ??
capabilities[0];
const protocol = primaryCapability?.type ?? fallbackProtocol;
const baseUrl = fallbackBaseUrl;
const baseUrl = primaryCapability?.baseUrl ?? fallbackBaseUrl;
const keySafetyIssue = providerApiKeySafetyIssue({
apiKey: providerDraft.apiKey,
@ -1512,24 +1431,20 @@ function App() {
return false;
}
const existingProvider = providerEditIndex !== undefined ? draftConfig.Providers[providerEditIndex] : undefined;
const providerId = existingProvider?.id ?? providerNameSlug(providerName);
const provider: GatewayProviderConfig = {
api_base_url: normalizeProviderBaseUrl(baseUrl),
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
api_key: providerDraft.apiKey.trim(),
capabilities: capabilities.length > 0 ? capabilities : undefined,
account: accountConfig,
credentials: credentials.length > 0 ? credentials : undefined,
icon: providerDraft.icon.trim() || undefined,
id: providerId,
modelDescriptions,
modelDisplayNames,
modelMetadata,
models,
name: providerName,
type: protocol
};
const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol, providerId);
const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol);
const wasImport = providerImportOpen;
const next = buildConfigUpdate((config) => {
@ -2863,7 +2778,6 @@ function App() {
<div className="relative flex h-full min-h-0 w-full min-w-0 overflow-hidden bg-background text-foreground max-[720px]:flex-col">
{activeView === "onboarding" ? (
<OnboardingLayout
gatewayStartupError={gatewayStartupError}
loaded={configLoaded && onboardingStatusLoaded && providerPresetsLoaded}
onboarding={{
activeStep: onboardingStep,
@ -2898,13 +2812,11 @@ function App() {
copy={copy}
gatewayActionBusy={gatewayActionBusy}
gatewayEndpoint={gatewayEndpoint}
gatewayStartupError={gatewayStartupError}
gatewayStatus={gatewayStatus}
isMac={isMac}
needsTrafficLightSafeArea={needsTrafficLightSafeArea}
networkCaptureEnabled={networkCaptureEnabled}
onOpenUpdate={openSidebarUpdateDialog}
onOpenServerSettings={() => setActiveView("server")}
onDownloadUpdate={openUpdateDownloadDialog}
onOpenSettings={openSettingsDialog}
onSelectNavigationItem={selectNavigationItem}
onToggleSidebar={() => setSidebarOpen((current) => !current)}

View file

@ -3705,7 +3705,7 @@ function ToolPayloadDialog({
: undefined;
return (
<Dialog className="z-[110] items-start" onOpenChange={(open) => !open && onClose()} open>
<Dialog className="z-[70] items-start" onOpenChange={(open) => !open && onClose()} open>
<DialogContent className="h-[calc(100dvh-1.5rem)] max-w-[1180px] origin-top sm:h-[min(820px,calc(100dvh-3rem))]">
<DialogHeader>
<div className="min-w-0">

View file

@ -1,10 +1,10 @@
import type { ComponentProps } from "react";
import {
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, Check, CircleAlert, cn, EndpointTitleBar,
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar,
AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase,
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, RefreshCw,
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen,
reducedMotionTransition, ServiceControlButton, Settings, ViewId,
useAppText, ViewMotionShell, viewUsesInternalScroll
ViewMotionShell, viewUsesInternalScroll
} from "../shared/index";
import { ApiKeysView } from "./api-keys";
import { AgentAnalysisView, OverviewView } from "./dashboard";
@ -38,20 +38,15 @@ type MainViewProps = {
};
export function OnboardingLayout({
gatewayStartupError,
loaded,
onboarding
}: {
gatewayStartupError?: string;
loaded: boolean;
onboarding: ComponentProps<typeof OnboardingView>;
}) {
return (
<main className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
<div className="pointer-events-none absolute inset-x-0 top-12 z-30 px-5 max-[720px]:top-10 max-[720px]:px-3">
<GatewayStartupErrorBanner className="pointer-events-auto mx-auto max-w-2xl shadow-lg" message={gatewayStartupError} />
</div>
{loaded ? <OnboardingView {...onboarding} /> : null}
</main>
);
@ -63,14 +58,12 @@ export function MainLayout({
copy,
gatewayActionBusy,
gatewayEndpoint,
gatewayStartupError,
gatewayStatus,
isMac,
needsTrafficLightSafeArea,
agentAnalysisEnabled,
networkCaptureEnabled,
onOpenUpdate,
onOpenServerSettings,
onDownloadUpdate,
onOpenSettings,
onSelectNavigationItem,
onToggleSidebar,
@ -89,13 +82,11 @@ export function MainLayout({
copy: AppCopy;
gatewayActionBusy: boolean;
gatewayEndpoint: string;
gatewayStartupError?: string;
gatewayStatus: GatewayStatus;
isMac: boolean;
needsTrafficLightSafeArea: boolean;
networkCaptureEnabled: boolean;
onOpenUpdate: () => void;
onOpenServerSettings: () => void;
onDownloadUpdate: () => void;
onOpenSettings: () => void;
onSelectNavigationItem: (id: NavigationId) => void;
onToggleSidebar: () => void;
@ -108,24 +99,15 @@ export function MainLayout({
requestLogsEnabled: boolean;
visibleNavigation: MainNavigationItem[];
}) {
const showUpdateButton = updateStatus.supported;
const windowControlSafeAreaWidth = showUpdateButton
? (isMac ? 188 : 124)
: (isMac ? 152 : 88);
const updateBusy =
updateActionBusy ||
updateStatus.state === "checking" ||
updateStatus.state === "downloading" ||
updateStatus.state === "installing";
const updateLabel = updateStatus.state === "downloaded"
const windowControlSafeAreaWidth = isMac ? 152 : 88;
const showUpdateDownloadButton =
updateStatus.supported &&
(updateStatus.state === "available" || updateStatus.state === "downloading" || updateStatus.state === "downloaded");
const updateDownloadLabel = updateStatus.state === "downloaded"
? copy.text["Update ready to install"] ?? "Update ready to install"
: updateStatus.state === "downloading"
? copy.text["Downloading update"] ?? "Downloading update"
: updateStatus.state === "checking"
? copy.text["Checking for updates"] ?? "Checking for updates"
: updateStatus.state === "available"
? copy.text["Download update"] ?? "Download update"
: copy.text["Check for updates"] ?? "Check for updates";
: copy.text["Download update"] ?? "Download update";
return (
<>
@ -150,25 +132,17 @@ export function MainLayout({
onClick={toggleGatewayService}
state={gatewayStatus.state}
/>
{showUpdateButton ? (
{showUpdateDownloadButton ? (
<Button
aria-label={updateLabel}
aria-label={updateDownloadLabel}
className="app-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-transparent p-0 text-primary outline-none transition-colors hover:bg-primary/10 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring/25"
onMouseDown={(event) => event.stopPropagation()}
onClick={onOpenUpdate}
title={updateLabel}
onClick={onDownloadUpdate}
title={updateDownloadLabel}
type="button"
unstyled
>
{updateBusy ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : updateStatus.state === "downloaded" ? (
<Check className="h-3.5 w-3.5" />
) : updateStatus.state === "available" ? (
<Download className="h-3.5 w-3.5" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{updateActionBusy || updateStatus.state === "downloading" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
</Button>
) : null}
</div>
@ -264,11 +238,6 @@ export function MainLayout({
gatewayStatus={gatewayStatus}
/>
</div>
<GatewayStartupErrorBanner
className="mx-5 mt-3 max-[720px]:mx-3"
message={gatewayStartupError}
onOpenServerSettings={onOpenServerSettings}
/>
<div
className={cn(
"min-h-0 flex-1 px-5 pb-5 pt-5 max-[720px]:px-3 max-[720px]:pb-3 max-[720px]:pt-3",
@ -288,51 +257,6 @@ export function MainLayout({
);
}
export function GatewayStartupErrorBanner({
className,
message,
onOpenServerSettings
}: {
className?: string;
message?: string;
onOpenServerSettings?: () => void;
}) {
const t = useAppText();
const detail = message?.trim();
if (!detail) {
return null;
}
return (
<div
aria-live="assertive"
className={cn(
"app-no-drag flex min-w-0 items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive",
className
)}
role="alert"
>
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-semibold">{t("Service failed to start")}</div>
<div className="mt-0.5 whitespace-pre-wrap break-words">{detail}</div>
</div>
{onOpenServerSettings ? (
<Button
className="shrink-0 border-destructive/30 bg-background/80 text-destructive hover:bg-destructive/10"
onClick={onOpenServerSettings}
size="sm"
type="button"
variant="outline"
>
<Settings className="h-3.5 w-3.5" />
{t("Server")}
</Button>
) : null}
</div>
);
}
function MainViewSwitch({
activeView,
agentAnalysisEnabled,

View file

@ -6,7 +6,7 @@ import {
normalizeProfileScope, normalizeProfileSurface, parseProfileModelValue, Pencil, Plus, PopoverContent,
profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelDisplayValue, profileModelMatchesQuery, profileModelProviderMatchesQuery,
profileModelOptionDisplayName, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
Play, Power, RefreshCw, Search, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig,
Play, Power, RefreshCw, Search, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactNode, type VirtualModelProfileConfig,
copyTextToClipboard,
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, X
} from "../shared/index";
@ -774,41 +774,10 @@ export function AddProfileForm({
virtualModelProfiles?: VirtualModelProfileConfig[];
}) {
const t = useAppText();
const [appPathDragActive, setAppPathDragActive] = useState(false);
const appPathLabel = profileAppPathLabel(draft.agent);
const showAppPathField = draft.surface !== "cli" && Boolean(appPathLabel);
const handleAppPathDrop = useCallback((event: ReactDragEvent<HTMLElement>) => {
if (!showAppPathField) {
return;
}
event.preventDefault();
event.stopPropagation();
setAppPathDragActive(false);
const appPath = appPathFromDropEvent(event);
if (appPath) {
onChange({ appPath });
}
}, [onChange, showAppPathField]);
const handleAppPathDragOver = useCallback((event: ReactDragEvent<HTMLElement>) => {
if (!showAppPathField) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setAppPathDragActive(true);
}, [showAppPathField]);
const handleAppPathDragLeave = useCallback(() => {
setAppPathDragActive(false);
}, []);
return (
<>
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
onDragLeave={showAppPathField ? handleAppPathDragLeave : undefined}
onDragOver={showAppPathField ? handleAppPathDragOver : undefined}
onDrop={showAppPathField ? handleAppPathDrop : undefined}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label={t("Agent")}>
<AgentSelectControl
onChange={(agent) => onChange({ agent })}
@ -847,20 +816,6 @@ export function AddProfileForm({
value={draft.surface}
/>
</Field>
{showAppPathField && appPathLabel ? (
<Field className="sm:col-span-2" label={t(appPathLabel)}>
<div className={cn(
"rounded-md border border-border bg-background p-1 transition-colors",
appPathDragActive ? "border-primary bg-primary/5" : "border-border"
)}>
<Input
placeholder={t("Drop the app here or paste the executable path")}
value={draft.appPath}
onChange={(event) => onChange({ appPath: event.target.value })}
/>
</div>
</Field>
) : null}
{draft.agent === "claude-code" ? (
<>
<Field label={t("Model override")}>
@ -929,63 +884,6 @@ export function AddProfileForm({
);
}
function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | "CHATGPT_APP_PATH" | undefined {
if (agent === "claude-code") {
return "CLAUDE_APP_PATH";
}
if (agent === "codex") {
return "CHATGPT_APP_PATH";
}
return undefined;
}
function appPathFromDropEvent(event: ReactDragEvent<HTMLElement>): string {
for (const file of Array.from(event.dataTransfer.files ?? [])) {
const path = filePathFromDroppedFile(file);
if (path) {
return path;
}
}
return appPathFromDroppedText(
event.dataTransfer.getData("text/uri-list") ||
event.dataTransfer.getData("text/plain")
);
}
function filePathFromDroppedFile(file: File): string {
try {
const bridgedPath = window.ccr?.getFilePath?.(file)?.trim();
if (bridgedPath) {
return bridgedPath;
}
} catch {
// Fall through to Electron versions that still expose File.path.
}
const legacyPath = (file as File & { path?: string }).path?.trim();
return legacyPath || "";
}
function appPathFromDroppedText(value: string): string {
const line = value
.split(/\r?\n/)
.map((item) => item.trim())
.find((item) => item && !item.startsWith("#")) || "";
if (!line) {
return "";
}
if (!line.startsWith("file:")) {
return line;
}
try {
const pathname = decodeURIComponent(new URL(line).pathname);
return /^\/[A-Za-z]:\//.test(pathname)
? pathname.slice(1)
: pathname;
} catch {
return "";
}
}
const ADD_BOT_SELECT_VALUE = "__add_bot__";
const HANDOFF_TARGET_NONE_VALUE = "__ccr_handoff_target_none__";

View file

@ -11,8 +11,8 @@ import {
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
ProviderConnectivityCheckReport, providerCapabilityBaseUrlForProtocol, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
providerDisplayIcon, providerGlobalBaseUrlForProbe, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions,
translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo,
useRef, useState, X, isPlainRecord
@ -111,25 +111,24 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
</div>
<div className="divide-y divide-border/60">
<AnimatePresence initial={false}>
{visibleProviders.map(({ provider, index }) => {
const itemKey = providerListItemKey(provider, index);
const expanded = expandedProviders.has(itemKey);
{visibleProviders.map(({ provider, index }) => {
const itemKey = providerListItemKey(provider, index);
const expanded = expandedProviders.has(itemKey);
const providerAccountSnapshots = accountSnapshotsByProvider.get(provider.name) ?? [];
const providerIconUrl = providerDisplayIcon(provider);
return (
<AnimatedListItem key={itemKey}>
<div
className="grid min-h-[58px] cursor-pointer grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(160px,0.7fr)_minmax(150px,0.65fr)_80px_84px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
onClick={() => toggleProvider(provider, index)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleProvider(provider, index);
}
}}
role="button"
tabIndex={0}
>
return (
<AnimatedListItem key={itemKey}>
<div
className="grid min-h-[58px] cursor-pointer grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(160px,0.7fr)_minmax(150px,0.65fr)_80px_84px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
onClick={() => toggleProvider(provider, index)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleProvider(provider, index);
}
}}
role="button"
tabIndex={0}
>
<div className="flex min-w-0 items-center gap-2">
<button
aria-expanded={expanded}
@ -144,7 +143,6 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
<ProviderPresetIcon className="h-8 w-8 rounded-md" iconUrl={providerIconUrl} />
<div className="min-w-0">
<div className="truncate text-[12px] font-semibold">{provider.name || t("Unnamed")}</div>
</div>
@ -610,6 +608,12 @@ export function ProviderDeepLinkDialog({
</div>
</div>
) : null}
{showExternalProviderWarnings ? (
<div className="flex items-start gap-2 rounded-md border border-border bg-background px-3 py-2 text-[11px] leading-4 text-muted-foreground">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")}</span>
</div>
) : null}
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border bg-background px-3 py-2.5">
<div className="relative shrink-0">
<ProviderPresetIcon className="h-10 w-10 rounded-md" iconUrl={providerIconUrl} preset={providerPreset} />
@ -1192,7 +1196,6 @@ function LocalAgentProviderImportPanel({
icon: result.provider.icon ?? "",
modelDescriptions: result.provider.modelDescriptions,
modelDisplayNames: result.provider.modelDisplayNames,
modelMetadata: result.provider.modelMetadata,
modelSearch: "",
modelsText: result.provider.models.join("\n"),
name: result.provider.name?.trim() || inferProviderNameFromBaseUrl(result.provider.baseUrl),
@ -1361,11 +1364,8 @@ export function AddProviderForm({
const customEndpoint = draft.presetId === customProviderPresetId;
const importMode = Boolean(importProvider);
const showBaseUrl = customEndpoint || mode === "edit";
const selectedDisplayProtocols = uniqueProviderProtocols(draft.selectedProtocols);
const detectedProtocol = selectedDisplayProtocols.length === 1
? selectedDisplayProtocols[0]
: probe?.detectedProtocol ?? draft.protocol;
const detectedBaseUrl = providerCapabilityBaseUrlForProtocol(draft.baseUrl, detectedProtocol, probe);
const detectedProtocol = probe?.detectedProtocol ?? draft.protocol;
const detectedBaseUrl = probe?.normalizedBaseUrl || draft.baseUrl;
const safetyIssue = providerDraftSafetyIssue(draft, detectedBaseUrl);
const localAgentImport = draft.providerPlugins.length > 0;
const providerPresetOptions = [
@ -1462,7 +1462,6 @@ export function AddProviderForm({
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelMetadata: undefined,
modelSearch: "",
presetId,
providerPlugins: [],
@ -1479,7 +1478,6 @@ export function AddProviderForm({
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelMetadata: undefined,
modelSearch: "",
presetId,
providerPlugins: [],
@ -1500,7 +1498,6 @@ export function AddProviderForm({
icon: "",
modelDescriptions: undefined,
modelDisplayNames: preset?.defaultModelDisplayNames,
modelMetadata: undefined,
modelSearch: "",
modelsText: draft.modelsText.trim() || preset?.defaultModels?.join("\n") || "",
name: mode === "add" && preset && generatedName ? uniqueProviderName(providers, t(preset.name)) : draft.name,
@ -1569,6 +1566,10 @@ export function AddProviderForm({
) : null}
<Field className="sm:col-span-2" label={t("API key")}>
<Input type="password" value={draft.apiKey} onChange={(event) => onChange({ apiKey: event.target.value }, true)} />
<div className="flex items-start gap-1.5 text-[11px] leading-4 text-muted-foreground">
<CircleAlert className="mt-0.5 h-3 w-3 shrink-0" />
<span>{t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")}</span>
</div>
</Field>
{safetyIssue ? (
<div className="sm:col-span-2 flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px] leading-5 text-amber-900 dark:text-amber-100">
@ -1694,6 +1695,17 @@ export function AddProviderForm({
{advancedOpen ? (
<AnimatedDisclosure className="sm:col-span-2" key="provider-advanced">
<div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-muted/20 p-3 sm:grid-cols-2">
{selectedPreset && !customEndpoint && mode === "add" ? (
<Field className="sm:col-span-2" label={t("API endpoint")}>
<Input value={draft.baseUrl} onChange={(event) => onChange({ baseUrl: event.target.value }, true)} />
</Field>
) : null}
<Field label={t("Detected compatibility")}>
<Input readOnly value={translatedProviderProtocolLabel(detectedProtocol, t)} />
</Field>
<Field label={t("Detected endpoint")}>
<Input readOnly value={detectedBaseUrl} />
</Field>
<ProviderCredentialSettings
draft={draft}
onChange={onChange}
@ -1709,7 +1721,6 @@ export function AddProviderForm({
{protocolProbeRows.length ? (
<div className="space-y-1.5">
{protocolProbeRows.map((item) => {
const available = item.supported;
const selectable = item.supported && selectableProtocols.includes(item.protocol);
const checked = selectable && draft.selectedProtocols.includes(item.protocol);
const itemKey = `${item.protocol}-${item.endpoint}`;
@ -1731,8 +1742,8 @@ export function AddProviderForm({
}}
/>
<span className="truncate font-medium">{translatedProviderProtocolLabel(item.protocol, t)}</span>
<span className={cn("inline-flex min-w-0 items-center justify-end gap-1.5", available ? "text-emerald-600 dark:text-emerald-300" : "text-muted-foreground")}>
<span className="truncate">{available ? t("Available") : t("Unavailable")}</span>
<span className={cn("inline-flex min-w-0 items-center justify-end gap-1.5", item.supported ? "text-emerald-600 dark:text-emerald-300" : "text-muted-foreground")}>
<span className="truncate">{item.supported ? t("Available") : t("Unavailable")}</span>
<button
aria-label={t("Protocol detection details")}
aria-pressed={protocolProbeDetails?.key === itemKey}
@ -2076,7 +2087,6 @@ function ProviderUsageSettings({
const [testError, setTestError] = useState("");
const [newApiUserId, setNewApiUserId] = useState("");
const modeOptions = translateOptions(providerAccountModeOptions, t);
const globalBaseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, draft.selectedProtocols);
const showNewApiUserBalanceTemplate = probe?.detectedProvider === "new-api" ||
draft.accountConnectorsText.includes("new-api-key-usage") ||
draft.accountConnectorsText.includes("new-api-user-self");
@ -2098,7 +2108,7 @@ function ProviderUsageSettings({
}
const safetyIssue = providerAccountConnectorApiKeySafetyIssue(connector, {
apiKey: draft.apiKey,
baseUrl: globalBaseUrl,
baseUrl: (probe?.normalizedBaseUrl || draft.baseUrl).trim(),
providerName: draft.name.trim(),
providerPresetId: draft.presetId
});
@ -2133,7 +2143,7 @@ function ProviderUsageSettings({
onChange({
accountConnectorsText: providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
draft.accountConnectorsText,
globalBaseUrl,
probe?.normalizedBaseUrl || draft.baseUrl,
newApiUserId
)
});
@ -2506,7 +2516,7 @@ export function AddProviderDialog({
</Dialog>
{checkConfirmOpen ? (
<Dialog className="z-[110]" onOpenChange={(open) => !open && !checkConfirmBusy && setCheckConfirmOpen(false)}>
<Dialog className="z-[60]" onOpenChange={(open) => !open && !checkConfirmBusy && setCheckConfirmOpen(false)}>
<DialogContent className="max-w-[520px]">
<DialogHeader>
<div className="min-w-0">

View file

@ -636,7 +636,7 @@ function ToolHubMcpServerDialog({
const stdioMessageModeOptions = translateOptions(mcpStdioMessageModeOptions, t);
return (
<Dialog className="z-[110]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<Dialog className="z-[80]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<DialogContent className="max-w-[760px]">
<DialogHeader>
<div className="min-w-0">
@ -755,7 +755,7 @@ function ToolHubMcpJsonDialog({
const t = useAppText();
return (
<Dialog className="z-[110]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<Dialog className="z-[80]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<DialogContent className="max-w-[760px]">
<DialogHeader>
<div className="min-w-0">

View file

@ -605,7 +605,7 @@ function CustomMcpToolDialog({
}
return (
<Dialog className="z-[110]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<Dialog className="z-[80]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
<DialogContent className="max-w-[760px]">
<DialogHeader>
<div className="min-w-0">

View file

@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import {
closestCenter,
DndContext,
@ -571,7 +571,7 @@ export {
providerIdentitySafetyIssue, getProviderPresets, setProviderPresets, standardProviderAccountConfig, normalizeProviderBaseUrl, providerUrlWithDefaultScheme
};
export type {
HTMLAttributes, ReactDragEvent, ReactPointerEvent, ReactNode, CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent,
HTMLAttributes, ReactPointerEvent, ReactNode, CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent,
LucideIcon, AgentAnalysisFilter, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentAnalysisTrace, AgentAnalysisTracePayloadFullResult, AgentAnalysisTracePayloadRequest, AgentAnalysisTraceRun, AgentAnalysisTraceRunKind, AgentKind, AppConfig, AppInfo, AppSaveConfigOptions, AppUpdateStatus, ApiKeyConfig,
ApiKeyLimitConfig, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginCancelResult, BotGatewayQrLoginStartRequest, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitRequest, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewayRuntimeConfig, BotGatewaySavedConfig, BotHandoffScanTarget, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig,
GatewayMcpServerTransport, GatewayMcpStdioMessageMode, GatewayMcpToolInfo, GatewayStatus, OverviewMetricKind, OverviewWidgetConfig, OverviewWidgetSize, OverviewWidgetType,

View file

@ -278,7 +278,6 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Ready": "Ready",
"Select at least one protocol.": "Select at least one protocol.",
"Service": "Service",
"Service failed to start": "Service failed to start",
"Scanning local agent logins": "Scanning local agent logins",
"Start": "Start",
"Start check": "Start check",
@ -430,9 +429,6 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Arguments": "Arguments",
"Working directory": "Working directory",
"API key env": "API key env",
"CLAUDE_APP_PATH": "CLAUDE_APP_PATH",
"CHATGPT_APP_PATH": "CHATGPT_APP_PATH",
"Drop the app here or paste the executable path": "Drop the app here or paste the executable path",
"Tool name": "Tool name",
"Third-party tool environment": "Third-party tool environment",
"Environment variables": "Environment variables",
@ -587,6 +583,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"API key created": "API 密钥已创建",
"API keys database": "API 密钥数据库",
"Copy this key now. It may not be shown again.": "请现在复制保存这个密钥,之后可能不会再次完整显示。",
"Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.": "只输入由当前端点签发的 API 密钥。官方供应商密钥只能用于官方端点。",
"Add": "添加",
"Add env variable": "添加环境变量",
"Add header": "添加请求头",
@ -1314,9 +1311,6 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Arguments": "参数",
"Working directory": "工作目录",
"API key env": "API Key 环境变量",
"CLAUDE_APP_PATH": "CLAUDE_APP_PATH",
"CHATGPT_APP_PATH": "CHATGPT_APP_PATH",
"Drop the app here or paste the executable path": "拖动应用到这里,或粘贴可执行文件路径",
"Third-party tool environment": "三方工具环境变量",
"Environment variable keys are required when values are set.": "设置变量值时必须填写变量名。",
"MCP tool discovery is available in the Electron app.": "MCP 工具发现仅在 Electron App 中可用。",
@ -1656,7 +1650,6 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Select a scanned target": "选择扫描到的目标",
"Selected": "已选择",
"Service": "服务",
"Service failed to start": "服务启动失败",
"Service control is available in the Electron app.": "服务控制仅在 Electron App 中可用。",
"Scanning targets": "正在扫描目标",
"Service status": "服务状态",

View file

@ -46,13 +46,11 @@ import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico";
import claudeapiProviderIconUrl from "@/assets/provider-icons/claudeapi.png";
import code0ProviderIconUrl from "@/assets/provider-icons/code0.png";
import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico";
import fennoProviderIconUrl from "@/assets/provider-icons/fenno.jpg";
import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg";
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 qiniuAiProviderIconUrl from "@/assets/provider-icons/qiniu-ai.png";
import runapiProviderIconUrl from "@/assets/provider-icons/runapi.jpg";
import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png";
import teamorouterProviderIconUrl from "@/assets/provider-icons/teamorouter.png";
@ -326,7 +324,6 @@ export const providerPresetIconUrls: Record<string, string> = {
claudeapi: claudeapiProviderIconUrl,
code0: code0ProviderIconUrl,
deepseek: deepseekProviderIconUrl,
fenno: fennoProviderIconUrl,
gemini: geminiProviderIconUrl,
"kimi-coding": moonshotProviderIconUrl,
mistral: mistralProviderIconUrl,
@ -334,7 +331,6 @@ export const providerPresetIconUrls: Record<string, string> = {
"moonshot-global": moonshotProviderIconUrl,
openai: openaiProviderIconUrl,
openrouter: openrouterProviderIconUrl,
"qiniu-ai": qiniuAiProviderIconUrl,
runapi: runapiProviderIconUrl,
siliconflow: siliconflowProviderIconUrl,
teamorouter: teamorouterProviderIconUrl,

View file

@ -754,7 +754,6 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code"
const surface = agent === "zcode" ? "app" : "cli";
return {
agent,
appPath: "",
...createBotGatewayDraft(),
configFile: defaultCodexConfigFile(agent),
envRows: agent === "claude-code" ? keyValueRowsFromRecord(claudeCodeProfileEnv()) : [],
@ -779,7 +778,6 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
return {
...createProfileDraft("claude-code", profile.name),
...botDraft,
appPath: profile.appPath ?? "",
botConfigId,
botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled),
envRows: keyValueRowsFromRecord(claudeCodeProfileEnv(profile.env ?? {})),
@ -794,7 +792,6 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
return {
...createProfileDraft(profile.agent, profile.name),
...botDraft,
appPath: profile.appPath ?? "",
botConfigId,
botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled),
configFile: profile.configFile ?? defaultCodexConfigFile(profile.agent),
@ -866,7 +863,6 @@ export function profileConfigFromDraft(
: {};
return normalizeProfileItem({
agent: draft.agent,
appPath: draft.appPath,
...botGateway,
configFile: draft.configFile,
enabled: existingProfile?.enabled ?? true,
@ -1366,10 +1362,6 @@ export function profileSummaryItems(
const envSummaryItems = envCount > 0
? [{ label: t("Environment variables"), value: String(envCount) }]
: [];
const appPath = profile.appPath?.trim() || "";
const appPathSummaryItems = appPath && surface !== "cli" && profile.agent !== "zcode"
? [{ label: t(profile.agent === "claude-code" ? "CLAUDE_APP_PATH" : "CHATGPT_APP_PATH"), value: appPath }]
: [];
const savedBot = profile.botConfigId
? config.botConfigs.find((item) => item.id === profile.botConfigId)
: undefined;
@ -1408,7 +1400,6 @@ export function profileSummaryItems(
: t("Keep Claude Code default")
},
...botSummaryItems,
...appPathSummaryItems,
...envSummaryItems
];
}
@ -1417,7 +1408,6 @@ export function profileSummaryItems(
{ label: t("Model"), value: modelValue },
{ label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" },
...(profile.agent === "zcode" ? [] : [{ label: t("Show all sessions"), value: profile.showAllSessions ? t("Enabled") : t("Disabled") }]),
...appPathSummaryItems,
...botSummaryItems,
...envSummaryItems
];
@ -1433,10 +1423,8 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
const botGateway = surface !== "cli" ? normalizeBotGatewayRuntimeConfig(profile.botGateway) : undefined;
const botConfigId = surface !== "cli" ? stringValue(profile.botConfigId) : "";
if (agent === "claude-code") {
const appPath = profile.appPath?.trim() || "";
return {
agent: "claude-code",
...(surface !== "cli" && appPath ? { appPath } : {}),
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
enabled: profile.enabled,
@ -1452,7 +1440,6 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
}
return {
agent: normalizeCodexCompatibleAgent(agent),
...(surface !== "cli" && agent !== "zcode" && profile.appPath?.trim() ? { appPath: profile.appPath.trim() } : {}),
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
cliMiddleware: true,
@ -1531,27 +1518,6 @@ export function normalizeUnknownProfileItem(value: Record<string, unknown>, inde
}
return normalizeProfileItem({
agent,
appPath: typeof value.appPath === "string"
? value.appPath
: typeof value.app_path === "string"
? value.app_path
: typeof value.appExecutablePath === "string"
? value.appExecutablePath
: typeof value.app_executable_path === "string"
? value.app_executable_path
: agent === "claude-code" && typeof value.claudeAppPath === "string"
? value.claudeAppPath
: agent === "claude-code" && typeof value.claude_app_path === "string"
? value.claude_app_path
: agent === "codex" && typeof value.chatgptAppPath === "string"
? value.chatgptAppPath
: agent === "codex" && typeof value.chatgpt_app_path === "string"
? value.chatgpt_app_path
: agent === "codex" && typeof value.codexAppPath === "string"
? value.codexAppPath
: agent === "codex" && typeof value.codex_app_path === "string"
? value.codex_app_path
: undefined,
botConfigId: typeof value.botConfigId === "string" ? value.botConfigId : typeof value.bot_config_id === "string" ? value.bot_config_id : undefined,
botGateway: normalizeBotGatewayRuntimeConfig(value.botGateway ?? value.bot_gateway ?? value.bot),
cliMiddleware: typeof value.cliMiddleware === "boolean" ? value.cliMiddleware : undefined,

View file

@ -190,7 +190,6 @@ import type {
ProviderCredentialConfig,
ProviderDeepLinkPayload,
ProviderDeepLinkRequest,
ProviderModelMetadata,
ProfileConfig,
ProfileOpenSurface,
CodexProfileConfigFormat,
@ -773,19 +772,8 @@ export function providerDeepLinkDisplayIcon(payload: ProviderDeepLinkPayload): s
return presetIcon || payload.icon?.trim() || "";
}
export function providerDisplayIcon(provider: GatewayProviderConfig): string {
const icon = provider.icon?.trim();
if (icon) {
return icon;
}
const preset = findProviderPresetByBaseUrl(providerBaseUrl(provider));
return preset ? providerPresetIconUrls[preset.id] ?? "" : "";
}
export type ProviderDeepLinkCatalogModelsResolution = {
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
models: string[];
};
@ -806,7 +794,6 @@ export async function resolveProviderDeepLinkCatalogModels(payload: ProviderDeep
});
return {
modelDisplayNames: mergeModelDisplayNames(result.modelDisplayNames),
modelMetadata: modelMetadataForModels(result.modelMetadata, result.models),
models: mergeProviderModelLists(result.models)
};
} catch {
@ -875,7 +862,6 @@ export function createProviderDraftFromDeepLinkPayload(
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), payload.modelDisplayNames),
models
),
modelMetadata: modelMetadataForModels(payload.modelMetadata, models),
modelSearch: "",
modelsText: models.join("\n"),
name: uniqueProviderName(providers, baseName),
@ -892,8 +878,8 @@ export function createProviderConfigFromDeepLink(
providers: GatewayProviderConfig[],
probe: GatewayProviderProbeResult | undefined
): GatewayProviderConfig {
const protocol = payload.protocol ?? probe?.detectedProtocol ?? "openai_chat_completions";
const baseUrl = providerGlobalBaseUrlForProbe(payload.baseUrl, probe, [protocol]);
const protocol = probe?.detectedProtocol ?? payload.protocol ?? "openai_chat_completions";
const baseUrl = probe?.normalizedBaseUrl || payload.baseUrl;
const apiKey = payload.apiKey?.trim() || "";
const models = apiKey && probe?.models.length
? mergeProviderModelLists(probe.models)
@ -924,17 +910,19 @@ export function createProviderConfigFromDeepLink(
throw new Error(accountKeySafetyIssue.message);
}
const capabilities = providerCapabilitiesForProtocols(payload.baseUrl, [protocol], probe);
const capabilities = mergeProviderCapabilities(
probe?.capabilities ?? [],
protocol && baseUrl ? [{ baseUrl, source: probe?.detectedProtocol ? "detected" : "preset", type: protocol }] : []
);
return {
account: cloneProviderAccountConfig(account),
api_base_url: normalizeProviderBaseUrl(baseUrl),
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
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),
modelMetadata: modelMetadataForModels(mergeModelMetadata(payload.modelMetadata, probe?.modelMetadata), models),
models,
name,
type: protocol
@ -961,7 +949,6 @@ export function createProviderDraft(providers: GatewayProviderConfig[]): AddProv
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelMetadata: undefined,
modelSearch: "",
modelsText: "",
name: uniqueProviderName(providers),
@ -989,7 +976,6 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), provider.modelDisplayNames),
provider.models
),
modelMetadata: modelMetadataForModels(provider.modelMetadata, provider.models),
modelSearch: "",
modelsText: provider.models.join("\n"),
name: provider.name,
@ -1503,13 +1489,8 @@ export function providerUsageFieldPatch(target: ProviderUsageFieldTarget, path:
export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, probe: GatewayProviderProbeResult | undefined): string {
const providerName = draft.name.trim();
const selectedProtocols = uniqueProviderProtocols(draft.selectedProtocols);
const protocol = selectedProtocols.includes(draft.protocol)
? draft.protocol
: selectedProtocols.length === 1
? selectedProtocols[0]
: probe?.detectedProtocol ?? draft.protocol;
const baseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, selectedProtocols.length > 0 ? selectedProtocols : [protocol]);
const baseUrl = (probe?.normalizedBaseUrl || draft.baseUrl).trim();
const protocol = probe?.detectedProtocol ?? draft.protocol;
const models = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
if (!providerName || !baseUrl) {
return "Provider name and Base URL are required.";
@ -1551,14 +1532,12 @@ export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, prob
const modelDescriptions = modelDescriptionsForModels(draft.modelDescriptions, models);
const modelDisplayNames = modelDisplayNamesForModels(draft.modelDisplayNames, models);
const modelMetadata = modelMetadataForModels(draft.modelMetadata, models);
const payload: ProviderDeepLinkPayload = {
...(account ? { account } : {}),
baseUrl,
...(draft.icon.trim() ? { icon: draft.icon.trim() } : {}),
...(modelDescriptions ? { modelDescriptions } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
...(modelMetadata ? { modelMetadata } : {}),
models,
name: providerName,
protocol
@ -1716,11 +1695,9 @@ export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeC
const preset = findProviderPreset(draft.presetId);
const protocols = providerProtocolOptions.map((option) => option.value);
if (preset) {
const probeAllProtocols = preset.endpoints.length === 1;
return preset.endpoints.map((endpoint) => ({
...endpoint,
declaredProtocols: endpoint.protocols,
protocols: probeAllProtocols ? protocols : endpoint.protocols,
protocols,
source: "preset"
}));
}
@ -1809,10 +1786,7 @@ export function providerSelectableProtocolsFromProbe(probe: GatewayProviderProbe
return [];
}
return uniqueProviderProtocols([
...probe.protocols.filter((item) => item.supported).map((item) => item.protocol),
...(probe.capabilities ?? []).map((capability) => capability.type)
]);
return uniqueProviderProtocols(probe.protocols.filter((item) => item.supported).map((item) => item.protocol));
}
export function selectedProviderProtocolsFromCapabilities(
@ -1903,83 +1877,18 @@ export function mergeProviderCapabilities(...groups: GatewayProviderCapability[]
return capabilities;
}
export function providerGlobalBaseUrlForProbe(
inputBaseUrl: string,
_probe: GatewayProviderProbeResult | undefined,
_protocols: GatewayProviderProtocol[] = []
): string {
return normalizeProviderBaseUrl(inputBaseUrl) || inputBaseUrl.trim();
}
export function providerCapabilityBaseUrlForProtocol(
inputBaseUrl: string,
protocol: GatewayProviderProtocol,
probe: GatewayProviderProbeResult | undefined
): string {
const detectedCapability = (probe?.capabilities ?? []).find((capability) =>
capability.type === protocol && capability.baseUrl.trim()
);
if (detectedCapability) {
return detectedCapability.baseUrl.trim();
}
if (probe?.detectedProtocol === protocol && probe.normalizedBaseUrl.trim()) {
return probe.normalizedBaseUrl.trim();
}
return normalizeProviderBaseUrl(inputBaseUrl, protocol) || normalizeProviderBaseUrl(inputBaseUrl) || inputBaseUrl.trim();
}
export function providerCapabilitiesForProtocols(
inputBaseUrl: string,
protocols: GatewayProviderProtocol[],
probe: GatewayProviderProbeResult | undefined,
presetCapabilities: GatewayProviderCapability[] = []
): GatewayProviderCapability[] {
const detectedCapabilities = probe?.capabilities ?? [];
const selectedCapabilities = uniqueProviderProtocols(protocols)
.map((type) => {
const capability =
detectedCapabilities.find((item) => item.type === type && item.baseUrl.trim()) ??
presetCapabilities.find((item) => item.type === type && item.baseUrl.trim());
if (capability) {
return capability;
}
const baseUrl = providerCapabilityBaseUrlForProtocol(inputBaseUrl, type, probe);
return baseUrl
? {
baseUrl,
source: probe?.detectedProtocol ? ("detected" as const) : ("preset" as const),
type
}
: undefined;
})
.filter((item): item is GatewayProviderCapability => Boolean(item));
return mergeProviderCapabilities(selectedCapabilities);
}
export function applyProviderProbeResult(draft: AddProviderDraft, probe: GatewayProviderProbeResult): AddProviderDraft {
const detectedProtocol = probe.detectedProtocol ?? draft.protocol;
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, detectedProtocol, draft.presetId);
const protocol = selectedProtocols.includes(draft.protocol)
? draft.protocol
: selectedProtocols.includes(detectedProtocol)
? detectedProtocol
: selectedProtocols[0] ?? detectedProtocol;
const protocol = probe.detectedProtocol ?? draft.protocol;
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
const modelMetadata = mergeModelMetadata(draft.modelMetadata, probe.modelMetadata);
const accountDraft = providerProbeAccountDraftPatch(draft, probe.account);
const baseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, selectedProtocols);
if (probe.models.length === 0) {
return {
...draft,
...accountDraft,
baseUrl,
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
modelDisplayNames,
modelMetadata,
protocol,
selectedModels: mergeProviderModelLists(draft.selectedModels),
selectedProtocols
@ -2001,9 +1910,8 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
return {
...draft,
...accountDraft,
baseUrl,
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
modelDisplayNames,
modelMetadata,
protocol,
modelsText: customModels.join("\n"),
selectedModels: nextSelectedModels,
@ -2038,33 +1946,6 @@ export function mergeModelDisplayNames(
return Object.keys(merged).length > 0 ? merged : undefined;
}
export function mergeModelMetadata(
...groups: Array<Record<string, ProviderModelMetadata> | undefined>
): Record<string, ProviderModelMetadata> | undefined {
const merged: Record<string, ProviderModelMetadata> = {};
for (const group of groups) {
for (const [rawModel, metadata] of Object.entries(group ?? {})) {
const model = rawModel.trim();
if (!model || !metadata || typeof metadata !== "object") {
continue;
}
merged[model] = metadata;
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
export function modelMetadataForModels(
value: Record<string, ProviderModelMetadata> | undefined,
models: string[]
): Record<string, ProviderModelMetadata> | undefined {
const modelIds = new Set(models);
const entries = Object.entries(value ?? {})
.map(([rawModel, metadata]) => [rawModel.trim(), metadata] as const)
.filter(([model, metadata]) => model && modelIds.has(model) && metadata && typeof metadata === "object");
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function modelDisplayNamesForModels(
value: Record<string, string> | undefined,
models: string[]

View file

@ -188,7 +188,6 @@ import type {
ProviderCredentialConfig,
ProviderDeepLinkPayload,
ProviderDeepLinkRequest,
ProviderModelMetadata,
ProfileConfig,
ProfileOpenSurface,
CodexProfileConfigFormat,
@ -425,7 +424,6 @@ export type AddProviderDraft = {
icon: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
modelMetadata?: Record<string, ProviderModelMetadata>;
modelSearch: string;
modelsText: string;
name: string;
@ -485,7 +483,6 @@ export type AddApiKeyDraft = {
export type AddProfileDraft = {
agent: ProfileConfig["agent"];
appPath: string;
botConfigId: string;
botAuthFields: Record<string, string>;
botAuthType: string;

View file

@ -155,7 +155,7 @@ export function formatUsdCost(value: number | undefined): string {
return new Intl.NumberFormat(undefined, {
currency: "USD",
maximumFractionDigits: normalized >= 100 ? 0 : 2,
minimumFractionDigits: normalized >= 100 ? 0 : 2,
minimumFractionDigits: 2,
style: "currency"
}).format(normalized);
}

View file

@ -609,7 +609,7 @@ export function formatUsdCost(value: number | undefined): string {
return new Intl.NumberFormat(undefined, {
currency: "USD",
maximumFractionDigits: normalized >= 100 ? 0 : 2,
minimumFractionDigits: normalized >= 100 ? 0 : 2,
minimumFractionDigits: 2,
style: "currency"
}).format(normalized);
}

View file

@ -40,8 +40,6 @@ import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportRequest,
LocalAgentProviderImportResult,
LocalAgentProviderProbeRequest,
LocalAgentProviderProbeResult,
PluginDirectorySelection,
PluginMarketplaceEntry,
ProfileOpenCommandResult,
@ -95,7 +93,6 @@ declare global {
getAgentTracePayload: (request: AgentAnalysisTracePayloadRequest) => Promise<AgentAnalysisTracePayloadFullResult>;
getAppInfo: () => Promise<AppInfo>;
getConfig: () => Promise<AppConfig>;
getFilePath?: (file: File) => string;
getGatewayStatus: () => Promise<GatewayStatus>;
getLocalAgentProviderCandidates: () => Promise<LocalAgentProviderCandidate[]>;
getOnboardingFinished: () => Promise<boolean>;
@ -121,7 +118,6 @@ declare global {
openExternal: (url: string) => Promise<void>;
openProfile: (request: ProfileOpenRequest) => Promise<ProfileOpenResult>;
prepareImageExportTarget?: (request: AppImageExportTargetRequest) => Promise<AppImageExportTargetResult>;
probeLocalAgentProvider?: (request: LocalAgentProviderProbeRequest) => Promise<LocalAgentProviderProbeResult>;
probeProviderCandidates: (request: GatewayProviderProbeCandidatesRequest) => Promise<GatewayProviderProbeCandidateResult | undefined>;
probeProvider: (request: GatewayProviderProbeRequest) => Promise<GatewayProviderProbeResult>;
quitApp: () => Promise<void>;

View file

@ -148,7 +148,6 @@ const webClientBridge: CcrApi = {
window.open(normalizeExternalHttpUrl(url), "_blank", "noopener,noreferrer");
},
openProfile: (request) => rpc("openProfile", [request]) as ReturnType<CcrApi["openProfile"]>,
probeLocalAgentProvider: (request) => rpc("probeLocalAgentProvider", [request]) as ReturnType<NonNullable<CcrApi["probeLocalAgentProvider"]>>,
probeProvider: (request) => rpc("probeProvider", [request]) as ReturnType<CcrApi["probeProvider"]>,
probeProviderCandidates: (request) => rpc("probeProviderCandidates", [request]) as ReturnType<CcrApi["probeProviderCandidates"]>,
quitApp: () => rpc("quitApp") as ReturnType<CcrApi["quitApp"]>,

View file

@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { claudeAppLaunchCommand, findInstalledClaudeAppExecutable, normalizeClaudeAppCandidate } from "../../packages/core/src/agents/claude-app/launch.ts";
import { claudeAppLaunchCommand } from "../../packages/core/src/agents/claude-app/launch.ts";
test("claudeAppLaunchCommand opens macOS app bundles through LaunchServices", (t) => {
const tempDir = mkdtempForTest();
@ -49,88 +49,6 @@ test("claudeAppLaunchCommand opens macOS app bundles through LaunchServices", (t
assert.ok(launch.args.includes(`--user-data-dir=${path.join(tempDir, "profile data")}`));
});
test("Claude App discovery rejects generic Claude Code CLI shims", (t) => {
const tempDir = mkdtempForTest();
t.after(() => rmSync(tempDir, { force: true, recursive: true }));
const windowsCliShim = path.join(tempDir, ".local", "bin", "claude.exe");
mkdirSync(path.dirname(windowsCliShim), { recursive: true });
writeFileSync(windowsCliShim, "");
withPlatform("win32", () => {
assert.equal(normalizeClaudeAppCandidate(windowsCliShim), undefined);
});
const linuxCliShim = path.join(tempDir, "usr", "bin", "claude");
mkdirSync(path.dirname(linuxCliShim), { recursive: true });
writeFileSync(linuxCliShim, "");
withPlatform("linux", () => {
assert.equal(normalizeClaudeAppCandidate(linuxCliShim), undefined);
});
});
test("Claude App discovery accepts generic Electron desktop app executables", (t) => {
const tempDir = mkdtempForTest();
t.after(() => rmSync(tempDir, { force: true, recursive: true }));
const windowsApp = path.join(tempDir, "Programs", "Claude", "Claude.exe");
mkdirSync(path.join(path.dirname(windowsApp), "resources", "app"), { recursive: true });
writeFileSync(windowsApp, "");
writeFileSync(path.join(path.dirname(windowsApp), "resources", "app", "package.json"), "{}");
withPlatform("win32", () => {
assert.equal(normalizeClaudeAppCandidate(windowsApp), windowsApp);
});
const linuxApp = path.join(tempDir, "opt", "Claude", "claude");
mkdirSync(path.join(path.dirname(linuxApp), "resources", "app"), { recursive: true });
writeFileSync(linuxApp, "");
writeFileSync(path.join(path.dirname(linuxApp), "resources", "app", "package.json"), "{}");
withPlatform("linux", () => {
assert.equal(normalizeClaudeAppCandidate(linuxApp), linuxApp);
});
});
test("Claude App profile appPath overrides process env discovery", (t) => {
const tempDir = mkdtempForTest();
const previous = process.env.CLAUDE_APP_PATH;
t.after(() => {
if (previous === undefined) {
delete process.env.CLAUDE_APP_PATH;
} else {
process.env.CLAUDE_APP_PATH = previous;
}
rmSync(tempDir, { force: true, recursive: true });
});
const envCliShim = path.join(tempDir, ".local", "bin", "claude");
mkdirSync(path.dirname(envCliShim), { recursive: true });
writeFileSync(envCliShim, "");
process.env.CLAUDE_APP_PATH = envCliShim;
const profileApp = path.join(tempDir, "opt", "Claude", "claude");
mkdirSync(path.join(path.dirname(profileApp), "resources", "app"), { recursive: true });
writeFileSync(profileApp, "");
writeFileSync(path.join(path.dirname(profileApp), "resources", "app", "package.json"), "{}");
withPlatform("linux", () => {
const result = findInstalledClaudeAppExecutable(profileApp);
assert.equal(result.executable, profileApp);
assert.equal(result.checked[0], profileApp);
});
});
function mkdtempForTest() {
return mkdtempSync(path.join(os.tmpdir(), "ccr-claude-app-launch-"));
}
function withPlatform(platform, callback) {
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", {
configurable: true,
value: platform
});
try {
return callback();
} finally {
Object.defineProperty(process, "platform", descriptor);
}
}

View file

@ -1,16 +1,11 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
codexDesktopAppName,
findInstalledCodexAppExecutable,
removeLegacyCodexVirtualAuthMarker,
writeCodexCompatibleAppModelCatalog
} from "../../packages/core/src/agents/codex/app-launch.ts";
import { writeCodexCompatibleAppModelCatalog } from "../../packages/core/src/agents/codex/app-launch.ts";
test("ChatGPT model catalog write includes patch bridge capabilities", () => {
test("Codex App model catalog write includes patch bridge capabilities", () => {
const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-app-catalog-"));
try {
const config = {
@ -57,106 +52,3 @@ test("ChatGPT model catalog write includes patch bridge capabilities", () => {
rmSync(configDir, { force: true, recursive: true });
}
});
test("ChatGPT desktop app path override discovers the renamed executable", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-chatgpt-app-"));
const previous = process.env.CHATGPT_APP_PATH;
try {
let configuredPath;
let expectedExecutable;
if (process.platform === "darwin") {
configuredPath = path.join(root, "ChatGPT.app");
const macosDir = path.join(configuredPath, "Contents", "MacOS");
mkdirSync(macosDir, { recursive: true });
expectedExecutable = path.join(macosDir, "ChatGPT");
writeFileSync(expectedExecutable, "");
writeFileSync(
path.join(configuredPath, "Contents", "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>ChatGPT</string></dict></plist>"
);
} else {
expectedExecutable = path.join(root, process.platform === "win32" ? "ChatGPT.exe" : "chatgpt");
configuredPath = expectedExecutable;
writeFileSync(expectedExecutable, "");
}
process.env.CHATGPT_APP_PATH = configuredPath;
const result = findInstalledCodexAppExecutable();
assert.equal(codexDesktopAppName, "ChatGPT");
assert.equal(result.executable, expectedExecutable);
assert.equal(result.checked[0], configuredPath);
} finally {
if (previous === undefined) {
delete process.env.CHATGPT_APP_PATH;
} else {
process.env.CHATGPT_APP_PATH = previous;
}
rmSync(root, { force: true, recursive: true });
}
});
test("ChatGPT profile appPath overrides process env discovery", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-chatgpt-profile-app-"));
const previous = process.env.CHATGPT_APP_PATH;
try {
const envExecutable = path.join(root, "env", "ChatGPT");
mkdirSync(path.dirname(envExecutable), { recursive: true });
writeFileSync(envExecutable, "");
process.env.CHATGPT_APP_PATH = envExecutable;
const profileExecutable = path.join(root, "profile", "ChatGPT");
mkdirSync(path.dirname(profileExecutable), { recursive: true });
writeFileSync(profileExecutable, "");
withPlatform("linux", () => {
const result = findInstalledCodexAppExecutable(profileExecutable);
assert.equal(result.executable, profileExecutable);
assert.equal(result.checked[0], profileExecutable);
});
} finally {
if (previous === undefined) {
delete process.env.CHATGPT_APP_PATH;
} else {
process.env.CHATGPT_APP_PATH = previous;
}
rmSync(root, { force: true, recursive: true });
}
});
function withPlatform(platform, callback) {
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", {
configurable: true,
value: platform
});
try {
return callback();
} finally {
Object.defineProperty(process, "platform", descriptor);
}
}
test("ChatGPT migration removes only the exact legacy CCR auth marker", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-chatgpt-auth-migration-"));
const authFile = path.join(root, "auth.json");
try {
writeFileSync(authFile, JSON.stringify({
auth_mode: "apikey",
OPENAI_API_KEY: "ccr-local-profile"
}));
assert.equal(removeLegacyCodexVirtualAuthMarker(root), true);
assert.equal(existsSync(authFile), false);
const realAuth = { auth_mode: "chatgpt", tokens: { access_token: "preserve-me" } };
writeFileSync(authFile, JSON.stringify(realAuth));
assert.equal(removeLegacyCodexVirtualAuthMarker(root), false);
assert.deepEqual(JSON.parse(readFileSync(authFile, "utf8")), realAuth);
const customApiKey = { auth_mode: "apikey", OPENAI_API_KEY: "user-key" };
writeFileSync(authFile, JSON.stringify(customApiKey));
assert.equal(removeLegacyCodexVirtualAuthMarker(root), false);
assert.deepEqual(JSON.parse(readFileSync(authFile, "utf8")), customApiKey);
} finally {
rmSync(root, { force: true, recursive: true });
}
});

View file

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
@ -13,211 +13,6 @@ test("generated Codex CLI middleware runtime is valid JavaScript", () => {
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
});
test("Codex app-server exposes a ChatGPT-shaped workspace identity without credentials", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-virtual-auth-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeCodex = path.join(dir, "fake-codex");
const codexHome = path.join(dir, "codex-home");
mkdirSync(codexHome, { recursive: true });
writeFileSync(fakeCodex, [
"#!/usr/bin/env node",
"const fs = require('node:fs');",
"const path = require('node:path');",
"const readline = require('node:readline');",
"const sawBootstrap = fs.existsSync(path.join(process.env.CODEX_HOME, 'auth.json'));",
"const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });",
"input.on('line', (line) => {",
" const request = JSON.parse(line);",
" const result = request.method === 'probe/auth-bootstrap'",
" ? { sawBootstrap }",
" : request.method === 'account/read'",
" ? { account: { type: 'chatgpt', email: 'real@example.com', planType: 'pro' }, requiresOpenaiAuth: true }",
" : { authMethod: 'chatgpt', authToken: 'real-chatgpt-token', requiresOpenaiAuth: true };",
" process.stdout.write(JSON.stringify({ id: request.id, result }) + '\\n');",
"});",
""
].join("\n"));
chmodSync(fakeCodex, 0o700);
const result = spawnSync(process.execPath, [runtimeFile, "app-server"], {
encoding: "utf8",
env: {
...process.env,
CCR_CODEX_REMOTE_FRONTEND_MODE: "app",
CCR_PROFILE_SCOPE: "ccr",
CCR_REAL_CODEX_CLI_PATH: fakeCodex,
CODEX_HOME: codexHome,
CODEXL_CODEX_WORKSPACE_NAME: "CCR Workspace"
},
input: [
JSON.stringify({ id: 0, method: "probe/auth-bootstrap", params: {} }),
JSON.stringify({ id: 1, method: "getAuthStatus", params: { includeToken: true, refreshToken: false } }),
JSON.stringify({ id: 2, method: "getAuthStatus", params: { includeToken: false, refreshToken: false } }),
JSON.stringify({ id: 3, method: "account/read", params: {} }),
""
].join("\n")
});
assert.equal(result.status, 0, result.stderr);
const responses = result.stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line));
assert.deepEqual(responses[0].result, { sawBootstrap: true });
assert.deepEqual(responses[1].result, {
authMethod: "chatgpt",
authToken: null,
requiresOpenaiAuth: true
});
assert.deepEqual(responses[2].result, {
authMethod: "chatgpt",
requiresOpenaiAuth: true
});
assert.deepEqual(responses[3].result, {
account: { type: "chatgpt", email: "CCR Workspace", planType: "unknown" },
requiresOpenaiAuth: true
});
assert.equal(existsSync(path.join(codexHome, "auth.json")), false);
});
test("Codex app-server reads but never overwrites an existing ChatGPT auth file", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-preserve-auth-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeCodex = path.join(dir, "fake-codex");
const codexHome = path.join(dir, "codex-home");
const authFile = path.join(codexHome, "auth.json");
const token = "header.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2ZpbGUiOnsiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIn0sImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3BsYW5fdHlwZSI6InBsdXMifX0.signature";
const existingAuth = {
auth_mode: "chatgpt",
tokens: { access_token: token, id_token: token, refresh_token: "preserve-me" }
};
mkdirSync(codexHome, { recursive: true });
writeFileSync(authFile, JSON.stringify(existingAuth));
writeFileSync(fakeCodex, [
"#!/usr/bin/env node",
"const readline = require('node:readline');",
"const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });",
"input.on('line', (line) => {",
" const request = JSON.parse(line);",
" const result = request.method === 'account/read'",
" ? { account: null, requiresOpenaiAuth: false }",
" : { authMethod: null, authToken: null, requiresOpenaiAuth: false };",
" process.stdout.write(JSON.stringify({ id: request.id, result }) + '\\n');",
"});",
""
].join("\n"));
chmodSync(fakeCodex, 0o700);
const result = spawnSync(process.execPath, [runtimeFile, "app-server"], {
encoding: "utf8",
env: {
...process.env,
CCR_CODEX_REMOTE_FRONTEND_MODE: "app",
CCR_PROFILE_SCOPE: "ccr",
CCR_REAL_CODEX_CLI_PATH: fakeCodex,
CODEX_HOME: codexHome
},
input: [
JSON.stringify({ id: 1, method: "getAuthStatus", params: { includeToken: true, refreshToken: false } }),
JSON.stringify({ id: 2, method: "account/read", params: {} }),
""
].join("\n")
});
assert.equal(result.status, 0, result.stderr);
const responses = result.stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line));
assert.deepEqual(responses[0].result, {
authMethod: "chatgpt",
authToken: token,
requiresOpenaiAuth: true
});
assert.deepEqual(responses[1].result, {
account: { type: "chatgpt", email: "user@example.com", planType: "plus" },
requiresOpenaiAuth: true
});
assert.deepEqual(JSON.parse(readFileSync(authFile, "utf8")), existingAuth);
});
test("Codex app-server delegates public Git marketplaces and leaves account-private marketplaces empty", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-official-plugins-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeCodex = path.join(dir, "fake-codex");
const codexHome = path.join(dir, "codex-home");
mkdirSync(codexHome, { recursive: true });
writeFileSync(fakeCodex, [
"#!/usr/bin/env node",
"const readline = require('node:readline');",
"const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });",
"input.on('line', (line) => {",
" const request = JSON.parse(line);",
" const kind = request.params.marketplaceKinds[0];",
" process.stdout.write(JSON.stringify({ id: request.id, result: { marketplaces: [{ name: kind, path: '/native/' + kind }], marketplaceLoadErrors: [], featuredPluginIds: [] } }) + '\\n');",
"});",
""
].join("\n"));
chmodSync(fakeCodex, 0o700);
const result = spawnSync(process.execPath, [runtimeFile, "app-server"], {
encoding: "utf8",
env: {
...process.env,
CCR_CODEX_REMOTE_FRONTEND_MODE: "app",
CCR_PROFILE_SCOPE: "ccr",
CCR_REAL_CODEX_CLI_PATH: fakeCodex,
CODEX_HOME: codexHome
},
input: [
JSON.stringify({ id: 1, method: "plugin/list", params: { marketplaceKinds: ["local", "vertical"] } }),
JSON.stringify({ id: 2, method: "plugin/list", params: { marketplaceKinds: ["created-by-me-remote"] } }),
""
].join("\n")
});
assert.equal(result.status, 0, result.stderr);
const responses = new Map(result.stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line)).map((response) => [response.id, response]));
assert.equal(responses.get(1).result.marketplaces[0].name, "local");
assert.equal(responses.get(1).result.marketplaces[0].path, "/native/local");
assert.deepEqual(responses.get(2).result, {
marketplaces: [],
marketplaceLoadErrors: [],
featuredPluginIds: []
});
});
test("Codex app-server delegates the native model catalog unchanged", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-native-models-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeCodex = path.join(dir, "fake-codex");
const codexHome = path.join(dir, "codex-home");
mkdirSync(codexHome, { recursive: true });
writeFileSync(fakeCodex, [
"#!/usr/bin/env node",
"const readline = require('node:readline');",
"const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });",
"input.on('line', (line) => {",
" const request = JSON.parse(line);",
" process.stdout.write(JSON.stringify({ id: request.id, result: { data: [{ id: 'native-model', hidden: true }], nextCursor: null } }) + '\\n');",
"});",
""
].join("\n"));
chmodSync(fakeCodex, 0o700);
const result = spawnSync(process.execPath, [runtimeFile, "app-server"], {
encoding: "utf8",
env: {
...process.env,
CCR_CODEX_MODEL_CATALOG: JSON.stringify({ models: [{ slug: "must-not-be-merged" }] }),
CCR_CODEX_REMOTE_FRONTEND_MODE: "app",
CCR_REAL_CODEX_CLI_PATH: fakeCodex,
CODEX_HOME: codexHome
},
input: JSON.stringify({ id: 1, method: "model/list", params: {} }) + "\n"
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout.trim()).result, {
data: [{ id: "native-model", hidden: true }],
nextCursor: null
});
});
test("Claude Code wrapper injects the scoped profile model into real CLI args", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-wrapper-"));
const runtimeFile = writeRuntimeScript(dir);

View file

@ -1,7 +1,4 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { buildCodexModelCatalog } from "../../packages/core/src/agents/codex/model-catalog.ts";
@ -64,97 +61,6 @@ test("codex catalog enables multimodal reasoning and search when provider protoc
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog uses provider model metadata for reasoning effort and speed tiers", () => {
const model = catalogModelFor({
Providers: [
{
modelMetadata: {
"gpt-5-codex": {
additionalSpeedTiers: [{ id: "fast", label: "Fast" }],
defaultReasoningLevel: "high",
defaultReasoningSummary: "auto",
serviceTiers: [{ id: "auto" }],
supportedReasoningLevels: [
{ description: "Low", effort: "low" },
{ description: "High", effort: "high" }
],
supportsReasoningSummaries: true
}
},
models: ["gpt-5-codex"],
name: "Codex API",
type: "openai_responses"
}
]
}, "Codex API/gpt-5-codex");
assert.deepEqual(model.additional_speed_tiers, [{ id: "fast", label: "Fast" }]);
assert.equal(model.default_reasoning_level, "high");
assert.equal(model.default_reasoning_summary, "auto");
assert.deepEqual(model.service_tiers, [{ id: "auto" }]);
assert.deepEqual(model.supported_reasoning_levels, [
{ description: "Low", effort: "low" },
{ description: "High", effort: "high" }
]);
assert.equal(model.supports_reasoning_summaries, true);
});
test("codex catalog falls back to local Codex model cache metadata", () => {
const previousCcrHome = process.env.CCR_INTERNAL_HOME_DIR;
const previousHome = process.env.HOME;
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-model-catalog-"));
try {
process.env.CCR_INTERNAL_HOME_DIR = home;
process.env.HOME = home;
const codexHome = path.join(home, ".codex");
mkdirSync(codexHome, { recursive: true });
writeFileSync(path.join(codexHome, "models_cache.json"), JSON.stringify({
models: [
{
additional_speed_tiers: [{ id: "fast", label: "Fast" }],
default_reasoning_level: "high",
service_tiers: [{ id: "auto" }],
slug: "gpt-5-codex",
supported_reasoning_levels: [
{ description: "Low", effort: "low" },
{ description: "High", effort: "high" }
],
supports_reasoning_summaries: true
}
]
}));
const model = catalogModelFor({
Providers: [
{
api_base_url: "https://chatgpt.com/backend-api/codex",
api_key: "ccr-local-agent-login",
models: ["gpt-5-codex"],
name: "Codex API",
type: "openai_responses"
}
]
}, "Codex API/gpt-5-codex");
assert.deepEqual(model.additional_speed_tiers, [{ id: "fast", label: "Fast" }]);
assert.equal(model.default_reasoning_level, "high");
assert.deepEqual(model.service_tiers, [{ id: "auto" }]);
assert.deepEqual(model.supported_reasoning_levels.map((level) => level.effort), ["low", "high"]);
} finally {
if (previousCcrHome === undefined) {
delete process.env.CCR_INTERNAL_HOME_DIR;
} else {
process.env.CCR_INTERNAL_HOME_DIR = previousCcrHome;
}
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
rmSync(home, { force: true, recursive: true });
}
});
test("codex catalog enables native search for Gemini Interactions providers", () => {
const model = catalogModelFor({
Providers: [

View file

@ -1,155 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
import { gatewayService } from "../../packages/core/src/gateway/service.ts";
test("gateway treats downstream client aborts as expected stream cleanup", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-gateway-client-abort-test-"));
const uncaughtErrors = [];
const unhandledRejections = [];
const onUncaughtException = (error) => uncaughtErrors.push(error);
const onUnhandledRejection = (error) => unhandledRejections.push(error);
process.prependListener("uncaughtException", onUncaughtException);
process.prependListener("unhandledRejection", onUnhandledRejection);
let upstreamResponseClosed = false;
const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
const upstream = createServer((request, response) => {
request.resume();
response.writeHead(200, { "content-type": "text/event-stream" });
response.write("event: response.output_text.delta\n");
response.write(`data: ${JSON.stringify({
item: {
arguments: JSON.stringify({ patch }),
call_id: "call_patch",
name: "virtual_apply_patch",
type: "function_call"
},
type: "response.output_item.done"
})}\n\n`);
const interval = setInterval(() => {
response.write("event: response.output_text.delta\n");
response.write(`data: ${JSON.stringify({ delta: "tick", type: "response.output_text.delta" })}\n\n`);
}, 20);
response.on("close", () => {
upstreamResponseClosed = true;
clearInterval(interval);
});
});
const gateway = createServer((request, response) => {
const requestPath = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
void gatewayService.proxyRequest(request, response, requestPath).catch((error) => {
if (!response.headersSent) {
response.writeHead(502, { "content-type": "application/json" });
}
if (!response.writableEnded) {
response.end(`${JSON.stringify({ error: { message: String(error?.message ?? error) } })}\n`);
}
});
});
try {
await listen(upstream);
const upstreamPort = serverPort(upstream);
const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.config.json") });
config.APIKEY = "test-api-key";
config.gateway.coreHost = "127.0.0.1";
config.gateway.corePort = upstreamPort;
config.gateway.host = "127.0.0.1";
config.gateway.port = 0;
gatewayService.updateConfig(config);
gatewayService.coreAuthToken = "test-core-auth-token";
await listen(gateway);
const gatewayUrl = `http://127.0.0.1:${serverPort(gateway)}/v1/responses`;
const controller = new AbortController();
const response = await fetch(gatewayUrl, {
body: JSON.stringify({
input: "hello",
model: "provider-deepseek::openai_chat_completions/deepseek-v4-flash",
stream: true,
tools: [{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } }]
}),
headers: {
authorization: "Bearer test-api-key",
"content-type": "application/json",
"user-agent": "codex-test"
},
method: "POST",
signal: controller.signal
});
const errorText = response.status === 200 ? "" : await response.text();
assert.equal(response.status, 200, errorText);
const reader = response.body.getReader();
const firstChunk = await reader.read();
assert.equal(firstChunk.done, false);
const firstChunkText = new TextDecoder().decode(firstChunk.value);
assert.match(firstChunkText, /"type":"custom_tool_call"/, "expected the response to use the Codex apply_patch response transform");
assert.match(firstChunkText, /"name":"apply_patch"/, "expected virtual_apply_patch to be rewritten back to apply_patch");
await reader.cancel("test downstream disconnect");
controller.abort();
await waitFor(() => upstreamResponseClosed, 1000);
assert.deepEqual(uncaughtErrors.map((error) => error?.message ?? String(error)), []);
assert.deepEqual(unhandledRejections.map((error) => error?.message ?? String(error)), []);
} finally {
process.off("uncaughtException", onUncaughtException);
process.off("unhandledRejection", onUnhandledRejection);
await closeServer(gateway);
await closeServer(upstream);
await gatewayService.stop();
rmSync(dir, { force: true, recursive: true });
}
});
function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
function serverPort(server) {
const address = server.address();
assert.equal(typeof address, "object");
assert.ok(address);
return address.port;
}
function closeServer(server) {
return new Promise((resolve, reject) => {
if (!server.listening) {
resolve();
return;
}
const timeout = setTimeout(() => server.closeAllConnections?.(), 1000);
server.close((error) => {
clearTimeout(timeout);
error ? reject(error) : resolve();
});
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(predicate, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await sleep(10);
}
assert.equal(predicate(), true);
}

View file

@ -1,20 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
import { gatewayService } from "../../packages/core/src/gateway/service.ts";
test("gateway start persists preflight validation failures for status polling", async () => {
await gatewayService.stop();
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" });
config.gateway.coreHost = "0.0.0.0";
const startStatus = await gatewayService.start(config);
const polledStatus = gatewayService.getStatus();
assert.equal(startStatus.state, "error");
assert.equal(polledStatus.state, "error");
assert.equal(polledStatus.lastError, "Core gateway host must be 127.0.0.1 or ::1.");
await gatewayService.stop();
});

View file

@ -3,7 +3,6 @@ import test from "node:test";
import {
attachCodexRateLimitResetCreditDetails,
codexDefaultBaseUrl,
codexModelCatalogFromPayloadForTest,
codexProviderAccountConfig,
codexRateLimitResetCreditDetails,
normalizeCodexProviderAccountConfig
@ -205,44 +204,3 @@ test("Codex local provider account config keeps custom connectors", () => {
assert.equal(provider.account, account);
});
test("Codex model catalog parser accepts live model endpoint shapes", () => {
const catalog = codexModelCatalogFromPayloadForTest({
data: [
{
additional_speed_tiers: [{ id: "fast", label: "Fast" }],
default_reasoning_level: "high",
display_name: "GPT-5 Codex",
service_tiers: [{ id: "auto" }],
slug: "gpt-5-codex",
supported_reasoning_levels: [
{ description: "Low", effort: "low" },
{ description: "High", effort: "high" }
],
supports_reasoning_summaries: true
},
{ displayName: "GPT-5.1 Codex", id: "gpt-5.1-codex" }
],
models: [
"gpt-5-codex",
{ label: "GPT-5.2 Codex", name: "gpt-5.2-codex" }
]
});
assert.deepEqual(catalog.models, ["gpt-5-codex", "gpt-5.1-codex", "gpt-5.2-codex"]);
assert.deepEqual(catalog.modelDisplayNames, {
"gpt-5-codex": "GPT-5 Codex",
"gpt-5.1-codex": "GPT-5.1 Codex",
"gpt-5.2-codex": "GPT-5.2 Codex"
});
assert.deepEqual(catalog.modelMetadata["gpt-5-codex"], {
additionalSpeedTiers: [{ id: "fast", label: "Fast" }],
defaultReasoningLevel: "high",
serviceTiers: [{ id: "auto" }],
supportedReasoningLevels: [
{ description: "Low", effort: "low" },
{ description: "High", effort: "high" }
],
supportsReasoningSummaries: true
});
});

View file

@ -1,84 +0,0 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
import { pluginService } from "../../packages/core/src/plugins/service.ts";
test("plugin service skips failed plugins and rolls back their registrations", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-plugin-service-test-"));
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args.map(String).join(" "));
try {
const brokenPlugin = path.join(dir, "broken-plugin.cjs");
const goodPlugin = path.join(dir, "good-plugin.cjs");
writeFileSync(brokenPlugin, `
module.exports = async function brokenPlugin(context) {
context.registerApp({ id: "broken-context-app", name: "Broken context", url: "http://broken-context.local" });
context.registerGatewayRoute({ id: "broken-context-route", path: "/broken-context", handler(_request, response) { response.end("broken"); } });
context.registerCoreGatewayProviderPlugin({ key: "broken-context-provider" });
context.registerCoreGatewayVirtualModelProfile({ id: "broken-context-vm" });
context.registerProviderAccountConnector({ id: "broken-account", resolve() { return []; } });
throw new Error("broken plugin setup failed");
};
`);
writeFileSync(goodPlugin, `
module.exports = {
setup(context) {
context.registerCoreGatewayProviderPlugin({ key: "good-context-provider" });
context.registerCoreGatewayVirtualModelProfile({ id: "good-context-vm" });
return {
apps: [{ id: "good-app", name: "Good app", url: "http://good.local" }],
coreGateway: {
config: { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } },
providerPlugins: [{ key: "good-provider" }],
virtualModelProfiles: [{ id: "good-vm" }]
},
gatewayRoutes: [{ id: "good-route", path: "/good", handler(_request, response) { response.end("good"); } }],
providerAccountConnectors: [{ id: "good-account", resolve() { return []; } }],
proxyRoutes: [{ host: "good.local", upstream: "http://127.0.0.1" }]
};
}
};
`);
const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") });
config.plugins = [
{
apps: [{ id: "broken-config-app", name: "Broken config", url: "http://broken-config.local" }],
coreGateway: {
config: { agent: { mcpServers: [{ name: "broken-config-mcp", command: "broken" }] } },
providerPlugins: [{ key: "broken-config-provider" }],
virtualModelProfiles: [{ id: "broken-config-vm" }]
},
id: "broken",
module: brokenPlugin,
proxy: { routes: [{ host: "broken.local", upstream: "http://127.0.0.1" }] }
},
{
id: "good",
module: goodPlugin
}
];
await pluginService.start(config);
assert.match(warnings.join("\n"), /plugin:broken.*Disabled after startup failure.*broken plugin setup failed/);
assert.deepEqual(pluginService.getApps().map((app) => app.id), ["good-app"]);
assert.equal(pluginService.matchGatewayRoute("GET", "/broken-context"), undefined);
assert.equal(pluginService.matchGatewayRoute("GET", "/good")?.id, "good-route");
assert.deepEqual(pluginService.getProxyRouteTargets(), [{ host: "good.local", paths: undefined }]);
assert.deepEqual(pluginService.getCoreProviderPlugins().map((plugin) => plugin.key), ["good-context-provider", "good-provider"]);
assert.deepEqual(pluginService.getVirtualModelProfiles().map((profile) => profile.id), ["good-context-vm", "good-vm"]);
assert.deepEqual(pluginService.getCoreGatewayConfig(), { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } });
assert.equal(pluginService.getProviderAccountConnector("broken", "broken-account"), undefined);
assert.equal(typeof pluginService.getProviderAccountConnector("good", "good-account")?.resolve, "function");
} finally {
console.warn = originalWarn;
await pluginService.stop();
rmSync(dir, { force: true, recursive: true });
}
});

View file

@ -8,16 +8,6 @@ import {
providerIdentitySafetyIssueInList,
providerPresetMatchesBaseUrl
} from "../../packages/core/src/providers/presets/utils.ts";
import {
fennoProviderPreset
} from "../../packages/core/src/providers/presets/fenno/index.ts";
import {
moonshotChinaProviderPreset,
moonshotGlobalProviderPreset
} from "../../packages/core/src/providers/presets/moonshot/index.ts";
import {
qiniuAiProviderPreset
} from "../../packages/core/src/providers/presets/qiniu-ai/index.ts";
const openAiPreset = {
aliases: ["OpenAI", "ChatGPT"],
@ -36,7 +26,6 @@ const anthropicPreset = {
};
const presets = [openAiPreset, anthropicPreset];
const moonshotPresets = [moonshotChinaProviderPreset, moonshotGlobalProviderPreset];
test("provider preset matching accepts endpoint subpaths but rejects different hosts", () => {
const openRouterPreset = {
@ -58,36 +47,7 @@ test("provider identity lookup normalizes aliases and punctuation", () => {
assert.equal(findProviderPresetByIdentityInList(presets, "Claude Provider")?.id, "anthropic");
});
test("provider identity lookup prefers exact Kimi regional names over shared aliases", () => {
assert.equal(findProviderPresetByIdentityInList(moonshotPresets, "Kimi API (Global)")?.id, "moonshot-global");
assert.equal(findProviderPresetByIdentityInList(moonshotPresets, "Kimi API (China)")?.id, "moonshot");
});
test("sponsor provider presets expose requested endpoints and protocols", () => {
assert.equal(fennoProviderPreset.websiteUrl, "https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=9HHHAB5QLAES");
assert.deepEqual(fennoProviderPreset.endpoints[0]?.protocols, [
"openai_chat_completions",
"openai_responses",
"anthropic_messages"
]);
assert.equal(qiniuAiProviderPreset.websiteUrl, "https://s.qiniu.com/AVjMVf");
assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.qnaigc.com"), true);
assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.modelink.ai/v1/models"), false);
assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.qnaigc.com/bypass/openai/v1/responses"), true);
assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.qnaigc.com/bypass/vertex/v1/models/gemini-pro:generateContent"), true);
assert.deepEqual(qiniuAiProviderPreset.endpoints.map((endpoint) => [endpoint.label, endpoint.baseUrl, endpoint.protocols]), [
["China mainland OpenAI", "https://api.qnaigc.com", ["openai_chat_completions"]],
["China mainland OpenAI Responses", "https://api.qnaigc.com/bypass/openai/v1", ["openai_responses"]],
["China mainland Anthropic", "https://api.qnaigc.com", ["anthropic_messages"]],
["China mainland Gemini Generate", "https://api.qnaigc.com/bypass/vertex/v1", ["gemini_generate_content"]]
]);
assert.deepEqual(qiniuAiProviderPreset.endpoints[0]?.protocols, [
"openai_chat_completions"
]);
});
test("provider identity safety does not block branded third-party endpoints", () => {
test("provider identity safety allows loopback but warns on branded third-party endpoints", () => {
assert.equal(
providerIdentitySafetyIssueInList(presets, {
baseUrl: "http://127.0.0.1:3456/v1",
@ -95,55 +55,23 @@ test("provider identity safety does not block branded third-party endpoints", ()
}),
undefined
);
assert.equal(
assert.match(
providerIdentitySafetyIssueInList(presets, {
baseUrl: "https://proxy.example.com/v1",
name: "OpenAI proxy"
}),
undefined
})?.message ?? "",
/Provider identity looks like OpenAI/
);
});
test("provider identity safety does not block shared Kimi aliases", () => {
assert.equal(
providerIdentitySafetyIssueInList(moonshotPresets, {
baseUrl: "https://api.moonshot.ai/anthropic",
name: "Kimi API (Global)"
}),
undefined
);
assert.equal(
providerIdentitySafetyIssueInList(moonshotPresets, {
baseUrl: "https://api.moonshot.ai/v1",
name: "Kimi API (China)"
}),
undefined
);
assert.equal(
providerEndpointCanReceiveProviderApiKeyInList(moonshotPresets, {
apiKey: "manifest-provider-api-key",
endpoint: "https://api.moonshot.ai/v1/users/me/balance",
providerName: "Kimi API (China)"
}),
undefined
);
assert.equal(
providerIdentitySafetyIssueInList(moonshotPresets, {
baseUrl: "https://proxy.example.com/v1",
name: "Kimi API (Global)"
}),
undefined
);
});
test("provider API key safety does not block official-looking keys on third-party endpoints", () => {
assert.equal(
test("provider API key safety blocks official-looking keys on untrusted endpoints", () => {
assert.match(
providerApiKeySafetyIssueInList(presets, {
apiKey: "sk-openai-test",
baseUrl: "https://proxy.example.com/v1",
name: "neutral proxy"
}),
undefined
})?.message ?? "",
/official OpenAI key/
);
assert.equal(
providerApiKeySafetyIssueInList(presets, {
@ -152,12 +80,12 @@ test("provider API key safety does not block official-looking keys on third-part
}),
undefined
);
assert.equal(
assert.match(
providerEndpointCanReceiveProviderApiKeyInList(presets, {
apiKey: "sk-ant-test",
endpoint: "https://proxy.example.com/anthropic",
providerName: "Anthropic"
}),
undefined
})?.message ?? "",
/official Anthropic key/
);
});

View file

@ -43,7 +43,7 @@ test("protocol support probe keeps auth-only fallback for unhinted endpoints", (
);
});
test("protocol support probe treats HTTP 400 validation as protocol support", () => {
test("protocol support probe treats Gemini contents validation as Gemini support only", () => {
const message = "HTTP 400: * GenerateContentRequest.contents: contents is not specified";
assert.equal(
@ -52,11 +52,11 @@ test("protocol support probe treats HTTP 400 validation as protocol support", ()
);
assert.equal(
isProviderProtocolEndpointSupportedForProbe(400, message, "openai_chat_completions", ["openai_chat_completions"]),
true
false
);
});
test("protocol support probe treats HTTP 400 input validation as protocol support", () => {
test("protocol support probe treats Gemini Interactions input validation as Interactions support", () => {
const message = "HTTP 400: Gemini Interactions request requires input.";
assert.equal(
@ -65,15 +65,6 @@ test("protocol support probe treats HTTP 400 input validation as protocol suppor
);
assert.equal(
isProviderProtocolEndpointSupportedForProbe(400, message, "gemini_generate_content", ["gemini_generate_content"]),
true
);
});
test("protocol support probe still rejects HTTP 400 route misses", () => {
const message = "HTTP 400: unknown route";
assert.equal(
isProviderProtocolEndpointSupportedForProbe(400, message, "openai_chat_completions", ["openai_chat_completions"]),
false
);
});

View file

@ -27,18 +27,6 @@ test("provider URL parsing handles local and Gemini endpoint variants", () => {
assert.equal(parsed.geminiBaseUrl, "http://localhost:8787");
});
test("provider URL parsing preserves versioned Vertex bypass bases for Gemini", () => {
const parsed = parseProviderBaseUrl("https://api.qnaigc.com/bypass/vertex/v1/models/gemini-pro:generateContent");
assert.equal(parsed.normalizedInputBaseUrl, "https://api.qnaigc.com/bypass/vertex/v1");
assert.equal(parsed.rootBaseUrl, "https://api.qnaigc.com/bypass/vertex");
assert.equal(parsed.geminiBaseUrl, "https://api.qnaigc.com/bypass/vertex/v1");
assert.equal(
normalizeProviderBaseUrl("https://api.qnaigc.com/bypass/vertex/v1", "gemini_generate_content"),
"https://api.qnaigc.com/bypass/vertex/v1"
);
});
test("provider URL parsing handles Gemini Interactions endpoint variants", () => {
const parsed = parseProviderBaseUrl("localhost:8787/v1/interactions/interaction-123/cancel");

View file

@ -1,11 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ClaudeCodeRouterPlugin } from "../../packages/core/src/gateway/claude-code-router-plugin.ts";
import {
fallbackRetryDelayAfterNetworkErrorForTest,
fallbackRetryDelayAfterStatusForTest,
prepareGatewayUpstreamAttemptForTest
} from "../../packages/core/src/gateway/service.ts";
import { prepareGatewayUpstreamAttemptForTest } from "../../packages/core/src/gateway/service.ts";
function createRouterPlugin(options = {}) {
const agent = options.agent ?? "claude-code";
@ -46,18 +42,6 @@ function createRouterPlugin(options = {}) {
});
}
test("fallback retry delay backs off retryable HTTP statuses", () => {
assert.equal(fallbackRetryDelayAfterStatusForTest({ statusCode: 503 }), 1000);
assert.equal(fallbackRetryDelayAfterStatusForTest({ failedAttemptIndex: 1, statusCode: 408 }), 2000);
assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "3", statusCode: 429 }), 3000);
assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "0", statusCode: 429 }), 1000);
});
test("fallback retry delay backs off network errors", () => {
assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(), 1000);
assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(2), 4000);
});
function createIssue1480UserConfig() {
return {
APIKEY: "gateway-key",

View file

@ -1,26 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { GatewayStartupErrorBanner } from "../../packages/ui/src/pages/home/components/layout.tsx";
import { AppI18nContext, appCopy } from "../../packages/ui/src/pages/home/shared/i18n.tsx";
test("GatewayStartupErrorBanner renders startup failure details", () => {
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.zh}>
<GatewayStartupErrorBanner message="没有可用模型。请先配置供应商。" onOpenServerSettings={() => undefined} />
</AppI18nContext.Provider>
);
assert.match(html, /role="alert"/);
assert.match(html, /aria-live="assertive"/);
assert.match(html, /服务启动失败/);
assert.match(html, /没有可用模型。请先配置供应商。/);
assert.match(html, />服务<\/button>/);
});
test("GatewayStartupErrorBanner stays hidden without a failure message", () => {
const html = renderToStaticMarkup(<GatewayStartupErrorBanner message="" />);
assert.equal(html, "");
});

View file

@ -1,31 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts";
import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts";
import { moonshotGlobalProviderPreset } from "../../packages/core/src/providers/presets/moonshot/index.ts";
import { qiniuAiProviderPreset } from "../../packages/core/src/providers/presets/qiniu-ai/index.ts";
import { AddProviderDialog, ProvidersView } from "../../packages/ui/src/pages/home/components/providers.tsx";
import {
applyProviderProbeResult,
createProviderConfigFromDeepLink,
createProviderDraft,
createProviderInstallLinkFromDraft,
providerCapabilitiesForProtocols,
providerCapabilityBaseUrlForProtocol,
providerDisplayIcon,
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
providerGlobalBaseUrlForProbe,
providerPresetIconUrls,
providerProtocolOptions,
providerProbeCandidates,
providerSelectableProtocolsFromProbe,
setProviderPresets
} from "../../packages/ui/src/pages/home/shared/index.tsx";
import { installBrowserGlobals } from "./fixtures.ts";
installBrowserGlobals();
test("Gemini preset keeps full protocol probing candidates", () => {
setProviderPresets([geminiProviderPreset]);
@ -38,23 +22,6 @@ test("Gemini preset keeps full protocol probing candidates", () => {
assert.equal(candidates.length, 1);
assert.deepEqual(candidates[0].protocols, providerProtocolOptions.map((option) => option.value));
assert.deepEqual(candidates[0].declaredProtocols, ["gemini_generate_content", "gemini_interactions"]);
});
test("multi-endpoint presets probe only each endpoint's declared protocols", () => {
setProviderPresets([qiniuAiProviderPreset]);
const draft = {
...createProviderDraft([]),
presetId: "qiniu-ai"
};
const candidates = providerProbeCandidates(draft);
assert.equal(candidates.length, qiniuAiProviderPreset.endpoints.length);
assert.deepEqual(
candidates.map((candidate) => [candidate.baseUrl, candidate.protocols]),
qiniuAiProviderPreset.endpoints.map((endpoint) => [endpoint.baseUrl, endpoint.protocols])
);
});
test("provider probe result drops unavailable selected protocols", () => {
@ -107,63 +74,6 @@ test("provider probe result keeps only supported selected protocols", () => {
assert.deepEqual(next.selectedProtocols, ["gemini_generate_content"]);
});
test("provider protocol details keep failed endpoint rows unavailable", () => {
const draft = {
...createProviderDraft([]),
baseUrl: "https://api.example.com/v1",
name: "Example",
protocol: "openai_chat_completions" as const,
selectedProtocols: ["openai_chat_completions" as const]
};
const html = renderToStaticMarkup(
React.createElement(AddProviderDialog, {
canSubmit: true,
draft,
error: "",
mode: "edit",
onChange: () => undefined,
onClose: () => undefined,
onSubmit: async () => true,
probe: {
capabilities: [
{
baseUrl: "https://api.example.com/v1",
endpoint: "https://api.example.com/v1/chat/completions",
source: "detected" as const,
type: "openai_chat_completions" as const
}
],
detectedProtocol: "openai_chat_completions" as const,
models: [],
normalizedBaseUrl: "https://api.example.com/v1",
protocols: [
{
baseUrl: "https://api.example.com",
endpoint: "https://api.example.com/chat/completions",
message: "HTTP 404",
protocol: "openai_chat_completions" as const,
status: 404,
supported: false
},
{
baseUrl: "https://api.example.com/v1",
endpoint: "https://api.example.com/v1/chat/completions",
message: "HTTP 400: model is required",
protocol: "openai_chat_completions" as const,
status: 400,
supported: true
}
]
},
probeLoading: false,
providers: []
})
);
assert.match(html, /Unavailable/);
assert.match(html, /Available/);
});
test("provider probe result applies detected New API key quota account connector", () => {
const draft = {
...createProviderDraft([]),
@ -202,301 +112,6 @@ test("provider probe result applies detected New API key quota account connector
assert.equal(connectors[0].mapping.meters[0].remaining, "$.data.total_available");
});
test("provider probe keeps anthropic prefix on the protocol capability", () => {
const draft = {
...createProviderDraft([]),
baseUrl: "https://gateway.example",
protocol: "anthropic_messages",
selectedProtocols: ["anthropic_messages"]
};
const anthropicCapability = {
baseUrl: "https://gateway.example/anthropic",
endpoint: "https://gateway.example/anthropic/v1/messages",
source: "detected" as const,
type: "anthropic_messages" as const
};
const probe = {
capabilities: [anthropicCapability],
detectedProtocol: "anthropic_messages" as const,
models: [],
normalizedBaseUrl: "https://gateway.example/anthropic",
protocols: [
{
...anthropicCapability,
message: "HTTP 400: model is required",
status: 400,
supported: true
}
]
};
const next = applyProviderProbeResult(draft, probe);
assert.equal(next.baseUrl, "https://gateway.example");
assert.equal(providerGlobalBaseUrlForProbe(draft.baseUrl, probe, ["anthropic_messages"]), "https://gateway.example");
assert.equal(
providerCapabilityBaseUrlForProtocol(draft.baseUrl, "anthropic_messages", probe),
"https://gateway.example/anthropic"
);
assert.deepEqual(providerCapabilitiesForProtocols(draft.baseUrl, ["anthropic_messages"], probe), [anthropicCapability]);
});
test("provider capabilities prefer detected anthropic endpoint over preset root", () => {
const probe = {
capabilities: [
{
baseUrl: "https://gateway.example/anthropic",
endpoint: "https://gateway.example/anthropic/v1/messages",
source: "detected" as const,
type: "anthropic_messages" as const
}
],
detectedProtocol: "anthropic_messages" as const,
models: [],
normalizedBaseUrl: "https://gateway.example/anthropic",
protocols: []
};
const capabilities = providerCapabilitiesForProtocols(
"https://gateway.example",
["anthropic_messages", "openai_chat_completions"],
probe,
[
{ baseUrl: "https://gateway.example", source: "preset" as const, type: "anthropic_messages" as const },
{ baseUrl: "https://gateway.example/v1", source: "preset" as const, type: "openai_chat_completions" as const }
]
);
assert.deepEqual(capabilities.map((capability) => [capability.type, capability.baseUrl]), [
["openai_chat_completions", "https://gateway.example/v1"],
["anthropic_messages", "https://gateway.example/anthropic"]
]);
assert.equal(
providerGlobalBaseUrlForProbe("https://gateway.example", probe, ["anthropic_messages", "openai_chat_completions"]),
"https://gateway.example"
);
});
test("provider global URL keeps Kimi Global OpenAI v1 when anthropic probe is detected", () => {
const probe = {
capabilities: [
{
baseUrl: "https://api.moonshot.ai/anthropic",
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
source: "detected" as const,
type: "anthropic_messages" as const
}
],
detectedProtocol: "anthropic_messages" as const,
models: [],
normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
protocols: []
};
assert.equal(
providerGlobalBaseUrlForProbe("https://api.moonshot.ai/v1", probe, ["openai_chat_completions"]),
"https://api.moonshot.ai/v1"
);
assert.deepEqual(
providerCapabilitiesForProtocols("https://api.moonshot.ai/v1", ["openai_chat_completions"], probe)
.map((capability) => [capability.type, capability.baseUrl]),
[["openai_chat_completions", "https://api.moonshot.ai/v1"]]
);
});
test("provider global URL always follows the typed endpoint instead of probe endpoint", () => {
const probe = {
capabilities: [
{
baseUrl: "https://gateway.example/v1",
endpoint: "https://gateway.example/v1/chat/completions",
source: "detected" as const,
type: "openai_chat_completions" as const
},
{
baseUrl: "https://gateway.example/anthropic",
endpoint: "https://gateway.example/anthropic/v1/messages",
source: "detected" as const,
type: "anthropic_messages" as const
}
],
detectedProtocol: "openai_chat_completions" as const,
models: [],
normalizedBaseUrl: "https://gateway.example/v1",
protocols: []
};
assert.equal(
providerGlobalBaseUrlForProbe("https://gateway.example", probe, ["openai_chat_completions", "anthropic_messages"]),
"https://gateway.example"
);
});
test("Kimi Global preset keeps OpenAI Chat when probe detects anthropic fallback", () => {
setProviderPresets([moonshotGlobalProviderPreset]);
const draft = {
...createProviderDraft([]),
baseUrl: "https://api.moonshot.ai/v1",
presetId: "moonshot-global",
protocol: "openai_chat_completions",
selectedProtocols: ["openai_chat_completions"]
};
const probe = {
capabilities: [
{
baseUrl: "https://api.moonshot.ai/anthropic",
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
source: "detected" as const,
type: "anthropic_messages" as const
},
{
baseUrl: "https://api.moonshot.ai/v1",
source: "preset" as const,
type: "openai_chat_completions" as const
}
],
detectedProtocol: "anthropic_messages" as const,
models: [],
normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
protocols: [
{
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
message: "HTTP 400: model is required",
protocol: "anthropic_messages" as const,
status: 400,
supported: true
}
]
};
const next = applyProviderProbeResult(draft, probe);
assert.equal(next.baseUrl, "https://api.moonshot.ai/v1");
assert.equal(next.protocol, "openai_chat_completions");
assert.deepEqual(next.selectedProtocols, ["openai_chat_completions", "anthropic_messages"]);
assert.deepEqual(providerSelectableProtocolsFromProbe(probe), ["openai_chat_completions", "anthropic_messages"]);
const installLink = createProviderInstallLinkFromDraft({
...next,
modelsText: "kimi-k2.7-code",
name: "Kimi API (Global)"
}, probe);
const payload = providerInstallLinkPayload(installLink);
assert.equal(payload.protocol, "openai_chat_completions");
assert.equal(payload.baseUrl, "https://api.moonshot.ai/v1");
});
test("Kimi Global deep link explicit OpenAI protocol wins over anthropic probe", () => {
const probe = {
capabilities: [
{
baseUrl: "https://api.moonshot.ai/anthropic",
endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
source: "detected" as const,
type: "anthropic_messages" as const
}
],
detectedProtocol: "anthropic_messages" as const,
models: [],
normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
protocols: []
};
const provider = createProviderConfigFromDeepLink({
baseUrl: "https://api.moonshot.ai/v1",
models: ["kimi-k2.7-code"],
name: "Kimi API (Global)",
protocol: "openai_chat_completions"
}, [], probe);
assert.equal(provider.type, "openai_chat_completions");
assert.equal(provider.api_base_url, "https://api.moonshot.ai/v1");
assert.deepEqual(provider.capabilities?.map((capability) => [capability.type, capability.baseUrl]), [
["openai_chat_completions", "https://api.moonshot.ai/v1"]
]);
});
test("provider deep link config saves anthropic probe prefix as capability URL", () => {
const probe = {
capabilities: [
{
baseUrl: "https://gateway.example/anthropic",
endpoint: "https://gateway.example/anthropic/v1/messages",
source: "detected" as const,
type: "anthropic_messages" as const
}
],
detectedProtocol: "anthropic_messages" as const,
models: [],
normalizedBaseUrl: "https://gateway.example/anthropic",
protocols: []
};
const provider = createProviderConfigFromDeepLink({
baseUrl: "https://gateway.example",
models: ["claude-test"],
name: "Gateway",
protocol: "anthropic_messages"
}, [], probe);
assert.equal(provider.api_base_url, "https://gateway.example");
assert.deepEqual(provider.capabilities?.map((capability) => [capability.type, capability.baseUrl]), [
["anthropic_messages", "https://gateway.example/anthropic"]
]);
});
test("provider display icon prefers custom icons and falls back to preset icons", () => {
setProviderPresets([geminiProviderPreset]);
assert.equal(
providerDisplayIcon({
api_base_url: "https://custom.example/v1",
icon: "https://custom.example/icon.png",
models: [],
name: "Custom Provider",
type: "openai_chat_completions"
}),
"https://custom.example/icon.png"
);
assert.equal(
providerDisplayIcon({
api_base_url: "https://generativelanguage.googleapis.com",
models: [],
name: "Google Gemini",
type: "gemini_generate_content"
}),
providerPresetIconUrls.gemini
);
});
test("ProvidersView renders configured provider icons in the list", () => {
const iconUrl = "https://custom.example/icon.png";
const html = renderToStaticMarkup(
React.createElement(ProvidersView, {
accountSnapshots: [],
addProvider: () => undefined,
editProvider: () => undefined,
notify: () => undefined,
providers: [
{
index: 0,
provider: {
api_base_url: "https://custom.example/v1",
icon: iconUrl,
models: ["custom-model"],
name: "Custom Provider",
type: "openai_chat_completions"
}
}
],
removeProvider: () => undefined
})
);
assert.match(html, /Custom Provider/);
assert.match(html, /src="https:\/\/custom\.example\/icon\.png"/);
});
test("New API user balance template adds configurable user self connector", () => {
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
const text = providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
@ -514,12 +129,3 @@ test("New API user balance template adds configurable user self connector", () =
assert.equal(connectors[1].headers["New-Api-User"], "42");
assert.equal(connectors[1].mapping.meters[0].id, "new_api_user_balance");
});
function providerInstallLinkPayload(link) {
const url = new URL(link);
const payload = url.searchParams.get("payload");
assert.ok(payload);
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, "=").replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}

View file

@ -1,8 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatLogTokenSummary } from "../../packages/ui/src/pages/home/shared/logs.ts";
import { formatCompactNumber, formatUsdCost as formatHomeUsdCost } from "../../packages/ui/src/pages/home/shared/usage.ts";
import { formatUsdCost as formatTrayUsdCost } from "../../packages/ui/src/pages/tray/shared.tsx";
import { formatCompactNumber } from "../../packages/ui/src/pages/home/shared/usage.ts";
import type { RequestLogEntry } from "../../packages/core/src/contracts/app.ts";
test("formatCompactNumber can be bound to the UI language locale", () => {
@ -10,13 +9,6 @@ test("formatCompactNumber can be bound to the UI language locale", () => {
assert.equal(formatCompactNumber(123456, "zh-CN"), "12.3万");
});
test("formatUsdCost formats large values without conflicting fraction digits", () => {
assert.doesNotThrow(() => formatHomeUsdCost(100));
assert.doesNotThrow(() => formatTrayUsdCost(100));
assert.doesNotMatch(formatHomeUsdCost(123.45), /[.,]45/);
assert.doesNotMatch(formatTrayUsdCost(123.45), /[.,]45/);
});
test("formatLogTokenSummary uses the provided locale for token counts", () => {
const entry: RequestLogEntry = {
cacheReadTokens: 0,