diff --git a/README.md b/README.md index f32ddf1..70f73bb 100644 --- a/README.md +++ b/README.md @@ -9,44 +9,12 @@ Documentation

-
- - - - - - - - -
- - Kimi K2.7 Code sponsor banner - -
- - Kimi Code Subscription -  ·  - API Global -  ·  - API China - -
-

- Thanks to Kimi for sponsoring this project! 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. -

-

- CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. -

-
- -
- -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. -

Claude Code Router Desktop screenshot

+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 - + code0.ai icon
code0.ai
- + claudeapi icon
claudeapi
- - - - Qiniu Cloud AI icon -
- Qiniu Cloud AI -
- - - - Fenno.ai icon -
- Fenno.ai -
- -

Community Sponsors

diff --git a/README_zh.md b/README_zh.md index 7065c54..710c17a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,44 +9,12 @@ 文档

-
- - - - - - - - -
- - Kimi K2.7 Code 赞助横幅 - -
- - Kimi Code 订阅 -  ·  - API 中文站 -  ·  - API Global - -
-

- 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 -

-

- CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 -

-
- -
- -Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 -

Claude Code Router Desktop 项目截图

+Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 + ## 为什么使用 CCR - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 @@ -209,36 +177,20 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 - + code0.ai 图标
code0.ai
- + claudeapi 图标
claudeapi
- - - - 七牛云 AI 图标 -
- 七牛云 AI -
- - - - Fenno.ai 图标 -
- Fenno.ai -
- -

社区赞助者

diff --git a/docs/public/provider-icons/fenno.jpg b/docs/public/provider-icons/fenno.jpg deleted file mode 100644 index 364f735..0000000 Binary files a/docs/public/provider-icons/fenno.jpg and /dev/null differ diff --git a/docs/public/provider-icons/qiniu-ai.png b/docs/public/provider-icons/qiniu-ai.png deleted file mode 100644 index 2ff255d..0000000 Binary files a/docs/public/provider-icons/qiniu-ai.png and /dev/null differ diff --git a/docs/src/content/docs/en/configuration/profiles.md b/docs/src/content/docs/en/configuration/profiles.md index 0676811..c3ce32e 100644 --- a/docs/src/content/docs/en/configuration/profiles.md +++ b/docs/src/content/docs/en/configuration/profiles.md @@ -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 diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md index 137d8d9..b2dd290 100644 --- a/docs/src/content/docs/en/configuration/provider-deeplink.md +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -78,22 +78,14 @@ Choose a provider below to get started. CCR shows what will be added before savi TeamoRouterAnthropic / Chat / Responses - + code0.aiAnthropic / Chat / Responses - + claudeapiAnthropic Messages - - - Qiniu Cloud AIChat / Responses / Anthropic / Gemini Generate - - - - Fenno.aiChat / Responses / Anthropic - ## Embeddable Button Component diff --git a/docs/src/content/docs/en/configuration/routing.md b/docs/src/content/docs/en/configuration/routing.md index f21a521..a378c56 100644 --- a/docs/src/content/docs/en/configuration/routing.md +++ b/docs/src/content/docs/en/configuration/routing.md @@ -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. diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md index 6a7c91c..f5b1ffc 100644 --- a/docs/src/content/docs/zh/configuration/profile.md +++ b/docs/src/content/docs/zh/configuration/profile.md @@ -24,7 +24,7 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过 | --- | --- | | 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 | | 独立启动器 | Claude Code 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 | -| 独立 App 数据目录 | 从 App 打开时,Claude App、ChatGPT(Codex 桌面端的新名称)、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 marketplace,CCR 只在进程启动阶段创建精确的 `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 diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index a281471..ac471c7 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -78,22 +78,14 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少 TeamoRouterAnthropic / Chat / Responses - + code0.aiAnthropic / Chat / Responses - + claudeapiAnthropic Messages - - - 七牛云 AIChat / Responses / Anthropic / Gemini Generate - - - - Fenno.aiChat / Responses / Anthropic - ## 嵌入式按钮组件 diff --git a/docs/src/content/docs/zh/configuration/routing.md b/docs/src/content/docs/zh/configuration/routing.md index 332eda9..c00f069 100644 --- a/docs/src/content/docs/zh/configuration/routing.md +++ b/docs/src/content/docs/zh/configuration/routing.md @@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成; | 继续重试 | `408`、`409`、`429`、`5xx` | | 失败降级目标 | 任意 `4xx` 或 `5xx` | -进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 +对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 **失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。 diff --git a/package-lock.json b/package-lock.json index 4bbb5fd..84d877d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9b218e1..26fbf21 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/README.md b/packages/cli/README.md index 17dc66b..3f5ce1e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8,44 +8,12 @@ Documentation

-
- - - - - - - - -
- - Kimi K2.7 Code sponsor banner - -
- - Kimi Code Subscription -  ·  - API Global -  ·  - API China - -
-

- Thanks to Kimi for sponsoring this project! 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. -

-

- CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. -

-
- -
- -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. -

Claude Code Router Desktop screenshot

+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 TeamoRouter - - - Qiniu Cloud AI icon -
- Qiniu Cloud AI -
- - - - Fenno.ai icon -
- Fenno.ai -
- diff --git a/packages/cli/README_zh.md b/packages/cli/README_zh.md index 58c4fef..cd79161 100644 --- a/packages/cli/README_zh.md +++ b/packages/cli/README_zh.md @@ -8,44 +8,12 @@ 文档

-
- - - - - - - - -
- - Kimi K2.7 Code 赞助横幅 - -
- - Kimi Code 订阅 -  ·  - API 中文站 -  ·  - API Global - -
-

- 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 -

-

- CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 -

-
- -
- -Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 -

Claude Code Router Desktop 项目截图

+Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 + ## 为什么使用 CCR - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 @@ -207,20 +175,6 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 TeamoRouter - - - 七牛云 AI 图标 -
- 七牛云 AI -
- - - - Fenno.ai 图标 -
- Fenno.ai -
- diff --git a/packages/cli/package.json b/packages/cli/package.json index ef8d921..8e040f7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index fe66d04..60cd3a8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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 { 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): string { if (profile.agent === "zcode") { return "ZCode App"; } - return codexDesktopAppName; + return "Codex App"; } function parseStopArgs(args: string[]): StopCliOptions { diff --git a/packages/core/package.json b/packages/core/package.json index 91f6862..d9eeb5a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/agents/claude-app/launch.ts b/packages/core/src/agents/claude-app/launch.ts index 7969edf..cdfa57d 100644 --- a/packages/core/src/agents/claude-app/launch.ts +++ b/packages/core/src/agents/claude-app/launch.ts @@ -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 { - 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 { return Object.entries(profile.env ?? {}).reduce>((result, [key, value]) => { if (isEnvName(key) && typeof value === "string") { diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index 6db6a46..566770d 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -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; - 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): { 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[] { + 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", diff --git a/packages/core/src/agents/codex/cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts index 1da08f6..0c87695 100644 --- a/packages/core/src/agents/codex/cli-middleware-runtime.ts +++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts @@ -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) { diff --git a/packages/core/src/agents/codex/model-catalog.ts b/packages/core/src/agents/codex/model-catalog.ts index 198f207..dd51f76 100644 --- a/packages/core/src/agents/codex/model-catalog.ts +++ b/packages/core/src/agents/codex/model-catalog.ts @@ -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> ): 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> | 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[]) { diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index bdae4e2..37c05ff 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -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 | 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; - } -} diff --git a/packages/core/src/agents/local-providers/codex.ts b/packages/core/src/agents/local-providers/codex.ts index b6ba7dd..2a16a11 100644 --- a/packages/core/src/agents/local-providers/codex.ts +++ b/packages/core/src/agents/local-providers/codex.ts @@ -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; - modelMetadata?: Record; 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 { +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 { - 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 { }; } -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 { - 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 { - 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 = {}; - const modelMetadata: Record = {}; - 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): 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"]) diff --git a/packages/core/src/agents/local-providers/service.ts b/packages/core/src/agents/local-providers/service.ts index 6f1ab13..d86415f 100644 --- a/packages/core/src/agents/local-providers/service.ts +++ b/packages/core/src/agents/local-providers/service.ts @@ -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 { +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 { - 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."); -} diff --git a/packages/core/src/agents/local-providers/shared.ts b/packages/core/src/agents/local-providers/shared.ts index 1432c01..f70c238 100644 --- a/packages/core/src/agents/local-providers/shared.ts +++ b/packages/core/src/agents/local-providers/shared.ts @@ -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 | undefined, - models: string[] -): Record | 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 | undefined, models: string[] diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4e93f9c..0cf78ab 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 0 ? Object.fromEntries(entries) : undefined; } -function parseModelMetadata(value: unknown, models: string[]): Record | 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(); 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, 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; diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 1fa0e90..0dbfa56 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -133,7 +133,6 @@ export type GatewayProviderConfig = { id?: string; modelDescriptions?: Record; modelDisplayNames?: Record; - modelMetadata?: Record; 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; modelDisplayNames?: Record; - modelMetadata?: Record; models: string[]; name?: string; protocol?: GatewayProviderProtocol; @@ -339,7 +323,6 @@ export type LocalAgentProviderCandidate = { importable: boolean; kind: LocalAgentProviderKind; modelDisplayNames?: Record; - modelMetadata?: Record; 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; - modelMetadata?: Record; 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; - modelMetadata?: Record; 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; diff --git a/packages/core/src/contracts/i18n.ts b/packages/core/src/contracts/i18n.ts index 4cc9c57..0b5b927 100644 --- a/packages/core/src/contracts/i18n.ts +++ b/packages/core/src/contracts/i18n.ts @@ -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 (.+)$/, diff --git a/packages/core/src/contracts/ipc-channels.ts b/packages/core/src/contracts/ipc-channels.ts index 6a89c08..f24d040 100644 --- a/packages/core/src/contracts/ipc-channels.ts +++ b/packages/core/src/contracts/ipc-channels.ts @@ -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", diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 1131954..5936e7a 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -346,12 +346,11 @@ class GatewayService { async start(config: AppConfig): Promise { 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 { } } -async function cancelResponseBody(response: Response): Promise { - 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 | undefined { if (!buffer || buffer.byteLength === 0) { return undefined; diff --git a/packages/core/src/plugins/service.ts b/packages/core/src/plugins/service.ts index 05fc11c..ae9ebd9 100644 --- a/packages/core/src/plugins/service.ts +++ b/packages/core/src/plugins/service.ts @@ -144,18 +144,6 @@ type LoadedPlugin = { stop?: () => MaybePromise; }; -type PluginServiceStateSnapshot = { - apps: InstalledBrowserApp[]; - coreGatewayConfig: Record; - coreProviderPlugins: unknown[]; - gatewayRoutes: RegisteredGatewayRoute[]; - providerAccountConnectors: Map; - proxyRoutes: RegisteredProxyRoute[]; - resourceOwnerIds: Set; - stopHooks: Array<() => MaybePromise>; - virtualModelProfiles: unknown[]; -}; - const requireFromHere = createRequire(__filename); const builtInMarketplacePluginModules = new Map([ ["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 { 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 { - 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(); diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index 17cd4d5..a0b1e7f 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -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): Promise { - 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) { diff --git a/packages/core/src/providers/presets/claudeapi/index.ts b/packages/core/src/providers/presets/claudeapi/index.ts index dbd10d7..85a7b65 100644 --- a/packages/core/src/providers/presets/claudeapi/index.ts +++ b/packages/core/src/providers/presets/claudeapi/index.ts @@ -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" }; diff --git a/packages/core/src/providers/presets/code0/index.ts b/packages/core/src/providers/presets/code0/index.ts index 1caf832..b87be18 100644 --- a/packages/core/src/providers/presets/code0/index.ts +++ b/packages/core/src/providers/presets/code0/index.ts @@ -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" }; diff --git a/packages/core/src/providers/presets/fenno/index.ts b/packages/core/src/providers/presets/fenno/index.ts deleted file mode 100644 index 63a645c..0000000 --- a/packages/core/src/providers/presets/fenno/index.ts +++ /dev/null @@ -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" -}; diff --git a/packages/core/src/providers/presets/index.ts b/packages/core/src/providers/presets/index.ts index 2a56d91..85c1c86 100644 --- a/packages/core/src/providers/presets/index.ts +++ b/packages/core/src/providers/presets/index.ts @@ -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, diff --git a/packages/core/src/providers/presets/minimax/index.ts b/packages/core/src/providers/presets/minimax/index.ts deleted file mode 100644 index f01fee0..0000000 --- a/packages/core/src/providers/presets/minimax/index.ts +++ /dev/null @@ -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" -}; diff --git a/packages/core/src/providers/presets/qiniu-ai/index.ts b/packages/core/src/providers/presets/qiniu-ai/index.ts deleted file mode 100644 index 36deee3..0000000 --- a/packages/core/src/providers/presets/qiniu-ai/index.ts +++ /dev/null @@ -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" -}; diff --git a/packages/core/src/providers/presets/utils.ts b/packages/core/src/providers/presets/utils.ts index e1e7f65..362e19d 100644 --- a/packages/core/src/providers/presets/utils.ts +++ b/packages/core/src/providers/presets/utils.ts @@ -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(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index 3f6c7bd..0a82346 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -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(); 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; diff --git a/packages/core/src/providers/url.ts b/packages/core/src/providers/url.ts index e3f66b3..afb4c3a 100644 --- a/packages/core/src/providers/url.ts +++ b/packages/core/src/providers/url.ts @@ -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") || "/"; } diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index cf3f787..966a087 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -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 = { 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 () => { diff --git a/packages/electron/package.json b/packages/electron/package.json index 1e55555..6c82ac0 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -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", diff --git a/packages/electron/src/main/browser-automation-mcp.ts b/packages/electron/src/main/browser-automation-mcp.ts index 10f8558..884905f 100644 --- a/packages/electron/src/main/browser-automation-mcp.ts +++ b/packages/electron/src/main/browser-automation-mcp.ts @@ -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(promise: Promise, timeoutMs: number, message: s } } -async function captureSnapshot(webContents: WebContents, options: SnapshotOptions): Promise { +async function captureSnapshot(webContents: WebContents, options: { maxElements: number; maxText: number }): Promise { 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 { 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."; diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 062f754..a639fbc 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -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() : ""; diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts index 9d2b392..89e10f7 100644 --- a/packages/electron/src/main/preload.ts +++ b/packages/electron/src/main/preload.ts @@ -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, getAppInfo: () => invoke(IPC_CHANNELS.appGetInfo) as Promise, getConfig: () => invoke(IPC_CHANNELS.appGetConfig) as Promise, - getFilePath: (file: File) => webUtils.getPathForFile(file), getGatewayStatus: () => invoke(IPC_CHANNELS.appGetGatewayStatus) as Promise, getLocalAgentProviderCandidates: () => invoke(IPC_CHANNELS.appGetLocalAgentProviderCandidates) as Promise, getOnboardingFinished: () => invoke(IPC_CHANNELS.appGetOnboardingFinished) as Promise, @@ -134,7 +131,6 @@ contextBridge.exposeInMainWorld("ccr", { openExternal: (url: string) => invoke(IPC_CHANNELS.appOpenExternal, url) as Promise, openProfile: (request: ProfileOpenRequest) => invoke(IPC_CHANNELS.appOpenProfile, request) as Promise, prepareImageExportTarget: (request: AppImageExportTargetRequest) => invoke(IPC_CHANNELS.appPrepareImageExportTarget, request) as Promise, - probeLocalAgentProvider: (request: LocalAgentProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeLocalAgentProvider, request) as Promise, probeProviderCandidates: (request: GatewayProviderProbeCandidatesRequest) => invoke(IPC_CHANNELS.appProbeProviderCandidates, request) as Promise, probeProvider: (request: GatewayProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeProvider, request) as Promise, quitApp: () => invoke(IPC_CHANNELS.appQuit) as Promise, diff --git a/packages/ui/package.json b/packages/ui/package.json index 609fab2..7c3be31 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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": { diff --git a/packages/ui/src/assets/provider-icons/fenno.jpg b/packages/ui/src/assets/provider-icons/fenno.jpg deleted file mode 100644 index 364f735..0000000 Binary files a/packages/ui/src/assets/provider-icons/fenno.jpg and /dev/null differ diff --git a/packages/ui/src/assets/provider-icons/qiniu-ai.png b/packages/ui/src/assets/provider-icons/qiniu-ai.png deleted file mode 100644 index 2ff255d..0000000 Binary files a/packages/ui/src/assets/provider-icons/qiniu-ai.png and /dev/null differ diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index 1d971dd..3f28c94 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -46,7 +46,7 @@ function Dialog({ return ( { diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index d557b14..5bd3b5e 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -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 = { [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 | 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() {
{activeView === "onboarding" ? ( setActiveView("server")} + onDownloadUpdate={openUpdateDownloadDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 310d111..1f7f517 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3705,7 +3705,7 @@ function ToolPayloadDialog({ : undefined; return ( - !open && onClose()} open> + !open && onClose()} open>
diff --git a/packages/ui/src/pages/home/components/layout.tsx b/packages/ui/src/pages/home/components/layout.tsx index 5affef5..663d7ac 100644 --- a/packages/ui/src/pages/home/components/layout.tsx +++ b/packages/ui/src/pages/home/components/layout.tsx @@ -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; }) { return (
-
- -
{loaded ? : null}
); @@ -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 ? ( ) : null}
@@ -264,11 +238,6 @@ export function MainLayout({ gatewayStatus={gatewayStatus} />
-
void; -}) { - const t = useAppText(); - const detail = message?.trim(); - if (!detail) { - return null; - } - - return ( -
- -
-
{t("Service failed to start")}
-
{detail}
-
- {onOpenServerSettings ? ( - - ) : null} -
- ); -} - function MainViewSwitch({ activeView, agentAnalysisEnabled, diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 6228749..f62f282 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -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) => { - if (!showAppPathField) { - return; - } - event.preventDefault(); - event.stopPropagation(); - setAppPathDragActive(false); - const appPath = appPathFromDropEvent(event); - if (appPath) { - onChange({ appPath }); - } - }, [onChange, showAppPathField]); - const handleAppPathDragOver = useCallback((event: ReactDragEvent) => { - if (!showAppPathField) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setAppPathDragActive(true); - }, [showAppPathField]); - const handleAppPathDragLeave = useCallback(() => { - setAppPathDragActive(false); - }, []); return ( <> -
+
onChange({ agent })} @@ -847,20 +816,6 @@ export function AddProfileForm({ value={draft.surface} /> - {showAppPathField && appPathLabel ? ( - -
- onChange({ appPath: event.target.value })} - /> -
-
- ) : null} {draft.agent === "claude-code" ? ( <> @@ -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): 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__"; diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 2cf7b32..5f1753a 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -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
- {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 ( - -
toggleProvider(provider, index)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - toggleProvider(provider, index); - } - }} - role="button" - tabIndex={0} - > + return ( + +
toggleProvider(provider, index)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleProvider(provider, index); + } + }} + role="button" + tabIndex={0} + >
-
{provider.name || t("Unnamed")}
@@ -610,6 +608,12 @@ export function ProviderDeepLinkDialog({
) : null} + {showExternalProviderWarnings ? ( +
+ + {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")} +
+ ) : null}
@@ -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} onChange({ apiKey: event.target.value }, true)} /> +
+ + {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")} +
{safetyIssue ? (
@@ -1694,6 +1695,17 @@ export function AddProviderForm({ {advancedOpen ? (
+ {selectedPreset && !customEndpoint && mode === "add" ? ( + + onChange({ baseUrl: event.target.value }, true)} /> + + ) : null} + + + + + + {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({ }} /> {translatedProviderProtocolLabel(item.protocol, t)} - - {available ? t("Available") : t("Unavailable")} + + {item.supported ? t("Available") : t("Unavailable")}