Merge dev/3.1 into main for v3.0.18

This commit is contained in:
musistudio
2026-07-31 15:37:47 +08:00
60 changed files with 23417 additions and 8086 deletions
@@ -39,10 +39,58 @@ This lets you create multiple configs for the same agent, such as "Claude Code -
| Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. |
| Entry mode | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, Pi | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI, Kimi CLI, and Pi are fixed to `CLI only`. |
| Model | All | Default model for the opened agent, either a provider model or Fusion model. Claude Code requires this value. |
| Profile API key | All | CCR generates an independent gateway API key for each enabled profile. Requests launched from that profile carry the key's internal id, so routing can match `request.auth.apiKeyId` or `request.auth.profileId` without exposing the raw key. |
| Profile routing | All | Optional profile-level routing rules. Profile rules run before global Routing rules and apply only to requests authenticated with that profile's API key. |
| Available models | Kimi CLI | Models exposed by Kimi's `/model` command. The default model is always included. |
| Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. |
| Environment variables | All | Extra environment variables injected into this config. Claude Code includes `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` by default so gateway model discovery is enabled. |
## Profile Routing
Each Agent Profiles entry can carry its own routing policy:
| Option | Behavior |
| --- | --- |
| Enable profile routing | Turns on the profile's private rule list. Disabled profile routing keeps the profile usable but skips its private rules. |
| Use enhanced route | Claude Code and Codex only. A profile-level switch for CCR's built-in Claude Code / Codex routing enhancements. It is independent from the private rule list, so each profile can enable or disable the built-in route separately. |
| Profile routes | Uses condition-based rules. Rules can match `request.header`, `request.body`, or `request.auth`, and can rewrite the request model or other fields. Node.js script rules are only supported on the global Routing page. |
Profile rules are evaluated before the global Routing page rules. They only see traffic from their own profile API key, so two profiles can use the same client model name and still route differently. For example, a "Claude Code - Work" profile can send image-heavy requests to a Fusion vision model while a "Claude Code - Low Cost" profile sends the same request shape to a cheaper provider.
For hand-written config, the shape is stored on the profile as `routing`:
```json
{
"id": "claude-work",
"agent": "claude-code",
"model": "Anthropic/claude-sonnet",
"routing": {
"enabled": true,
"enhancedRoute": true,
"rules": [
{
"id": "work-images",
"name": "Work images",
"enabled": true,
"type": "condition",
"condition": {
"left": "request.body.messages",
"operator": "contains-deep",
"right": "image"
},
"rewrites": [
{
"op": "set",
"key": "request.body.model",
"value": "Fusion/vision"
}
]
}
]
}
}
```
## Per-Agent Options
### Claude Code
@@ -7,6 +7,8 @@ lead: Choose the model for a request, then automatically retry or switch to fall
## Built-In Routing
Claude Code and Codex built-in routes are controlled per Agent Profile through **Use enhanced route**. They do not appear in the global Routing page rule list.
### Claude Code
The built-in Claude Code route detects requests from Claude Code and routes main requests to the Claude Code Agent Profiles model when the client has not selected a recognized model.
@@ -40,7 +42,7 @@ Recommended setup:
1. Add usable models under **Providers**, and verify that the model IDs can be requested.
2. Open **Models** and fill Description for the models you want Subagents to choose automatically. Describe task fit, speed, cost, and limits.
3. Enable a Claude Code config under **Agent Profiles**, and choose the default model. Claude Code uses it when the client has not selected a recognized model.
4. Confirm that the built-in **Claude Code** route is enabled on the **Routing** page.
4. In **Agent Profiles**, keep **Use enhanced route** enabled for that Claude Code profile.
5. Use Agent, Task, or Workflow in Claude Code. When Claude Code spawns an agent, it can choose a CCR model from the descriptions and write the tag.
Write descriptions around tasks instead of only naming the provider. For example:
@@ -59,7 +61,7 @@ CCR automatically adapts Codex's `apply_patch` file-editing tool for third-party
Technically, this is a tool protocol bridge. Native Codex `apply_patch` is a custom/freeform tool whose input is raw patch text, while many OpenAI-compatible third-party models handle ordinary function tools more reliably. CCR rewrites `apply_patch` into an upstream-visible `virtual_apply_patch` function tool and injects the full `apply_patch.lark` grammar into the tool description, requiring the model to put the patch in the `patch` field.
When the model returns `virtual_apply_patch`, CCR rewrites it back to Codex's expected shape: `custom_tool_call` with `name = apply_patch` and `input = raw patch text`. CCR does not edit files directly; Codex still executes the resulting patch. This adaptation is enabled automatically for non-GPT models and is independent of the built-in **Codex** routing switch. GPT-named models, including Fusion models whose resolved base model is GPT, keep using Codex's native freeform `apply_patch` path.
When the model returns `virtual_apply_patch`, CCR rewrites it back to Codex's expected shape: `custom_tool_call` with `name = apply_patch` and `input = raw patch text`. CCR does not edit files directly; Codex still executes the resulting patch. This adaptation is enabled automatically for non-GPT models and is independent of the profile-level **Use enhanced route** switch. GPT-named models, including Fusion models whose resolved base model is GPT, keep using Codex's native freeform `apply_patch` path.
## Custom Routing
@@ -67,6 +69,8 @@ Custom routes are configured in the Routing page rule list. The top **Search rou
Custom rules match in list order, and the first enabled matching rule rewrites the request. Use the move up and move down buttons to adjust priority. Use the edit button to open **Edit Routing Rule**, and the delete button to open a confirmation dialog. Turning off the **Status** toggle keeps the rule in the list but removes it from matching.
Agent Profiles can also define private routing rules. Profile rules use the same rule shape, but CCR evaluates them before the global list and only for requests authenticated with that profile's generated API key. Inside rule conditions and scripts, use `request.auth.apiKeyId` or `request.auth.profileId` when a policy must explicitly match the caller profile.
### Add Or Edit A Rule
The dialog fields map directly to the saved rule:
@@ -74,7 +78,7 @@ The dialog fields map directly to the saved rule:
| UI field | How to fill it | Saved meaning |
| --- | --- | --- |
| **Name** | Enter a recognizable rule name. This field is required. | Shown in the **Name** column and included in search. |
| **Condition** | Choose `request.header` or `request.body`, then fill in field, operator, and value. | Builds `condition.left`, `condition.operator`, and `condition.right`. |
| **Condition** | Choose `request.header`, `request.body`, or `request.auth`, then fill in field, operator, and value. | Builds `condition.left`, `condition.operator`, and `condition.right`. |
| **Rewrite request parameters** | Keep at least one rewrite row. Each row chooses an operation, target key, and required value fields. | Builds `rewrites`, applied when the rule matches. |
| **Enabled** | Turn the rule on or off. | Controls `enabled`; disabled rules do not match. |
| **On failure** | Configure fallback behavior for this rule. | Overrides **Default on failure** when this rule matches. |
@@ -126,6 +130,7 @@ Each execution receives its own read-only `input` object:
| `input.tokenCount` | `number` | CCR's estimated input token count, or `0` when unavailable. |
| `input.sessionId` | `string \| undefined` | Session ID when CCR can resolve it. |
| `input.apiKeyId` | `string \| undefined` | CCR API-key identifier from `x-auth-api-key-id`; this is not the raw key. |
| `input.profileId` | `string \| undefined` | The authenticated Agent Profile's configured `id`, such as `claude-work`. |
| `input.builtInSubagentModel` | `string \| undefined` | Built-in subagent model when CCR can identify it. |
| `input.summary.lastUserText` | `string` | Text from the last user message, limited to 16 KiB characters. |
| `input.summary.systemText` | `string` | Text from the system content, limited to 8 KiB characters. |
@@ -419,9 +424,12 @@ The **Condition** area has four controls: source, field, operator, and value.
| --- | --- | --- |
| `request.header` | `user-agent`, `x-api-key`, `x-client-name` | `request.header.user-agent` |
| `request.body` | `model`, `messages`, `messages.0.role`, `tools` | `request.body.model` |
| `request.auth` | `apiKeyId`, `profileId` | `request.auth.profileId` |
Header names are case-insensitive. Body fields use dot-path lookup, and numeric segments address array indexes; for example, `messages.0.role` reads the first message role. For nested arrays such as `messages` or `tools`, `contains deep` is usually more robust than a fixed index.
`request.auth.apiKeyId` is CCR's internal authenticated key id, not the raw API key. Agent Profile keys use the `profile:<sanitized-profile-id>` form, while `request.auth.profileId` exposes the authenticated profile's configured `id`. This makes it possible to route one profile differently from another without matching a secret value.
The value field is parsed as a common literal when possible: `true`, `false`, `null`, numbers, JSON objects, and JSON arrays compare as their corresponding types. Other input is treated as a string. To force a value to stay string-like, wrap it as `"123"` or `'123'`.
| Operator | Use |
@@ -461,6 +469,7 @@ When a rule matches, its **On failure** setting is used. Requests that do not ma
| Goal | Condition source | Field | Operator | Value | Rewrite request parameters |
| --- | --- | --- | --- | --- | --- |
| Route by client header | `request.header` | `x-client-name` | `==` | `claude-code` | **Set** `request.body.model = provider/model` |
| Route one profile | `request.auth` | `profileId` | `==` | `claude-work` | **Set** `request.body.model = provider/model` |
| Route by original model prefix | `request.body` | `model` | `starts with` | `claude-` | **Set** `request.body.model = provider/model` |
| Route message content to a vision model | `request.body` | `messages` | `contains deep` | `image` | **Set** `request.body.model = vision-provider/model` |
| Remove a debug header | `request.header` | `x-debug-route` | `==` | `1` | **Delete** `request.header.x-debug-route` |
@@ -471,7 +480,7 @@ After saving, the rule appears in the list. Use request logs, especially `reques
Fallback is the failure strategy after a model or upstream request fails. Routing picks the first model; Fallback decides whether CCR should keep trying after the current target fails.
The **Default on failure** control at the top of the Routing page is the global Fallback. Each rule also has **On failure**. When a rule matches, its rule-level Fallback overrides the global Fallback.
The **Default on failure** control at the top of the Routing page is the global Fallback. Each rule also has **On failure**. When a rule matches, its rule-level Fallback overrides the global Fallback. Agent Profile routing does not define a separate profile-level fallback.
## Fallback Modes
@@ -39,10 +39,58 @@ lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、Pi、ZCode 创建可复用
| 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 |
| 入口模式 | Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI、Pi | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI、Kimi CLI 和 Pi 固定为 `CLI only`。 |
| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 必须填写该值。 |
| 配置专属 API Key | 全部 | CCR 会为每个启用的配置生成独立网关 API Key。通过该配置启动的请求会携带内部 key id,因此路由可以匹配 `request.auth.apiKeyId``request.auth.profileId`,不需要暴露原始密钥。 |
| 配置级路由 | 全部 | 可选的配置专属路由规则。配置级规则在全局路由规则之前执行,只作用于使用该配置 API Key 鉴权的请求。 |
| 可用模型 | Kimi CLI | Kimi `/model` 命令中可切换的模型;默认模型始终包含在内。 |
| Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 |
| 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 |
## 配置级路由
每个 Agent 配置档案都可以带自己的路由策略:
| 配置项 | 行为 |
| --- | --- |
| 启用配置级路由 | 打开该配置的私有规则列表。关闭后配置仍可使用,但不会执行私有规则。 |
| 使用增强路由 | 仅 Claude Code 和 Codex 显示。它是配置档案级别的内置 Claude Code / Codex 路由增强开关,独立于私有规则列表,因此每个配置档案都可以单独开启或关闭内置路由。 |
| 配置级路由规则 | 使用条件规则。规则可以匹配 `request.header``request.body``request.auth`,并改写请求模型或其他字段。Node.js 脚本规则只在全局路由页支持。 |
配置级规则会先于全局路由页规则执行。它们只看到自己的配置 API Key 发来的流量,因此两个配置即使用同一个客户端模型名,也可以走不同分流。例如,“Claude Code - 工作”可以把带图片的请求发到 Fusion 视觉模型,而“Claude Code - 低成本”可以把同样形态的请求发到更便宜的供应商。
手写配置时,路由策略保存在 profile 的 `routing` 字段:
```json
{
"id": "claude-work",
"agent": "claude-code",
"model": "Anthropic/claude-sonnet",
"routing": {
"enabled": true,
"enhancedRoute": true,
"rules": [
{
"id": "work-images",
"name": "Work images",
"enabled": true,
"type": "condition",
"condition": {
"left": "request.body.messages",
"operator": "contains-deep",
"right": "image"
},
"rewrites": [
{
"op": "set",
"key": "request.body.model",
"value": "Fusion/vision"
}
]
}
]
}
}
```
## 各 Agent 的配置项
### Claude Code
@@ -7,6 +7,8 @@ lead: 设置请求如何选择模型,并在失败时通过 Fallback 自动重
## 内置路由
Claude Code 和 Codex 内置路由通过 Agent 配置档案里的 **使用增强路由** 单独控制,不会出现在全局路由页的规则列表中。
### Claude Code
Claude Code 内置路由的作用是识别 Claude Code 发来的请求,并在客户端没有选择可识别模型时,把主请求路由到 Claude Code Agent 配置中的模型。
@@ -40,7 +42,7 @@ Claude Code 的 Agent / Task / Workflow 可以派生新的模型请求。CCR 使
1.**供应商** 中添加可用模型,确认模型 ID 可以真实请求。
2. 打开 **模型** 页面,为希望 Subagent 自动选择的模型填写 Description。说明要写清模型适合的任务、速度、成本和限制。
3.**Agent 配置档案** 中启用 Claude Code 配置,并设置默认模型。Claude Code 未选择可识别模型时会使用它。
4.**路由** 页面确认 **Claude Code** 内置路由已启用
4.**Agent 配置档案** 中保持该 Claude Code 配置的 **使用增强路由** 开启
5. 在 Claude Code 中使用 Agent、Task 或 Workflow。需要派生 Agent 时,Claude Code 会根据模型 Description 选择一个 CCR 模型并写入标签。
Description 建议写成任务导向,而不是只写模型厂商名。例如:
@@ -59,7 +61,7 @@ CCR 会自动为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件
技术原理是做一次工具协议桥接:Codex 原生的 `apply_patch` 是 custom/freeform 工具,入参是原始 patch 文本;很多 OpenAI-compatible 三方模型更擅长普通 function tool。CCR 会在上游请求中把 `apply_patch` 转成 `virtual_apply_patch` function tool,并在工具说明里注入完整的 `apply_patch.lark` 语法,要求模型把 patch 写入 `patch` 字段。
模型返回 `virtual_apply_patch` 后,CCR 会把它转换回 Codex 期望的 `custom_tool_call``name = apply_patch``input = 原始 patch 文本`。CCR 不直接修改文件,真正执行 patch 的仍然是 Codex 客户端。这个适配会对非 GPT 模型自动启用,不受 **Codex** 内置路由开关影响;GPT 命名模型以及实际基模为 GPT 的 Fusion 模型继续使用 Codex 原生 freeform `apply_patch` 路径。
模型返回 `virtual_apply_patch` 后,CCR 会把它转换回 Codex 期望的 `custom_tool_call``name = apply_patch``input = 原始 patch 文本`。CCR 不直接修改文件,真正执行 patch 的仍然是 Codex 客户端。这个适配会对非 GPT 模型自动启用,不受配置档案级 **使用增强路由** 开关影响;GPT 命名模型以及实际基模为 GPT 的 Fusion 模型继续使用 Codex 原生 freeform `apply_patch` 路径。
## 自定义路由
@@ -67,6 +69,8 @@ CCR 会自动为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件
自定义规则按列表顺序匹配,第一条命中的启用规则会改写请求。表格右侧的上移、下移按钮用来调整优先级,编辑按钮打开 **编辑路由规则**,删除按钮会先弹出确认框。**状态** 列的开关关闭后,规则保留在列表里,但不会参与匹配。
Agent 配置档案也可以定义私有路由规则。配置级规则使用同一套规则结构,但 CCR 会先于全局规则列表执行它们,并且只对使用该配置生成的 API Key 鉴权的请求生效。需要显式匹配调用方配置时,可以在规则条件或脚本中使用 `request.auth.apiKeyId``request.auth.profileId`
### 添加或编辑规则
弹窗里的字段和保存后的配置一一对应:
@@ -74,7 +78,7 @@ CCR 会自动为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件
| UI 字段 | 填写方式 | 保存后的含义 |
| --- | --- | --- |
| **名称** | 填一个便于识别的规则名。该字段不能为空。 | 显示在列表 **名称** 列,也参与搜索。 |
| **条件** | 选择 `request.header``request.body`,填写字段名、操作符和值。 | 生成 `condition.left``condition.operator``condition.right`。 |
| **条件** | 选择 `request.header``request.body``request.auth`,填写字段名、操作符和值。 | 生成 `condition.left``condition.operator``condition.right`。 |
| **改写请求参数** | 至少保留一行 rewrite。每行选择操作、目标 key 和需要的值。 | 生成 `rewrites`,规则命中后按行改写请求。 |
| **启用** | 打开或关闭规则。 | 控制 `enabled`,关闭时不会匹配。 |
| **失败时** | 配置这条规则自己的 Fallback。 | 规则命中后覆盖页面顶部的 **默认失败处理**。 |
@@ -126,6 +130,7 @@ return {
| `input.tokenCount` | `number` | CCR 估算的输入 Token 数;无法估算时为 `0`。 |
| `input.sessionId` | `string \| undefined` | CCR 能解析到的会话 ID。 |
| `input.apiKeyId` | `string \| undefined` | `x-auth-api-key-id` Header 中的 CCR API Key 标识,不是原始密钥。 |
| `input.profileId` | `string \| undefined` | 已鉴权 Agent 配置档案中配置的 `id`,例如 `claude-work`。 |
| `input.builtInSubagentModel` | `string \| undefined` | CCR 能识别到的内置子代理模型。 |
| `input.summary.lastUserText` | `string` | 最后一条用户消息的文本,最多 16 KiB 字符。 |
| `input.summary.systemText` | `string` | System 内容的文本,最多 8 KiB 字符。 |
@@ -419,9 +424,12 @@ Worker 隔离不是操作系统级安全沙箱。`api.fetch`、`api.fs` 和 `api
| --- | --- | --- |
| `request.header` | `user-agent``x-api-key``x-client-name` | `request.header.user-agent` |
| `request.body` | `model``messages``messages.0.role``tools` | `request.body.model` |
| `request.auth` | `apiKeyId``profileId` | `request.auth.profileId` |
Header 名不区分大小写。Body 字段按点号路径读取,数字片段表示数组下标;例如 `messages.0.role` 读取第一条 message 的 role。对于 messages、tools 这类嵌套数组,通常用 `contains deep` 比固定下标更稳。
`request.auth.apiKeyId` 是 CCR 的内部鉴权 key id,不是原始 API Key。Agent 配置档案生成的 key 使用 `profile:<sanitized-profile-id>` 形式,而 `request.auth.profileId` 会暴露已鉴权配置档案中配置的原始 `id`。这样可以按配置分流,而不需要匹配密钥值。
值输入框会按常见字面量解析:`true``false``null`、数字、JSON 对象或数组会按对应类型比较;其他内容按字符串处理。需要强制作为字符串时,可以写成 `"123"``'123'`
| 操作符 | 用法 |
@@ -461,6 +469,7 @@ Rewrite 的值也会按字面量解析,所以 `0.2` 会变成数字,`true`
| 目标 | 条件来源 | 字段 | 操作符 | 值 | 改写请求参数 |
| --- | --- | --- | --- | --- | --- |
| 按客户端 Header 分流 | `request.header` | `x-client-name` | `==` | `claude-code` | **设置** `request.body.model = 供应商/模型` |
| 按单个配置分流 | `request.auth` | `profileId` | `==` | `claude-work` | **设置** `request.body.model = 供应商/模型` |
| 按原始模型前缀分流 | `request.body` | `model` | `starts with` | `claude-` | **设置** `request.body.model = 供应商/模型` |
| 按消息内容分流到视觉模型 | `request.body` | `messages` | `contains deep` | `image` | **设置** `request.body.model = 视觉供应商/模型` |
| 删除调试 Header | `request.header` | `x-debug-route` | `==` | `1` | **删除** `request.header.x-debug-route` |
@@ -471,7 +480,7 @@ Rewrite 的值也会按字面量解析,所以 `0.2` 会变成数字,`true`
Fallback 处理请求失败后的降级。第一次选模型由路由完成;当前模型或上游失败时,Fallback 决定是否继续尝试。
路由页面顶部的 **默认失败处理** 是全局 Fallback。每条路由规则里的 **失败时** 是规则级 Fallback:当某条规则命中时,规则级配置会覆盖全局配置。
路由页面顶部的 **默认失败处理** 是全局 Fallback。每条路由规则里的 **失败时** 是规则级 Fallback:当某条规则命中时,规则级配置会覆盖全局配置。Agent 配置档案的配置级路由不再定义单独的配置级 Fallback。
## Fallback 模式
+6 -6
View File
@@ -12,7 +12,7 @@
"packages/*"
],
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.15",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
@@ -2311,9 +2311,9 @@
}
},
"node_modules/@the-next-ai/ai-gateway": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.14.tgz",
"integrity": "sha512-ZVilhuxEoMxvMdPlVI55q6wm6JfWJyKLh9fS5uIAMZtc6Zzc0vf21coLYoP/hsqoNIQCchzkFgD5I2rHDG1QNA==",
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.15.tgz",
"integrity": "sha512-U5SnIBGXHVq0uzzljpUb/hvND5cGehzMZegtvnB8+pRsRTM19yrAsPSaUocolB7JYun7TlKhi+BdOWp5Az17gA==",
"license": "MIT",
"dependencies": {
"diff": "^8.0.3",
@@ -9546,7 +9546,7 @@
"version": "3.0.17",
"license": "MIT",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.15",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@@ -9563,7 +9563,7 @@
"name": "@claude-code-router/core",
"version": "3.0.17",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.15",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
+1 -1
View File
@@ -72,7 +72,7 @@
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.15",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
+1 -1
View File
@@ -41,7 +41,7 @@
"test:integration": "node ../../build/test.mjs cli --scope integration && node ../../build/run-tests.mjs cli"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.15",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
+19328 -7492
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@
"test:integration": "node ../../build/test.mjs core --scope integration && node ../../build/run-tests.mjs core"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.15",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@@ -1,7 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { CONFIGDIR } from "@ccr/core/config/constants";
import type {
BotGatewayQrLoginCancelRequest,
@@ -12,6 +11,7 @@ import type {
BotGatewayQrLoginWaitResult,
BotGatewayRuntimeConfig
} from "@ccr/core/contracts/app";
import { botGatewaySdkImportSpecifier } from "./sdk-import";
type BotGatewayClientWithRequest = {
close?: () => Promise<void> | void;
@@ -276,17 +276,6 @@ function resolveBundledBotGatewaySdkModule(): string {
return candidates.find((candidate) => existsSync(candidate)) ?? "";
}
function botGatewaySdkImportSpecifier(value: string): string {
const trimmed = value.trim();
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
return trimmed;
}
if (path.isAbsolute(trimmed)) {
return pathToFileURL(trimmed).href;
}
return trimmed;
}
async function resolveWeixinQrIntegrationId(
client: BotGatewayClientWithRequest,
bot: BotGatewayRuntimeConfig,
@@ -0,0 +1,21 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
const BOT_GATEWAY_SDK_PACKAGE = "@the-next-ai/bot-gateway-sdk";
export function botGatewaySdkImportSpecifier(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return BOT_GATEWAY_SDK_PACKAGE;
}
if (path.isAbsolute(trimmed)) {
return pathToFileURL(trimmed).href;
}
if (path.win32.isAbsolute(trimmed)) {
return pathToFileURL(trimmed, { windows: true }).href;
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
return trimmed;
}
return trimmed;
}
@@ -5,6 +5,7 @@ import path from "node:path";
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "@ccr/core/agents/claude-app/cdp";
import { prepareClaudeAppVmStorage } from "@ccr/core/agents/claude-app/vm-storage";
import { claudeCodeModelEnv as claudeCodeProfileModelEnv, claudeCodeUtcTimezoneEnvOverride, isClaudeCodeManagedModelEnvKey } from "@ccr/core/agents/claude-code/environment";
import { resolveClaudeCodeSettingsFile } from "@ccr/core/profiles/launch-core";
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
@@ -52,6 +53,10 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
const settingsDir = path.dirname(settingsFile);
const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile);
mkdirSync(userDataDir, { recursive: true });
const vmStorage = prepareClaudeAppVmStorage(configDir, userDataDir);
if (vmStorage.action === "skipped" && vmStorage.reason === "clone-failed") {
console.warn(`[profile] Failed to clone Claude App VM seed for ${profile.name || profile.id}. Claude App may rebuild its VM in ${vmStorage.targetBundleDir}.`);
}
prepareClaudeAppCdpUserDataDir(userDataDir);
const shouldOpenDesign = shouldOpenClaudeAppDesign(config);
const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign);
@@ -0,0 +1,408 @@
import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, chmodSync, renameSync } from "node:fs";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { resolveRuntimeAppPath } from "@ccr/core/runtime/app-paths";
const claudeAppVmBundlesDir = "vm_bundles";
const claudeAppVmBundleName = "claudevm.bundle";
const claudeAppVmSeedEnv = "CCR_CLAUDE_APP_VM_SEED_DIR";
const claudeAppVmSeedDisabledEnv = "CCR_CLAUDE_APP_VM_SEED_DISABLED";
const maxSmallFileCopyBytes = 64 * 1024 * 1024;
const lockRetryIntervalMs = 100;
const lockTimeoutMs = 15_000;
const staleLockMs = 10 * 60_000;
export type ClaudeAppVmStoragePrepareResult =
| {
action: "prepared";
seedBundleDir: string;
sourceBundleDir: string;
targetBundleDir: string;
}
| {
action: "skipped";
reason: string;
targetBundleDir: string;
};
export function prepareClaudeAppVmStorage(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult {
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
if (isDisabledEnv(process.env[claudeAppVmSeedDisabledEnv])) {
return { action: "skipped", reason: "disabled", targetBundleDir };
}
return withVmStorageLock(configDir, targetBundleDir, () => prepareClaudeAppVmStorageLocked(configDir, userDataDir));
}
function prepareClaudeAppVmStorageLocked(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult {
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
if (isUsableClaudeAppVmBundle(targetBundleDir)) {
return { action: "skipped", reason: "target-present", targetBundleDir };
}
const sourceBundleDir = findClaudeAppVmSeedBundle(configDir, userDataDir);
if (!sourceBundleDir) {
return { action: "skipped", reason: "no-seed", targetBundleDir };
}
const seedBundleDir = ensureSharedClaudeAppVmSeed(configDir, sourceBundleDir, targetBundleDir);
if (!seedBundleDir || samePath(seedBundleDir, targetBundleDir)) {
return { action: "skipped", reason: "seed-unavailable", targetBundleDir };
}
if (!prepareTargetBundlePath(targetBundleDir)) {
return { action: "skipped", reason: "target-not-replaceable", targetBundleDir };
}
try {
cloneDirectory(seedBundleDir, targetBundleDir);
return {
action: "prepared",
seedBundleDir,
sourceBundleDir,
targetBundleDir
};
} catch {
rmSync(targetBundleDir, { force: true, recursive: true });
return { action: "skipped", reason: "clone-failed", targetBundleDir };
}
}
function withVmStorageLock(
configDir: string,
targetBundleDir: string,
run: () => ClaudeAppVmStoragePrepareResult
): ClaudeAppVmStoragePrepareResult {
const lockDir = path.join(configDir, "app-cache", "claude-app", ".vm-storage.lock");
if (!acquireDirectoryLock(lockDir)) {
return { action: "skipped", reason: "lock-timeout", targetBundleDir };
}
try {
return run();
} finally {
rmSync(lockDir, { force: true, recursive: true });
}
}
function acquireDirectoryLock(lockDir: string): boolean {
const deadline = Date.now() + lockTimeoutMs;
mkdirSync(path.dirname(lockDir), { mode: 0o700, recursive: true });
while (Date.now() < deadline) {
try {
mkdirSync(lockDir, { mode: 0o700 });
return true;
} catch {
removeStaleLock(lockDir);
sleepSync(lockRetryIntervalMs);
}
}
return false;
}
function removeStaleLock(lockDir: string): void {
try {
const stat = statSync(lockDir);
if (Date.now() - stat.mtimeMs > staleLockMs) {
rmSync(lockDir, { force: true, recursive: true });
}
} catch {
// Another process may have released the lock between attempts.
}
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
export function resolveClaudeAppVmBundleDir(userDataDir: string): string {
return path.join(userDataDir, claudeAppVmBundlesDir, claudeAppVmBundleName);
}
export function resolveSharedClaudeAppVmSeedBundleDir(configDir: string): string {
return path.join(configDir, "app-cache", "claude-app", "vm-seeds", claudeAppVmBundleName);
}
export function resolveClaudeAppDefaultUserDataDirs(): string[] {
if (process.platform === "darwin") {
const applicationSupport = path.join(resolveRuntimeAppPath("home"), "Library", "Application Support");
return [
path.join(applicationSupport, "Claude"),
path.join(applicationSupport, "Claude Desktop"),
path.join(applicationSupport, "Claude-3p")
];
}
if (process.platform === "win32") {
const roaming = resolveRuntimeAppPath("appData");
const local = process.env.LOCALAPPDATA || path.join(roaming, "..", "Local");
return [
path.join(roaming, "Claude"),
path.join(roaming, "Claude Desktop"),
path.join(local, "Claude"),
path.join(local, "Claude Desktop"),
path.join(local, "Claude-3p")
];
}
const configHome = resolveRuntimeAppPath("appData");
return [
path.join(configHome, "Claude"),
path.join(configHome, "claude"),
path.join(configHome, "Claude Desktop"),
path.join(configHome, "claude-desktop"),
path.join(configHome, "Claude-3p")
];
}
function findClaudeAppVmSeedBundle(configDir: string, userDataDir: string): string | undefined {
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
return uniqueStrings([
...configuredSeedBundleCandidates(),
resolveSharedClaudeAppVmSeedBundleDir(configDir),
...resolveClaudeAppDefaultUserDataDirs().map(resolveClaudeAppVmBundleDir),
...existingCcrProfileVmBundleCandidates(configDir)
]).find((candidate) =>
!samePath(candidate, targetBundleDir) &&
isUsableClaudeAppVmBundle(candidate)
);
}
function configuredSeedBundleCandidates(): string[] {
const configured = process.env[claudeAppVmSeedEnv]?.trim();
if (!configured) {
return [];
}
return configured
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.flatMap((entry) => {
const resolved = resolveUserPath(entry);
return path.basename(resolved) === claudeAppVmBundleName
? [resolved]
: [resolved, resolveClaudeAppVmBundleDir(resolved)];
});
}
function existingCcrProfileVmBundleCandidates(configDir: string): string[] {
const profilesDir = path.join(configDir, "profiles");
if (!isDirectory(profilesDir)) {
return [];
}
const results: string[] = [];
const pending: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: profilesDir }];
while (pending.length > 0) {
const current = pending.shift();
if (!current || current.depth > 8) {
continue;
}
for (const entry of readDirEntries(current.dir)) {
const file = path.join(current.dir, entry);
if (entry === claudeAppVmBundleName && isDirectory(file) && file.includes(`${path.sep}${claudeAppVmBundlesDir}${path.sep}`)) {
results.push(file);
continue;
}
if (isDirectory(file)) {
pending.push({ depth: current.depth + 1, dir: file });
}
}
}
return results;
}
function ensureSharedClaudeAppVmSeed(
configDir: string,
sourceBundleDir: string,
targetBundleDir: string
): string | undefined {
const seedBundleDir = resolveSharedClaudeAppVmSeedBundleDir(configDir);
if (isUsableClaudeAppVmBundle(seedBundleDir)) {
return seedBundleDir;
}
if (samePath(sourceBundleDir, seedBundleDir)) {
return isUsableClaudeAppVmBundle(sourceBundleDir) ? sourceBundleDir : undefined;
}
if (!prepareTargetBundlePath(seedBundleDir)) {
return sourceBundleDir;
}
try {
cloneDirectory(sourceBundleDir, seedBundleDir);
return seedBundleDir;
} catch {
rmSync(seedBundleDir, { force: true, recursive: true });
return samePath(sourceBundleDir, targetBundleDir) ? undefined : sourceBundleDir;
}
}
function prepareTargetBundlePath(bundleDir: string): boolean {
if (!pathEntryExists(bundleDir)) {
mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true });
return true;
}
if (!isReplaceableIncompleteBundle(bundleDir)) {
return false;
}
rmSync(bundleDir, { force: true, recursive: true });
mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true });
return true;
}
function isUsableClaudeAppVmBundle(bundleDir: string): boolean {
return isDirectory(bundleDir) && (
existsSync(path.join(bundleDir, "rootfs.img")) ||
existsSync(path.join(bundleDir, "rootfs.img.zst"))
);
}
function isReplaceableIncompleteBundle(bundleDir: string): boolean {
if (!isDirectory(bundleDir)) {
return isSymlink(bundleDir);
}
const entries = readDirEntries(bundleDir);
if (entries.length === 0) {
return true;
}
return entries.every((entry) =>
entry === ".cowork-adopted" ||
entry === ".DS_Store" ||
entry.startsWith(".wvm-tmp-")
);
}
function cloneDirectory(sourceDir: string, targetDir: string): void {
const parentDir = path.dirname(targetDir);
const tempDir = path.join(parentDir, `.${path.basename(targetDir)}.ccr-clone-${process.pid}-${Date.now()}`);
rmSync(tempDir, { force: true, recursive: true });
try {
copyDirectoryContents(sourceDir, tempDir);
rmSync(targetDir, { force: true, recursive: true });
renameSync(tempDir, targetDir);
} catch (error) {
rmSync(tempDir, { force: true, recursive: true });
throw error;
}
}
function copyDirectoryContents(sourceDir: string, targetDir: string): void {
const sourceStat = statSync(sourceDir);
mkdirSync(targetDir, { mode: sourceStat.mode & 0o777, recursive: true });
chmodBestEffort(targetDir, sourceStat.mode & 0o777);
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const source = path.join(sourceDir, entry.name);
const target = path.join(targetDir, entry.name);
const sourceEntryStat = lstatSync(source);
if (sourceEntryStat.isSymbolicLink()) {
symlinkSync(readlinkSync(source), target);
continue;
}
if (sourceEntryStat.isDirectory()) {
copyDirectoryContents(source, target);
continue;
}
if (sourceEntryStat.isFile()) {
copyFileWithClone(source, target, sourceEntryStat.size);
chmodBestEffort(target, sourceEntryStat.mode & 0o777);
}
}
}
function copyFileWithClone(source: string, target: string, size: number): void {
if (process.platform === "darwin" && cloneFileWithMacCp(source, target)) {
return;
}
try {
copyFileSync(source, target, constants.COPYFILE_FICLONE_FORCE);
return;
} catch {
if (size > maxSmallFileCopyBytes) {
throw new Error(`Copy-on-write clone is not available for ${source}.`);
}
}
copyFileSync(source, target);
}
function cloneFileWithMacCp(source: string, target: string): boolean {
const result = spawnSync("/bin/cp", ["-c", source, target], {
stdio: "ignore"
});
return result.status === 0;
}
function pathEntryExists(file: string): boolean {
try {
lstatSync(file);
return true;
} catch {
return false;
}
}
function isDirectory(file: string): boolean {
try {
return statSync(file).isDirectory();
} catch {
return false;
}
}
function isSymlink(file: string): boolean {
try {
return lstatSync(file).isSymbolicLink();
} catch {
return false;
}
}
function readDirEntries(dir: string): string[] {
try {
return readdirSync(dir);
} catch {
return [];
}
}
function samePath(left: string, right: string): boolean {
return normalizeComparablePath(left) === normalizeComparablePath(right);
}
function normalizeComparablePath(value: string): string {
const normalized = path.resolve(value);
return process.platform === "win32" ? normalized.replace(/\\/g, "/").toLowerCase() : normalized;
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = normalizeComparablePath(value);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(value);
}
return result;
}
function resolveUserPath(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return path.resolve(value);
}
function chmodBestEffort(file: string, mode: number): void {
try {
chmodSync(file, mode);
} catch {
// File permissions are best-effort across platforms.
}
}
function isDisabledEnv(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
@@ -5678,8 +5678,9 @@ function bundledBotGatewaySdkModule() {
function botGatewaySdkImportSpecifier(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return "@the-next-ai/bot-gateway-sdk";
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
if (path.isAbsolute(trimmed)) return pathToFileURL(trimmed).href;
if (path.win32.isAbsolute(trimmed)) return pathToFileURL(trimmed, { windows: true }).href;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
return trimmed;
}
+130 -14
View File
@@ -13,7 +13,7 @@ import { LEGACY_ACTIVE_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FI
import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex";
import { normalizeGrokProviderAccountConfig, normalizeGrokProviderMediaCapabilities } from "@ccr/core/agents/local-providers/grok";
import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-providers/opencode";
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, DEFAULT_TRAY_COMPONENT_VARIANTS, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent, knownGatewayPluginDefaultApps, knownGatewayPluginDefaultPermissions, knownGatewayPluginDefaultSurfaces } from "@ccr/core/contracts/app";
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, DEFAULT_TRAY_COMPONENT_VARIANTS, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent, isEnabledGlobalProfile, knownGatewayPluginDefaultApps, knownGatewayPluginDefaultPermissions, knownGatewayPluginDefaultSurfaces } from "@ccr/core/contracts/app";
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
import { maxRequestLogBodyBytes } from "@ccr/core/observability/request-log-limits";
import { findProviderPresetByBaseUrl, primaryProviderPresetEndpoint, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
@@ -53,6 +53,7 @@ import type {
ProviderModelPricing,
ProviderReasoningLevel,
ProfileConfig,
ProfileRoutingConfig,
ProfileRuntimeConfig,
ProxyRouteTarget,
ProxyRuntimeConfig,
@@ -419,13 +420,83 @@ function enqueueAppConfigWrite<T>(operation: () => Promise<T>): Promise<T> {
}
function withSingleEnabledGlobalProfiles(config: AppConfig): AppConfig {
const profiles = enforceSingleEnabledGlobalProfilePerAgent(config.profile.profiles);
return {
...config,
Providers: config.Providers.map(normalizeProviderPresetCapabilities),
profile: {
profile: synchronizeLegacyProfileConfig({
...config.profile,
profiles: enforceSingleEnabledGlobalProfilePerAgent(config.profile.profiles)
}
profiles
})
};
}
function synchronizeLegacyProfileConfig(profile: AppConfig["profile"]): AppConfig["profile"] {
const profiles = enforceSingleEnabledGlobalProfilePerAgent(profile.profiles);
const profileEnabled = profile.enabled !== false && profiles.some((item) => item.enabled);
const claudeCodeProfile = profileEnabled ? activeGlobalProfile(profiles, "claude-code") : undefined;
const codexProfile = profileEnabled ? activeGlobalProfile(profiles, "codex") : undefined;
return {
...profile,
enabled: profileEnabled,
claudeCode: synchronizeLegacyClaudeCodeProfile(profile.claudeCode, claudeCodeProfile),
codex: synchronizeLegacyCodexProfile(profile.codex, codexProfile),
profiles
};
}
function activeGlobalProfile(profiles: ProfileConfig[], agent: ProfileConfig["agent"]): ProfileConfig | undefined {
return profiles.find((profile) => profile.agent === agent && isEnabledGlobalProfile(profile));
}
function synchronizeLegacyClaudeCodeProfile(
legacy: ClaudeCodeProfileConfig,
profile: ProfileConfig | undefined
): ClaudeCodeProfileConfig {
if (!profile) {
return {
...legacy,
enabled: false
};
}
return {
...legacy,
enabled: true,
fableModel: profile.fableModel ?? legacy.fableModel,
haikuModel: profile.haikuModel ?? legacy.haikuModel,
managedCompact: profile.managedCompact ?? legacy.managedCompact,
model: profile.model,
opusModel: profile.opusModel ?? legacy.opusModel,
settingsFile: profile.settingsFile ?? legacy.settingsFile,
sonnetModel: profile.sonnetModel ?? legacy.sonnetModel,
smallFastModel: profile.smallFastModel ?? legacy.smallFastModel
};
}
function synchronizeLegacyCodexProfile(
legacy: CodexProfileConfig,
profile: ProfileConfig | undefined
): CodexProfileConfig {
if (!profile) {
return {
...legacy,
enabled: false
};
}
return {
...legacy,
cliMiddleware: profile.cliMiddleware ?? legacy.cliMiddleware,
codexCliPath: profile.codexCliPath ?? legacy.codexCliPath,
codexHome: profile.codexHome ?? legacy.codexHome,
configFormat: profile.configFormat ?? legacy.configFormat,
configFile: profile.configFile ?? legacy.configFile,
enabled: true,
managedCompact: profile.managedCompact ?? legacy.managedCompact,
model: profile.model,
providerId: profile.providerId ?? legacy.providerId,
providerName: profile.providerName ?? legacy.providerName,
showAllSessions: profile.showAllSessions ?? legacy.showAllSessions
};
}
@@ -656,13 +727,14 @@ function sanitizeConfigForDisk(config: AppConfig): AppConfig {
}
function sanitizeProfileConfigForDisk(profile: AppConfig["profile"]): AppConfig["profile"] {
const { remoteFrontendMode: _remoteFrontendMode, ...codex } = profile.codex as AppConfig["profile"]["codex"] & {
const synchronizedProfile = synchronizeLegacyProfileConfig(profile);
const { remoteFrontendMode: _remoteFrontendMode, ...codex } = synchronizedProfile.codex as AppConfig["profile"]["codex"] & {
remoteFrontendMode?: unknown;
};
return {
...profile,
...synchronizedProfile,
codex,
profiles: profile.profiles.map((profileItem) => {
profiles: synchronizedProfile.profiles.map((profileItem) => {
if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "kilo" && profileItem.agent !== "zcode") {
return profileItem;
}
@@ -3428,6 +3500,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
const parsedBotGateway = parseBotGateway(item.botGateway ?? item.bot_gateway ?? item.bot);
const botGateway = surface !== "cli" && parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined;
const managedCompact = readManagedCompact(item);
const routing = parseProfileRouting(item.routing ?? item.route, agent);
if (agent === "claude-code") {
const appPath = readProfileAppPath(item, agent);
@@ -3445,6 +3518,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
model,
name,
opusModel: readString(item.opusModel) || readString(item.defaultOpusModel) || "",
...(routing ? { routing } : {}),
scope: parseProfileScope(readString(item.scope) || readString(item.applyScope) || readString(item.effectScope)) || "global",
settingsFile: readString(item.settingsFile) || readString(item.configFile) || "~/.claude/settings.json",
sonnetModel: readString(item.sonnetModel) || readString(item.defaultSonnetModel) || "",
@@ -3462,6 +3536,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
id,
model,
name,
...(routing ? { routing } : {}),
scope: "ccr",
surface: "cli"
};
@@ -3475,12 +3550,20 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
id,
model: "",
name,
...(routing ? { routing } : {}),
scope: "ccr",
surface: "app"
};
}
const appPath = readProfileAppPath(item, agent);
const showAllSessions = agent === "zcode" || agent === "opencode" || agent === "kilo"
? false
: typeof item.showAllSessions === "boolean"
? item.showAllSessions
: typeof item.show_all_sessions === "boolean"
? item.show_all_sessions
: undefined;
return {
agent,
...(appPath ? { appPath } : {}),
@@ -3500,20 +3583,53 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
providerId: readString(item.providerId) || readString(item.provider) || "claude-code-router",
providerName: readString(item.providerName) || "Claude Code Router",
remoteFrontendMode: parseCodexRemoteFrontendMode(readString(item.remoteFrontendMode) || readString(item.frontendMode) || readString(item.coreMode)) || "app",
...(routing ? { routing } : {}),
scope: parseProfileScope(readString(item.scope) || readString(item.applyScope) || readString(item.effectScope)) || "global",
showAllSessions: agent === "zcode" || agent === "opencode" || agent === "kilo"
? false
: typeof item.showAllSessions === "boolean"
? item.showAllSessions
: typeof item.show_all_sessions === "boolean"
? item.show_all_sessions
: false,
...(showAllSessions !== undefined ? { showAllSessions } : {}),
surface
};
})
.filter((item): item is ProfileConfig => Boolean(item));
}
function parseProfileRouting(value: unknown, _agent: ProfileConfig["agent"]): ProfileRoutingConfig | undefined {
if (value === false) {
return {
enabled: false,
enhancedRoute: true,
rules: []
};
}
if (value === true) {
return {
enabled: true,
enhancedRoute: true,
rules: []
};
}
if (!isObject(value)) {
return undefined;
}
const enhancedRoute = readBoolean(
value.enhancedRoute ??
value.useEnhancedRoute ??
value.builtInRoute ??
value.builtinRoute ??
value.useBuiltInRoute ??
value.use_builtin_route
);
return {
enabled: readBoolean(value.enabled) ?? true,
enhancedRoute: enhancedRoute ?? true,
rules: parseRouterRules(value.rules) ?? []
};
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function readProfileAppPath(item: Record<string, unknown>, agent: ProfileConfig["agent"]): string | undefined {
return readString(item.appPath) ||
readString(item.app_path) ||
+9 -1
View File
@@ -688,6 +688,12 @@ export type RouterConfig = {
rules: RouterRule[];
};
export type ProfileRoutingConfig = {
enabled: boolean;
enhancedRoute: boolean;
rules: RouterRule[];
};
export type RouteScriptDiagnostic = {
code: string;
column?: number;
@@ -1427,6 +1433,7 @@ export type ProfileConfig = {
providerId?: string;
providerName?: string;
remoteFrontendMode?: CodexRemoteFrontendMode;
routing?: ProfileRoutingConfig;
scope?: ProfileScope;
showAllSessions?: boolean;
settingsFile?: string;
@@ -2273,7 +2280,7 @@ export type AgentAnalysisSubagentRow = {
export type AgentAnalysisTraceRunKind = "agent" | "llm" | "route" | "subagent" | "tool";
export type AgentAnalysisTraceRunStatus = "error" | "success";
export type AgentAnalysisTraceRunStatus = "error" | "partial" | "success";
export type AgentAnalysisTracePayloadPreview = {
kind: "empty" | "json" | "text";
@@ -2311,6 +2318,7 @@ export type AgentAnalysisTraceRun = {
cacheReadTokens: number;
cacheWriteTokens: number;
concurrentRequests: number;
costUsd?: number;
depth: number;
durationMs: number;
endedAt: string;
@@ -15,7 +15,7 @@ import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-
import { closeServer, formatError } from "@ccr/core/gateway/http/io";
import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync";
import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync";
import { assertLoopbackCoreHost, endpoint, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, waitForManagedCoreGatewayReady, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor";
import { assertLoopbackCoreHost, endpoint, formatCoreGatewayChildExit, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, waitForManagedCoreGatewayReady, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor";
import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared";
import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared";
import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline";
@@ -26,7 +26,34 @@ import { compileRouterConfig } from "@ccr/core/routing/config-compiler";
import { normalizeRouteScriptResult, scriptResultPreview } from "@ccr/core/routing/route-script-result";
import { mediaService } from "@ccr/core/media/service";
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
import { profileApiKeyId } from "@ccr/core/profiles/api-key";
type RouteScriptTestHeaders = Record<string, string | string[] | undefined>;
function routeScriptTestProfileId(
config: AppConfig,
headers: RouteScriptTestHeaders
): string | undefined {
if (config.profile?.enabled === false) {
return undefined;
}
const apiKeyId = readRouteScriptTestHeader(headers, "x-auth-api-key-id")?.trim();
if (!apiKeyId) {
return undefined;
}
return config.profile?.profiles.find((profile) =>
profile.enabled && profileApiKeyId(profile) === apiKeyId
)?.id;
}
function readRouteScriptTestHeader(
headers: RouteScriptTestHeaders,
name: string
): string | undefined {
const normalized = name.toLowerCase();
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === normalized)?.[1];
return Array.isArray(entry) ? entry[0] : entry;
}
class GatewayService {
private readonly requestHandler = new GatewayHttpRequestHandler({
@@ -170,7 +197,7 @@ class GatewayService {
void this.handleCoreGatewayTermination(managedChild, markerWritePromise, startupFailure.message);
});
this.child.once("exit", (code, signal) => {
startupFailure ??= new Error(coreGatewayExitMessage(code, signal));
startupFailure ??= new Error(formatCoreGatewayChildExit(managedChild, code, signal));
void this.handleCoreGatewayTermination(managedChild, markerWritePromise, startupFailure.message);
});
markerWritePromise = writeManagedCoreGatewayMarker(managedChild, runtimeId);
@@ -299,7 +326,9 @@ class GatewayService {
tokenCount: request.request.tokenCount ?? 0,
url: request.request.url ?? "/v1/messages"
};
const context = buildRouteScriptInput(routeRequest);
const context = buildRouteScriptInput(routeRequest, {
profileId: routeScriptTestProfileId(config, routeRequest.headers)
});
const execution = await this.routeScriptRuntime.execute(rule.id, request.script, context, {
circuitBreaker: false
});
@@ -385,13 +414,9 @@ class GatewayService {
}
function coreGatewayExitMessage(code: number | null, signal: NodeJS.Signals | null): string {
return `Core gateway exited with ${signal ?? code ?? "unknown status"}`;
}
function assertManagedGatewayStartupContinues(child: ChildProcess, startupFailure: Error | undefined): void {
if (startupFailure || child.exitCode !== null || child.signalCode !== null || child.killed) {
throw startupFailure ?? new Error(coreGatewayExitMessage(child.exitCode, child.signalCode));
throw startupFailure ?? new Error(formatCoreGatewayChildExit(child));
}
}
@@ -2,12 +2,12 @@ import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import { isGatewayProviderEnabled, type AppConfig, type ProfileClientKind, type RequestRouteTraceChange, type RouterBuiltInAgentRuleId, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition } from "@ccr/core/contracts/app";
import { isGatewayProviderEnabled, type AppConfig, type ProfileClientKind, type ProfileConfig, type RequestRouteTraceChange, type RouterBuiltInAgentRuleId, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition } from "@ccr/core/contracts/app";
import { CONFIGDIR } from "@ccr/core/config/constants";
import { applyAgentRequestEnrichers } from "@ccr/core/agents/request-enricher";
import { buildClaudeAppGatewayModelRoutes, type ClaudeAppGatewayModelRoute, resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes";
import { claudeAppGatewayModelRouteOptions } from "@ccr/core/gateway/internal/shared";
import { compileRouterConfig, type CompiledRouterConfig, type CompiledRouterRule } from "@ccr/core/routing/config-compiler";
import { compileRouterConfig, type CompiledProfileRoutingConfig, type CompiledRouterConfig, type CompiledRouterRule } from "@ccr/core/routing/config-compiler";
import type { RouteDecision, RouteDiagnostic, RouteModelRef, RouteRequest, RouteSource } from "@ccr/core/routing/contracts";
import { ModelRegistry, normalizeRouteSelector } from "@ccr/core/routing/model-registry";
import { RoutePolicyEngine, type RoutePolicy } from "@ccr/core/routing/policy-engine";
@@ -16,6 +16,7 @@ import { applyCompiledRouteRewrite, isBodyModelCompiledRewrite, type CompiledRou
import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context";
import { normalizeRouteScriptResult } from "@ccr/core/routing/route-script-result";
import type { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime";
import { profileApiKeyId } from "@ccr/core/profiles/api-key";
export { normalizeRouteSelector } from "@ccr/core/routing/model-registry";
@@ -281,6 +282,11 @@ type RouteResolutionRuntime = {
trace?: RouteTraceObserver;
};
type RouteAuthContext = {
apiKeyId?: string;
profileId?: string;
};
async function resolveConfiguredRouteDecision(
request: MutableRequestLike,
config: AppConfig,
@@ -290,6 +296,10 @@ async function resolveConfiguredRouteDecision(
): Promise<ResolvedConfiguredRouteDecision> {
const requestedModel = readString(request.body.model);
const explicitModel = normalizeRouteSelector(requestedModel);
const authenticatedProfile = resolveAuthenticatedAnyProfile(request, config);
const auth = routeAuthContext(request, authenticatedProfile);
const profileRouting = authenticatedProfile ? compiled.profileRoutings.find((entry) => entry.profile.id === authenticatedProfile.id && entry.active) : undefined;
const defaultFallback = compiled.fallback;
const resolvedExplicitModel = compiled.modelRegistry.resolve(explicitModel) ?? compiled.modelRegistry.resolve(
explicitModel
? resolveClaudeAppGatewayRouteModel(explicitModel, config, claudeAppGatewayModelRouteOptions)
@@ -297,19 +307,19 @@ async function resolveConfiguredRouteDecision(
);
const explicitDecision: ConfiguredRouteDecision | undefined = resolvedExplicitModel
? {
fallback: compiled.fallback,
fallback: defaultFallback,
model: resolvedExplicitModel,
reason: "default",
rewrites: [],
source: "default"
}
: undefined;
const builtInDecision = resolveBuiltInAgentRouteDecision(request, config, compiled.modelRegistry, compiled.fallback);
const builtInDecision = resolveBuiltInAgentRouteDecision(request, config, compiled.modelRegistry, defaultFallback);
const subagentEnvDecision = resolveBuiltInClaudeCodeSubagentEnvRouteDecision(
request,
config,
compiled.modelRegistry,
compiled.fallback
defaultFallback
);
const clientModelDecision = explicitDecision && explicitClientModelCanOverrideBuiltInClaudeCodeRoute(
request,
@@ -320,11 +330,31 @@ async function resolveConfiguredRouteDecision(
? explicitDecision
: undefined;
const ruleBaseDecision = subagentEnvDecision ?? clientModelDecision ?? builtInDecision;
const profilePolicies: Array<RoutePolicy<MutableRequestLike, ConfiguredRouteDecision>> = profileRouting
? profileRouting.rules.map((rule): RoutePolicy<MutableRequestLike, ConfiguredRouteDecision> => ({
evaluate: async (context) => {
const policyId = profileRulePolicyId(profileRouting, rule);
const decision = await resolveRouterRule(rule, context, compiled, runtime, {
auth,
defaultFallback,
policyId,
source: "profile"
});
if (!decision || decision.rewrites.some(isBodyModelCompiledRewrite)) {
return decision;
}
return ruleBaseDecision
? mergeConfiguredRouteDecisions(ruleBaseDecision, decision)
: decision;
},
id: profileRulePolicyId(profileRouting, rule)
}))
: [];
const policies: Array<RoutePolicy<MutableRequestLike, ConfiguredRouteDecision>> = [
{
evaluate: () => customModel
? {
fallback: compiled.fallback,
fallback: defaultFallback,
model: customModel,
reason: "custom-router",
rewrites: [],
@@ -338,13 +368,19 @@ async function resolveConfiguredRouteDecision(
context,
config,
compiled.modelRegistry,
compiled.fallback
defaultFallback
),
id: "builtin-agent-claude-code-subagent"
},
...profilePolicies,
...compiled.rules.map((rule): RoutePolicy<MutableRequestLike, ConfiguredRouteDecision> => ({
evaluate: async (context) => {
const decision = await resolveRouterRule(rule, context, compiled, runtime);
const decision = await resolveRouterRule(rule, context, compiled, runtime, {
auth,
defaultFallback,
policyId: `rule:${rule.rule.id}`,
source: "rule"
});
if (!decision || decision.rewrites.some(isBodyModelCompiledRewrite)) {
return decision;
}
@@ -368,7 +404,7 @@ async function resolveConfiguredRouteDecision(
},
{
evaluate: () => ({
fallback: compiled.fallback,
fallback: defaultFallback,
model: undefined,
reason: "default",
rewrites: [],
@@ -379,9 +415,7 @@ async function resolveConfiguredRouteDecision(
];
const match = await new RoutePolicyEngine(policies).evaluate(request);
if (match) {
const compiledRule = match.policyId.startsWith("rule:")
? compiled.rules.find((rule) => `rule:${rule.rule.id}` === match.policyId)
: undefined;
const compiledRule = findMatchedCompiledRule(match.policyId, compiled);
return {
...match.decision,
policyId: match.policyId,
@@ -389,7 +423,7 @@ async function resolveConfiguredRouteDecision(
};
}
return {
fallback: compiled.fallback,
fallback: defaultFallback,
model: resolvedExplicitModel,
policyId: "default",
reason: "default",
@@ -398,6 +432,29 @@ async function resolveConfiguredRouteDecision(
};
}
function profileRulePolicyId(
profileRouting: CompiledProfileRoutingConfig,
rule: CompiledRouterRule
): string {
return `profile:${profileRouting.profile.id}:rule:${rule.rule.id}`;
}
function findMatchedCompiledRule(
policyId: string,
compiled: CompiledRouterConfig
): CompiledRouterRule | undefined {
if (policyId.startsWith("rule:")) {
return compiled.rules.find((rule) => `rule:${rule.rule.id}` === policyId);
}
for (const profileRouting of compiled.profileRoutings) {
const matched = profileRouting.rules.find((rule) => profileRulePolicyId(profileRouting, rule) === policyId);
if (matched) {
return matched;
}
}
return undefined;
}
function mergeConfiguredRouteDecisions(
base: ConfiguredRouteDecision,
override: ConfiguredRouteDecision
@@ -586,10 +643,11 @@ function builtInAgentRouteMatches(
config: AppConfig,
agent: RouterBuiltInAgentRuleId
): boolean {
if (config.Router.builtInRules?.[agent]?.enabled === false) {
const profile = resolveBuiltInAgentProfile(request, config, agent);
if (!profile) {
return false;
}
if (!resolveBuiltInAgentProfile(request, config, agent)) {
if (profile.routing?.enhancedRoute === false) {
return false;
}
const userAgent = readRequestHeader(request.headers, "user-agent")?.toLowerCase() ?? "";
@@ -608,6 +666,14 @@ function resolveAuthenticatedProfile(
request: MutableRequestLike,
config: AppConfig,
agent: ProfileClientKind
) {
const profile = resolveAuthenticatedAnyProfile(request, config);
return profile?.agent === agent ? profile : undefined;
}
function resolveAuthenticatedAnyProfile(
request: MutableRequestLike,
config: AppConfig
) {
if (config.profile.enabled === false) {
return undefined;
@@ -618,11 +684,21 @@ function resolveAuthenticatedProfile(
}
return config.profile.profiles.find((profile) =>
profile.enabled &&
profile.agent === agent &&
profileApiKeyId(profile.id || profile.name || profile.agent) === authenticatedApiKeyId
);
}
function routeAuthContext(
request: MutableRequestLike,
profile: ProfileConfig | undefined
): RouteAuthContext {
const apiKeyId = readRequestHeader(request.headers, "x-auth-api-key-id")?.trim();
return {
...(apiKeyId ? { apiKeyId } : {}),
...(profile?.id ? { profileId: profile.id } : {})
};
}
function resolveBuiltInAgentRouteTarget(
request: MutableRequestLike,
config: AppConfig,
@@ -631,11 +707,6 @@ function resolveBuiltInAgentRouteTarget(
return normalizeRouteSelector(resolveBuiltInAgentProfile(request, config, agent)?.model);
}
function profileApiKeyId(value: string): string {
const profileId = value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
return `profile:${profileId || "profile"}`;
}
function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "claude" : "codex";
}
@@ -1104,13 +1175,19 @@ async function resolveRouterRule(
compiledRule: CompiledRouterRule,
request: MutableRequestLike,
compiled: CompiledRouterConfig,
runtime: RouteResolutionRuntime
runtime: RouteResolutionRuntime,
options: {
auth: RouteAuthContext;
defaultFallback: RouterFallbackConfig;
policyId: string;
source: "profile" | "rule";
}
): Promise<ConfiguredRouteDecision | undefined> {
if (!compiledRule.active) {
return undefined;
}
const rule = compiledRule.rule;
const fallback = rule.fallback ?? compiled.fallback;
const fallback = rule.fallback ?? options.defaultFallback;
const rewrites = compiledRule.rewrites;
@@ -1120,11 +1197,11 @@ async function resolveRouterRule(
code: "script-runtime-error",
message: `Router script "${rule.name}" runtime is unavailable.`,
ruleId: rule.id,
source: "rule"
source: options.source
});
return undefined;
}
const context = buildRouteScriptInput(request);
const context = buildRouteScriptInput(request, { profileId: options.auth.profileId });
const startedAtMs = Date.now();
const execution = await runtime.scriptRuntime.execute(rule.id, rule.script, context);
if (execution.status !== "ok") {
@@ -1133,11 +1210,11 @@ async function resolveRouterRule(
code: timeout ? "script-timeout" : "script-runtime-error",
message: `Router script "${rule.name}" ${timeout ? "timed out" : "failed"}: ${execution.error ?? execution.status}`,
ruleId: rule.id,
source: "rule"
source: options.source
};
runtime.runtimeDiagnostics.push(diagnostic);
runtime.trace?.capture({
decision: { diagnostics: [diagnostic], policyId: `rule:${rule.id}`, ruleId: rule.id, ruleName: rule.name, source: "rule" },
decision: { diagnostics: [diagnostic], policyId: options.policyId, ruleId: rule.id, ruleName: rule.name, source: options.source },
durationMs: execution.durationMs,
kind: "decision",
name: `customer.script:${rule.id}`,
@@ -1150,19 +1227,20 @@ async function resolveRouterRule(
}
const normalized = normalizeRouteScriptResult({
compiledRule,
defaultFallback: compiled.fallback,
defaultFallback: options.defaultFallback,
modelRegistry: compiled.modelRegistry,
source: options.source,
value: execution.value
});
runtime.runtimeDiagnostics.push(...normalized.diagnostics);
runtime.trace?.capture({
decision: {
diagnostics: normalized.diagnostics,
policyId: `rule:${rule.id}`,
reason: normalized.matched ? `script:${rule.id}` : undefined,
policyId: options.policyId,
reason: normalized.matched ? routerRuleReason(rule, options.source, options.policyId, "script") : undefined,
ruleId: rule.id,
ruleName: rule.name,
source: "rule"
source: options.source
},
durationMs: execution.durationMs,
kind: "decision",
@@ -1180,16 +1258,16 @@ async function resolveRouterRule(
? {
fallback: normalized.fallback ?? fallback,
model: normalized.model,
reason: `script:${rule.id}`,
reason: routerRuleReason(rule, options.source, options.policyId, "script"),
rewrites: normalized.rewrites,
source: "rule"
source: options.source
}
: undefined;
}
if (rule.type === "condition") {
return rule.condition && routerRuleConditionMatches(rule.condition, request)
? routerRuleRewriteDecision(rule, rewrites, fallback, compiledRule.model)
return rule.condition && routerRuleConditionMatches(rule.condition, request, options.auth)
? routerRuleRewriteDecision(rule, rewrites, fallback, compiledRule.model, options.source, options.policyId)
: undefined;
}
@@ -1197,7 +1275,7 @@ async function resolveRouterRule(
const pattern = readString(rule.pattern);
const requestedModel = readString(request.body.model);
return pattern && requestedModel?.startsWith(pattern)
? routerRuleRewriteDecision(rule, rewrites, fallback, compiledRule.model)
? routerRuleRewriteDecision(rule, rewrites, fallback, compiledRule.model, options.source, options.policyId)
: undefined;
}
@@ -1208,14 +1286,16 @@ function routerRuleRewriteDecision(
rule: RouterRule,
rewrites: CompiledRouteRewrite[],
fallback: RouterFallbackConfig,
model: RouteModelRef | undefined
model: RouteModelRef | undefined,
source: "profile" | "rule",
policyId?: string
): ConfiguredRouteDecision {
return {
fallback,
model,
reason: routerRuleReason(rule),
reason: routerRuleReason(rule, source, policyId),
rewrites,
source: "rule"
source
};
}
@@ -1226,6 +1306,9 @@ function routeDecisionTraceName(decision: ResolvedConfiguredRouteDecision): stri
if (decision.source === "rule") {
return "customer.rule-decision";
}
if (decision.source === "profile") {
return "customer.profile-rule-decision";
}
if (decision.source === "subagent") {
return `builtins.${decision.policyId}`;
}
@@ -1242,11 +1325,15 @@ function builtInAgentPolicyId(decision: ConfiguredRouteDecision): string {
return `builtin-agent-${builtInRoute}`;
}
function routerRuleConditionMatches(condition: RouterRuleCondition, request: MutableRequestLike): boolean {
function routerRuleConditionMatches(
condition: RouterRuleCondition,
request: MutableRequestLike,
auth: RouteAuthContext
): boolean {
if (condition.left.trim().startsWith("response.")) {
return false;
}
const actual = resolveRouterConditionValue(condition.left, request);
const actual = resolveRouterConditionValue(condition.left, request, auth);
const expected = parseConditionLiteral(condition.right);
if (condition.operator === "starts-with") {
@@ -1288,7 +1375,11 @@ function routerRuleConditionMatches(condition: RouterRuleCondition, request: Mut
return false;
}
function resolveRouterConditionValue(path: string, request: MutableRequestLike): unknown {
function resolveRouterConditionValue(
path: string,
request: MutableRequestLike,
auth: RouteAuthContext
): unknown {
const parts = path
.split(".")
.map((part) => part.trim())
@@ -1308,6 +1399,16 @@ function resolveRouterConditionValue(path: string, request: MutableRequestLike):
if (section === "header" || section === "headers") {
return readRequestHeader(request.headers, rest.join("."));
}
if (section === "auth") {
const key = rest.join(".").trim();
if (key === "apiKeyId" || key === "api_key_id" || key === "keyId" || key === "key_id" || key === "sub") {
return auth.apiKeyId;
}
if (key === "profileId" || key === "profile_id") {
return auth.profileId;
}
return undefined;
}
if (section === "body") {
return readPathValue(request.body, rest);
}
@@ -1462,7 +1563,18 @@ function singleLineText(value: string, maxLength: number): string {
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
}
function routerRuleReason(rule: RouterRule): string {
function routerRuleReason(
rule: RouterRule,
source: "profile" | "rule" = "rule",
policyId?: string,
kind: "rule" | "script" = "rule"
): string {
if (source === "profile") {
return policyId ?? `profile:${kind}:${rule.id}`;
}
if (kind === "script") {
return `script:${rule.id}`;
}
if (rule.id.startsWith("legacy-")) {
return rule.id.replace(/^legacy-/, "");
}
@@ -1,12 +1,12 @@
/**
* Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { networkInterfaces } from "node:os";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join as pathJoin, resolve as pathResolve } from "node:path";
import { delimiter as pathDelimiter, join as pathJoin, resolve as pathResolve } from "node:path";
import { CONFIGDIR } from "@ccr/core/config/constants";
import {
deletePersistedRuntimeState,
@@ -24,6 +24,14 @@ import { delay } from "@ccr/core/gateway/internal/clock";
const gatewayRuntimeStateKey = "gateway";
const gatewayConfigAcceptanceTimeoutMs = 5_000;
const gatewayStartupTimeoutMs = 15_000;
const gatewayChildOutputLimit = 4000;
const gatewayChildOutput = new WeakMap<ChildProcess, { stderr: string; stdout: string }>();
type GatewayNodeRuntime = {
command: string;
electronRunAsNode: boolean;
};
export type SpawnedGatewayProcess = {
child: ChildProcess;
@@ -39,15 +47,17 @@ export function spawnGatewayProcess(
): SpawnedGatewayProcess {
const gatewayEntry = resolveGatewayEntry();
const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile() : undefined;
const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken);
const nodeRuntime = resolveGatewayNodeRuntime();
const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken, nodeRuntime.electronRunAsNode);
const gatewayBootstrapEntry = resolveGatewayBootstrapEntry();
const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayBootstrapEntry] : [gatewayBootstrapEntry];
const child = spawn(process.execPath, args, {
const child = spawn(nodeRuntime.command, args, {
cwd: CONFIGDIR,
env,
serialization: "advanced",
stdio: ["ignore", "pipe", "pipe", "ipc"]
});
captureGatewayChildOutput(child);
if (!child.send) {
child.kill();
throw new Error("Gateway runtime did not create an IPC channel.");
@@ -109,10 +119,13 @@ function monitorGatewayConfigAcceptance(child: ChildProcess): {
}
};
const onError = (error: Error) => {
finish(new Error(`Core gateway failed before accepting runtime config: ${formatError(error)}`));
finish(new Error(appendGatewayChildOutput(
child,
`Core gateway failed before accepting runtime config: ${formatError(error)}`
)));
};
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
finish(new Error(`Core gateway exited with ${signal ?? code ?? "unknown status"} before accepting runtime config.`));
finish(new Error(formatCoreGatewayChildExit(child, code, signal, "before accepting runtime config")));
};
const timer = setTimeout(() => {
finish(new Error(`Core gateway did not accept runtime config within ${gatewayConfigAcceptanceTimeoutMs}ms.`));
@@ -128,6 +141,17 @@ function monitorGatewayConfigAcceptance(child: ChildProcess): {
};
}
export function formatCoreGatewayChildExit(
child: ChildProcess,
code: number | null = child.exitCode,
signal: NodeJS.Signals | null = child.signalCode,
phase?: string
): string {
const status = signal ?? code ?? (child.killed ? "killed" : "unknown status");
const phaseSuffix = phase ? ` ${phase}` : "";
return appendGatewayChildOutput(child, `Core gateway exited with ${status}${phaseSuffix}.`);
}
function resolveGatewayBootstrapEntry(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
const entry = [
@@ -224,7 +248,13 @@ function resolveBundledUndiciProxyAgentModule(): string | undefined {
].find((candidate) => existsSync(candidate));
}
function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): NodeJS.ProcessEnv {
function createGatewayProcessEnv(
config: AppConfig,
upstreamProxyUrl: string | undefined,
runtimeId: string,
coreAuthToken: string,
electronRunAsNode: boolean
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
AUTH_ENABLED: "true",
@@ -235,10 +265,14 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u
AUTH_STATIC_API_KEY_HEADER: coreGatewayAuthHeader,
CCR_GATEWAY_RUNTIME_ID: runtimeId,
[coreGatewayAuthTokenEnv]: coreAuthToken,
ELECTRON_RUN_AS_NODE: "1",
HOST: config.gateway.coreHost,
PORT: String(config.gateway.corePort)
};
if (electronRunAsNode) {
env.ELECTRON_RUN_AS_NODE = "1";
} else {
delete env.ELECTRON_RUN_AS_NODE;
}
// The managed gateway must use the generated raw-trace policy. Inheriting
// the upstream gateway's RAW_TRACE_* overrides could bypass CCR privacy,
@@ -272,6 +306,119 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u
return env;
}
function resolveGatewayNodeRuntime(): GatewayNodeRuntime {
const candidates = uniqueGatewayNodeRuntimeCandidates([
...configuredGatewayNodeRuntimeCandidates(),
...systemGatewayNodeRuntimeCandidates(),
{ command: process.execPath, electronRunAsNode: Boolean(process.versions.electron) }
]);
const nativeProbe = resolveGatewayNativeProbeModule();
if (nativeProbe) {
const compatible = candidates.find((candidate) => canLoadGatewayNativeProbe(candidate, nativeProbe));
if (compatible) return compatible;
}
return candidates[0] ?? { command: process.execPath, electronRunAsNode: Boolean(process.versions.electron) };
}
function configuredGatewayNodeRuntimeCandidates(): GatewayNodeRuntime[] {
const configured = process.env.CCR_NODE_BIN?.trim();
return configured ? [{ command: configured, electronRunAsNode: false }] : [];
}
function systemGatewayNodeRuntimeCandidates(): GatewayNodeRuntime[] {
if (!process.versions.electron) {
return [];
}
return [
process.env.NODE_BINARY?.trim(),
process.env.NODE?.trim(),
resolvePathExecutable(process.platform === "win32" ? "node.exe" : "node"),
process.platform === "darwin" ? "/opt/homebrew/bin/node" : "",
process.platform === "darwin" ? "/usr/local/bin/node" : "",
process.platform === "win32" ? "" : "/usr/bin/node"
]
.filter((candidate): candidate is string => Boolean(candidate && executableExists(candidate)))
.map((command) => ({ command, electronRunAsNode: false }));
}
function uniqueGatewayNodeRuntimeCandidates(candidates: GatewayNodeRuntime[]): GatewayNodeRuntime[] {
const seen = new Set<string>();
const unique: GatewayNodeRuntime[] = [];
for (const candidate of candidates) {
const key = `${candidate.electronRunAsNode ? "electron" : "node"}\0${candidate.command}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(candidate);
}
return unique;
}
function resolveGatewayNativeProbeModule(): string | undefined {
try {
return requireFromHere.resolve("better-sqlite3");
} catch {
return undefined;
}
}
function canLoadGatewayNativeProbe(candidate: GatewayNodeRuntime, modulePath: string): boolean {
const env: NodeJS.ProcessEnv = { ...process.env };
if (candidate.electronRunAsNode) {
env.ELECTRON_RUN_AS_NODE = "1";
} else {
delete env.ELECTRON_RUN_AS_NODE;
}
const result = spawnSync(candidate.command, ["-e", "require(process.argv[1])", modulePath], {
cwd: process.cwd(),
encoding: "utf8",
env,
stdio: ["ignore", "ignore", "pipe"],
timeout: 3000,
windowsHide: true
});
return result.status === 0;
}
function resolvePathExecutable(name: string): string {
for (const directory of (process.env.PATH ?? "").split(pathDelimiter)) {
const candidate = pathJoin(directory, name);
if (executableExists(candidate)) {
return candidate;
}
}
return "";
}
function executableExists(candidate: string): boolean {
if (!candidate) return false;
if (!candidate.includes("/") && !candidate.includes("\\")) return true;
return existsSync(candidate);
}
function captureGatewayChildOutput(child: ChildProcess): void {
gatewayChildOutput.set(child, { stderr: "", stdout: "" });
child.stdout?.on("data", (chunk) => appendGatewayOutput(child, "stdout", chunk));
child.stderr?.on("data", (chunk) => appendGatewayOutput(child, "stderr", chunk));
}
function appendGatewayOutput(child: ChildProcess, stream: "stderr" | "stdout", chunk: Buffer | string): void {
const output = gatewayChildOutput.get(child);
if (!output) return;
output[stream] = `${output[stream]}${chunk.toString()}`.slice(-gatewayChildOutputLimit);
}
function appendGatewayChildOutput(child: ChildProcess, message: string): string {
const output = gatewayChildOutput.get(child);
if (!output) return message;
const stderr = output.stderr.trim();
const stdout = output.stdout.trim();
const details = [
stderr ? `stderr:\n${stderr}` : "",
stdout ? `stdout:\n${stdout}` : ""
].filter(Boolean).join("\n");
return details ? `${message}\n${details}` : message;
}
export function writeGatewayProxyPreloadFile(): string {
const file = pathJoin(CONFIGDIR, "gateway-proxy-preload.cjs");
writeFileSync(
@@ -1882,15 +1882,67 @@ function extractSubagentModel(
}
for (const payload of requestPayloads) {
const match = stringifyForSearch(payload).match(/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s);
if (match?.[1]?.trim()) {
return match[1].trim();
const model = extractPayloadSubagentModel(payload);
if (model) {
return model;
}
}
return undefined;
}
function extractPayloadSubagentModel(payload: unknown): string | undefined {
if (!isRecord(payload)) {
return undefined;
}
const systemModel = extractSubagentModelFromContent(payload.system);
if (systemModel) {
return systemModel;
}
if (!Array.isArray(payload.messages)) {
return undefined;
}
for (const message of payload.messages.slice(0, 2)) {
if (!isRecord(message) || message.role !== "user") {
continue;
}
const model = extractSubagentModelFromContent(message.content);
if (model) {
return model;
}
}
return undefined;
}
function extractSubagentModelFromContent(content: unknown): string | undefined {
if (typeof content === "string") {
return extractSubagentModelFromText(content);
}
if (!Array.isArray(content)) {
return undefined;
}
for (const block of content) {
const text = typeof block === "string"
? block
: isRecord(block) && typeof block.text === "string"
? block.text
: undefined;
const model = text ? extractSubagentModelFromText(text) : undefined;
if (model) {
return model;
}
}
return undefined;
}
function extractSubagentModelFromText(text: string): string | undefined {
const match = text.match(/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s);
const model = match?.[1]?.trim();
return model && model.toLowerCase() !== "provider/model" ? model : undefined;
}
function parseLogBodyPayloads(body: RequestLogBody | undefined): unknown[] {
if (!body || body.encoding !== "utf8" || !body.text.trim()) {
return [];
@@ -2568,6 +2620,7 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace {
cacheReadTokens: totals.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens,
concurrentRequests: totals.maxConcurrentRequests,
costUsd: totals.costUsd,
depth: 0,
durationMs,
endedAt: isoFromMs(endMs),
@@ -2579,7 +2632,11 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace {
outputTokens: totals.outputTokens,
sessionId,
startedAt: isoFromMs(startMs),
status: totals.errorCount > 0 ? "error" : "success",
status: totals.errorCount === 0
? "success"
: totals.errorCount === totals.requestCount
? "error"
: "partial",
totalTokens: totals.totalTokens
}
];
@@ -2704,6 +2761,7 @@ function requestTraceRun({
cacheReadTokens: request.cacheReadTokens,
cacheWriteTokens: request.cacheWriteTokens,
concurrentRequests: request.concurrentRequests,
costUsd: request.costUsd,
depth,
durationMs: request.durationMs,
endedAt: isoFromMs(request.endedAtMs),
+107
View File
@@ -0,0 +1,107 @@
import { randomBytes } from "node:crypto";
import type { ApiKeyConfig, ProfileConfig } from "@ccr/core/contracts/app";
type ProfileApiKeySource = Pick<ProfileConfig, "agent" | "id" | "name">;
type ProfileApiKeySyncOptions = {
generateKey?: () => string;
now?: () => string;
};
export type ProfileApiKeySyncResult = {
apiKeys: ApiKeyConfig[];
changed: boolean;
tokens: Map<string, string>;
};
export function profileApiKeyId(profile: ProfileApiKeySource | string): string {
const value = typeof profile === "string" ? profile : profile.id || profile.name || profile.agent;
return `profile:${sanitizeProfileKeySegment(value) || "profile"}`;
}
export function profileIdFromApiKeyId(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed?.startsWith("profile:")) {
return undefined;
}
return trimmed.slice("profile:".length) || undefined;
}
export function sanitizeProfileKeySegment(value: string): string {
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
}
export function syncProfileApiKeys(
apiKeys: ApiKeyConfig[],
profiles: ProfileConfig[],
options: ProfileApiKeySyncOptions = {}
): ProfileApiKeySyncResult {
const nextApiKeys = [...apiKeys];
const byId = new Map(nextApiKeys.map((apiKey, index) => [apiKey.id || `key-${index + 1}`, { apiKey, index }]));
const tokens = new Map<string, string>();
const generateKey = options.generateKey ?? generateProfileApiKey;
const now = options.now ?? (() => new Date().toISOString());
let changed = false;
for (const profile of profiles.filter((candidate) => candidate.enabled)) {
const id = profileApiKeyId(profile);
const name = profileApiKeyName(profile);
const existing = byId.get(id);
if (existing?.apiKey.key.trim()) {
tokens.set(profile.id, existing.apiKey.key.trim());
if (existing.apiKey.name !== name) {
nextApiKeys[existing.index] = {
...existing.apiKey,
name
};
changed = true;
}
continue;
}
const apiKey: ApiKeyConfig = {
createdAt: now(),
id,
key: generateKey(),
name
};
nextApiKeys.push(apiKey);
byId.set(id, { apiKey, index: nextApiKeys.length - 1 });
tokens.set(profile.id, apiKey.key);
changed = true;
}
return {
apiKeys: nextApiKeys,
changed,
tokens
};
}
export function pruneInactiveProfileApiKeysFromList(
apiKeys: ApiKeyConfig[],
profiles: ProfileConfig[]
): { apiKeys: ApiKeyConfig[]; changed: boolean } {
const activeIds = new Set(profiles
.filter((profile) => profile.enabled)
.map(profileApiKeyId));
const retained = apiKeys.filter((apiKey) =>
!apiKey.id.startsWith("profile:") || activeIds.has(apiKey.id)
);
return {
apiKeys: retained,
changed: retained.length !== apiKeys.length
};
}
export function profileApiKeyName(profile: ProfileApiKeySource): string {
return `Profile: ${profile.name?.trim() || profile.id || profile.agent}`;
}
export function generateProfileApiKey(): string {
return `ccr-profile-${randomBase64Url(24)}`;
}
function randomBase64Url(byteLength: number): string {
return randomBytes(byteLength).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
+1 -4
View File
@@ -17,6 +17,7 @@ import { gatewayService } from "@ccr/core/gateway/service";
import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config";
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
import { profileApiKeyId } from "@ccr/core/profiles/api-key";
import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service";
import { isDesktopAppRuntime } from "@ccr/core/runtime/desktop-app";
import { windowsEnvironmentChangedPowerShellLines, windowsSystemCommand } from "@ccr/core/platform/windows-system";
@@ -2063,10 +2064,6 @@ function findProfileApiKey(config: AppConfig, profile: ReturnType<typeof findPro
return key || config.APIKEYS.find((apiKey) => apiKey.key.trim())?.key.trim() || config.APIKEY.trim();
}
function profileApiKeyId(profile: ReturnType<typeof findProfileForOpen>): string {
return `profile:${sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "profile"}`;
}
function sanitizeProfilePathSegment(value: string): string {
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
}
+37 -63
View File
@@ -1,8 +1,7 @@
import { randomBytes } from "node:crypto";
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, availableGatewayModelIds, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, isGatewayProviderEnabled, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "@ccr/core/contracts/app";
import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, availableGatewayModelIds, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, isGatewayProviderEnabled, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "@ccr/core/contracts/app";
import { replacePersistedApiKeys } from "@ccr/core/config/config-repository";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
import {
@@ -35,6 +34,7 @@ import {
writePiGatewayConfig
} from "@ccr/core/agents/pi/profile-config";
import { CONFIGDIR } from "@ccr/core/config/constants";
import { pruneInactiveProfileApiKeysFromList, syncProfileApiKeys } from "@ccr/core/profiles/api-key";
import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config";
import { CONTEXT_ARCHIVE_MCP_SERVER_NAME, contextArchiveConfigForProfile, contextArchiveMcpServer } from "@ccr/core/gateway/context-archive";
import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin";
@@ -704,82 +704,35 @@ function applyZcodeProfile(config: AppConfig, profile: ProfileConfig, token: str
}
function profileEntries(config: AppConfig): ProfileConfig[] {
return enforceSingleEnabledGlobalProfilePerAgent(config.profile.profiles);
const profiles = enforceSingleEnabledGlobalProfilePerAgent(config.profile.profiles);
if (config.profile.enabled !== false) {
return profiles;
}
return profiles.map((profile) => profile.enabled ? { ...profile, enabled: false } : profile);
}
async function ensureProfileApiKeys(config: AppConfig, profiles: ProfileConfig[]): Promise<Map<string, string>> {
const apiKeys = [...(Array.isArray(config.APIKEYS) ? config.APIKEYS : [])];
const byId = new Map(apiKeys.map((apiKey, index) => [apiKey.id || `key-${index + 1}`, { apiKey, index }]));
const tokens = new Map<string, string>();
let changed = false;
for (const profile of profiles.filter((candidate) => candidate.enabled)) {
const id = profileApiKeyId(profile);
const name = profileApiKeyName(profile);
const existing = byId.get(id);
if (existing?.apiKey.key.trim()) {
tokens.set(profile.id, existing.apiKey.key.trim());
if (existing.apiKey.name !== name) {
apiKeys[existing.index] = {
...existing.apiKey,
name
};
changed = true;
}
continue;
}
const apiKey: ApiKeyConfig = {
createdAt: new Date().toISOString(),
id,
key: generateProfileApiKey(),
name
};
apiKeys.push(apiKey);
byId.set(id, { apiKey, index: apiKeys.length - 1 });
tokens.set(profile.id, apiKey.key);
changed = true;
}
if (changed) {
config.APIKEYS = await replacePersistedApiKeys(apiKeys);
const result = syncProfileApiKeys(Array.isArray(config.APIKEYS) ? config.APIKEYS : [], profiles);
if (result.changed) {
config.APIKEYS = await replacePersistedApiKeys(result.apiKeys);
config.APIKEY = config.APIKEYS[0]?.key ?? "";
}
return tokens;
return result.tokens;
}
async function pruneInactiveProfileApiKeys(config: AppConfig, profiles: ProfileConfig[]): Promise<void> {
const activeIds = new Set(profiles
.filter((profile) => profile.enabled)
.map(profileApiKeyId));
const current = Array.isArray(config.APIKEYS) ? config.APIKEYS : [];
const retained = current.filter((apiKey) =>
!apiKey.id.startsWith("profile:") || activeIds.has(apiKey.id)
const result = pruneInactiveProfileApiKeysFromList(
Array.isArray(config.APIKEYS) ? config.APIKEYS : [],
profiles
);
if (retained.length === current.length) {
if (!result.changed) {
return;
}
config.APIKEYS = await replacePersistedApiKeys(retained);
config.APIKEYS = await replacePersistedApiKeys(result.apiKeys);
config.APIKEY = config.APIKEYS[0]?.key ?? "";
}
function profileApiKeyId(profile: ProfileConfig): string {
return `profile:${sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "profile"}`;
}
function profileApiKeyName(profile: ProfileConfig): string {
return `Profile: ${profile.name?.trim() || profile.id || profile.agent}`;
}
function generateProfileApiKey(): string {
return `ccr-profile-${randomBase64Url(24)}`;
}
function randomBase64Url(byteLength: number): string {
return randomBytes(byteLength).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function profilePath(profile: ProfileConfig): string {
return profile.agent === "claude-code"
? resolveClaudeCodeSettingsFile(profile)
@@ -2541,6 +2494,7 @@ function claudeCodeManagedSettingsEnvKeys(
function isManagedClaudeCodeSettingsEnvKey(key: string): boolean {
return (claudeCodeGatewayEnvKeys as readonly string[]).includes(key) ||
(claudeCodeRemovedAuthEnvKeys as readonly string[]).includes(key) ||
key === CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV ||
key === CLAUDE_CODE_MCP_CONFIG_ENV ||
key === CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV ||
isClaudeCodeManagedModelEnvKey(key) ||
@@ -3241,6 +3195,9 @@ function isManagedClaudeCodeSettingsContent(content: string): boolean {
if (!settings) {
return false;
}
if (!isPureManagedClaudeCodeSettings(settings)) {
return false;
}
const apiKeyHelper = typeof settings.apiKeyHelper === "string" ? settings.apiKeyHelper : "";
if (apiKeyHelper.includes("ccr-claude-code-api-key-")) {
return true;
@@ -3251,6 +3208,23 @@ function isManagedClaudeCodeSettingsContent(content: string): boolean {
typeof env.CLAUDE_AGENT_API_BASE_URL === "string";
}
function isPureManagedClaudeCodeSettings(settings: Record<string, unknown>): boolean {
const rootKeys = Object.keys(settings);
if (rootKeys.some((key) => key !== "apiKeyHelper" && key !== "env")) {
return false;
}
if ("apiKeyHelper" in settings && typeof settings.apiKeyHelper !== "string") {
return false;
}
if (!("env" in settings)) {
return true;
}
if (!isRecord(settings.env)) {
return false;
}
return Object.keys(settings.env).every((key) => isManagedClaudeCodeSettingsEnvKey(key));
}
function isManagedCodexConfigContent(content: string, providerId: string): boolean {
if (content.includes(managedRootStart) || content.includes(managedProviderStart)) {
return true;
@@ -64,9 +64,6 @@ const presetCatalogModelOverrides: Record<string, CatalogProviderModelOverride>
modelDisplayNames: {
"kimi-for-coding": "K2.7 Code"
},
metadataModelAliases: {
"kimi-for-coding": "k2p7"
},
models: ["kimi-for-coding"],
provider: "kimi-for-coding",
providerName: "Kimi Code"
+32 -15
View File
@@ -164,7 +164,7 @@ export async function probeGatewayProviderCandidates(
try {
const probe = await probeGatewayProvider({
apiKey: mode === "connectivity" || mode === "models" ? request.apiKey : undefined,
apiKey: request.apiKey,
baseUrl: candidate.baseUrl,
forceRefresh: request.forceRefresh,
mode,
@@ -337,7 +337,7 @@ function providerProbeCandidateName(candidate: GatewayProviderProbeCandidate | u
async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest): Promise<GatewayProviderProbeResult> {
const mode = request.mode ?? "protocols";
const safetyIssue = providerApiKeySafetyIssue({
apiKey: mode === "connectivity" || mode === "models" ? request.apiKey : undefined,
apiKey: request.apiKey,
baseUrl: request.baseUrl
});
if (safetyIssue) {
@@ -1458,36 +1458,53 @@ function geminiApiEndpoint(baseUrl: string, path: string, defaultVersion: "v1" |
}
function withGeminiKey(url: string, apiKey: string | undefined): string {
if (!apiKey) {
const key = apiKeyCredentialValue(apiKey);
if (!key) {
return url;
}
const parsed = new URL(url);
parsed.searchParams.set("key", apiKey);
parsed.searchParams.set("key", key);
return compactProviderUrl(parsed);
}
function openAiHeaders(apiKey: string | undefined): Record<string, string> {
return apiKey
? {
authorization: `Bearer ${apiKey}`
}
: {};
return authorizationHeaders(apiKey);
}
function anthropicHeaders(apiKey: string | undefined): Record<string, string> {
const key = apiKeyCredentialValue(apiKey);
return {
"anthropic-version": "2023-06-01",
...(apiKey ? { "x-api-key": apiKey } : {})
...authorizationHeaders(apiKey),
...(key ? { "x-api-key": key } : {})
};
}
function geminiHeaders(apiKey: string | undefined): Record<string, string> {
return apiKey
? {
"x-goog-api-key": apiKey
}
: {};
const key = apiKeyCredentialValue(apiKey);
return {
...authorizationHeaders(apiKey),
...(key ? { "x-goog-api-key": key } : {})
};
}
function authorizationHeaders(apiKey: string | undefined): Record<string, string> {
const trimmed = apiKey?.trim();
if (!trimmed) {
return {};
}
return {
authorization: /^Bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`
};
}
function apiKeyCredentialValue(apiKey: string | undefined): string | undefined {
const trimmed = apiKey?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.replace(/^Bearer\s+/i, "");
}
function headersForProtocol(protocol: GatewayProviderCapabilityProtocol, apiKey: string | undefined): Record<string, string> {
+103 -29
View File
@@ -1,5 +1,6 @@
import type {
AppConfig,
ProfileConfig,
RouterFallbackConfig,
RouterRule,
RouterRuleRewrite
@@ -20,12 +21,21 @@ export type CompiledRouterRule = {
model?: RouteModelRef;
rewrites: CompiledRouteRewrite[];
rule: RouterRule;
source: "profile" | "rule";
};
export type CompiledProfileRoutingConfig = {
active: boolean;
diagnostics: RouteDiagnostic[];
profile: ProfileConfig;
rules: CompiledRouterRule[];
};
export type CompiledRouterConfig = {
diagnostics: RouteDiagnostic[];
fallback: RouterFallbackConfig;
modelRegistry: ModelRegistry;
profileRoutings: CompiledProfileRoutingConfig[];
rules: CompiledRouterRule[];
};
@@ -35,20 +45,64 @@ export type CompileRouterConfigOptions = {
export function compileRouterConfig(config: AppConfig, options: CompileRouterConfigOptions = {}): CompiledRouterConfig {
const modelRegistry = new ModelRegistry(config);
const rules = (config.Router.rules ?? []).map((rule) => compileRouterRule(rule, modelRegistry, options));
const rules = (config.Router.rules ?? []).map((rule) => compileRouterRule(rule, modelRegistry, options, {
label: `Router rule "${rule.name}"`,
source: "rule"
}));
const profileRoutings = compileProfileRoutings(config, modelRegistry, options);
const fallbackDiagnostics = fallbackModelDiagnostics(config.Router.fallback, modelRegistry, "default");
const profileDiagnostics = configuredProfileDiagnostics(config, modelRegistry);
const validFallbackModels = config.Router.fallback.models.filter((model) => modelRegistry.isConfigured(model));
return {
diagnostics: [...rules.flatMap((rule) => rule.diagnostics), ...fallbackDiagnostics, ...profileDiagnostics],
diagnostics: [
...rules.flatMap((rule) => rule.diagnostics),
...profileRoutings.flatMap((profileRouting) => profileRouting.diagnostics),
...fallbackDiagnostics,
...profileDiagnostics
],
fallback: fallbackDiagnostics.length === 0
? config.Router.fallback
: { ...config.Router.fallback, models: validFallbackModels },
modelRegistry,
profileRoutings,
rules
};
}
function compileProfileRoutings(
config: AppConfig,
modelRegistry: ModelRegistry,
options: CompileRouterConfigOptions
): CompiledProfileRoutingConfig[] {
if (config.profile?.enabled === false) {
return [];
}
return (config.profile?.profiles ?? [])
.filter((profile) => profile.enabled && profile.routing)
.map((profile) => {
const routing = profile.routing;
if (routing?.enabled === false) {
return {
active: false,
diagnostics: [],
profile,
rules: []
};
}
const rules = (routing?.rules ?? []).map((rule) => compileRouterRule(rule, modelRegistry, options, {
allowScript: false,
label: `Profile "${profile.name}" route "${rule.name}"`,
source: "profile"
}));
return {
active: true,
diagnostics: rules.flatMap((rule) => rule.diagnostics),
profile,
rules
};
});
}
function configuredProfileDiagnostics(config: AppConfig, modelRegistry: ModelRegistry): RouteDiagnostic[] {
if (config.profile?.enabled === false) {
return [];
@@ -66,7 +120,8 @@ function configuredProfileDiagnostics(config: AppConfig, modelRegistry: ModelReg
function compileRouterRule(
rule: RouterRule,
modelRegistry: ModelRegistry,
options: CompileRouterConfigOptions
options: CompileRouterConfigOptions,
diagnostic: { allowScript?: boolean; label: string; source: "profile" | "rule" }
): CompiledRouterRule {
const rewriteResults = routerRuleRewrites(rule).map(compileConfiguredRouteRewrite);
const rewrites = rewriteResults.flatMap((result) => result.rewrite ? [result.rewrite] : []);
@@ -75,16 +130,17 @@ function compileRouterRule(
active: false,
diagnostics: [],
rewrites,
rule
rule,
source: diagnostic.source
};
}
const diagnostics: RouteDiagnostic[] = rewriteResults.flatMap((result) => result.error ? [{
code: "rule-rewrite-invalid" as const,
message: `Router rule "${rule.name}" has an invalid rewrite: ${result.error}`,
message: `${diagnostic.label} has an invalid rewrite: ${result.error}`,
ruleId: rule.id,
source: "rule" as const
source: diagnostic.source
}] : []);
diagnostics.push(...scriptRuleDiagnostics(rule, options));
diagnostics.push(...scriptRuleDiagnostics(rule, options, diagnostic));
const providerName = effectiveTargetProviderName(rewrites);
const targetProvider = providerName ? modelRegistry.findProvider(providerName) : undefined;
let model: RouteModelRef | undefined;
@@ -94,50 +150,63 @@ function compileRouterRule(
if (!resolved) {
diagnostics.push({
code: "rule-model-not-configured",
message: `Router rule "${rule.name}" references unconfigured model "${modelRewriteValue}".`,
message: `${diagnostic.label} references unconfigured model "${modelRewriteValue}".`,
model: modelRewriteValue,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
});
} else {
model = resolved;
if (targetProvider && resolved.kind === "provider" && resolved.provider !== targetProvider) {
diagnostics.push({
code: "rule-provider-model-conflict",
message: `Router rule "${rule.name}" targets provider "${providerName}" but model "${modelRewriteValue}" belongs to "${resolved.provider.name}".`,
message: `${diagnostic.label} targets provider "${providerName}" but model "${modelRewriteValue}" belongs to "${resolved.provider.name}".`,
model: modelRewriteValue,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
});
}
}
}
diagnostics.push(...fallbackModelDiagnostics(rule.fallback, modelRegistry, "rule", rule));
diagnostics.push(...fallbackModelDiagnostics(rule.fallback, modelRegistry, diagnostic.source, rule));
return {
active: (rule.type === "script" ? Boolean(rule.script) : rewrites.length > 0) && diagnostics.length === 0,
diagnostics,
model,
rewrites,
rule
rule,
source: diagnostic.source
};
}
function scriptRuleDiagnostics(rule: RouterRule, options: CompileRouterConfigOptions): RouteDiagnostic[] {
function scriptRuleDiagnostics(
rule: RouterRule,
options: CompileRouterConfigOptions,
diagnostic: { allowScript?: boolean; label: string; source: "profile" | "rule" }
): RouteDiagnostic[] {
if (rule.type !== "script") return [];
if (diagnostic.allowScript === false) {
return [{
code: "script-api-unsupported",
message: `${diagnostic.label} does not support Node.js script rules.`,
ruleId: rule.id,
source: diagnostic.source
}];
}
if (!rule.script) {
return [{
code: "script-source-invalid",
message: `Router rule "${rule.name}" does not contain a script.`,
message: `${diagnostic.label} does not contain a script.`,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
}];
}
if (rule.script.apiVersion !== 1 || rule.script.language !== "javascript") {
return [{
code: "script-api-unsupported",
message: `Router rule "${rule.name}" uses an unsupported script API or language.`,
message: `${diagnostic.label} uses an unsupported script API or language.`,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
}];
}
const file = rule.script.file?.trim();
@@ -150,41 +219,42 @@ function scriptRuleDiagnostics(rule: RouterRule, options: CompileRouterConfigOpt
if (sourceInvalid) {
return [{
code: "script-source-invalid",
message: `Router rule "${rule.name}" requires a local JavaScript file.`,
message: `${diagnostic.label} requires a local JavaScript file.`,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
}];
}
if (file && !/\.(?:cjs|js|mjs)$/i.test(file)) {
return [{
code: "script-source-invalid",
message: `Router rule "${rule.name}" script file must use a .js, .mjs, or .cjs extension.`,
message: `${diagnostic.label} script file must use a .js, .mjs, or .cjs extension.`,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
}];
}
if (!Number.isInteger(rule.script.timeoutMs) || rule.script.timeoutMs < 10 || rule.script.timeoutMs > ROUTER_SCRIPT_MAX_TIMEOUT_MS) {
return [{
code: "script-source-invalid",
message: `Router rule "${rule.name}" script timeout must be between 10 and ${ROUTER_SCRIPT_MAX_TIMEOUT_MS} ms.`,
message: `${diagnostic.label} script timeout must be between 10 and ${ROUTER_SCRIPT_MAX_TIMEOUT_MS} ms.`,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
}];
}
const externalError = options.scriptValidationErrors?.get(rule.id);
return externalError ? [{
code: "script-source-invalid",
message: `Router rule "${rule.name}" script failed validation: ${externalError}`,
message: `${diagnostic.label} script failed validation: ${externalError}`,
ruleId: rule.id,
source: "rule"
source: diagnostic.source
}] : [];
}
function fallbackModelDiagnostics(
fallback: RouterFallbackConfig | undefined,
modelRegistry: ModelRegistry,
source: "default" | "rule",
rule?: RouterRule
source: "default" | "profile" | "rule",
rule?: RouterRule,
profile?: ProfileConfig
): RouteDiagnostic[] {
if (fallback?.mode !== "model-chain") {
return [];
@@ -193,8 +263,12 @@ function fallbackModelDiagnostics(
.filter((model) => !modelRegistry.isConfigured(model))
.map((model) => ({
code: "fallback-model-not-configured" as const,
message: rule
message: rule && profile
? `Profile "${profile.name}" route "${rule.name}" references unconfigured fallback model "${model}".`
: rule
? `Router rule "${rule.name}" references unconfigured fallback model "${model}".`
: profile
? `Profile "${profile.name}" fallback references unconfigured model "${model}".`
: `Router fallback references unconfigured model "${model}".`,
model,
...(rule ? { ruleId: rule.id } : {}),
+1 -1
View File
@@ -1,7 +1,7 @@
import type { GatewayProviderConfig, RouterFallbackConfig } from "@ccr/core/contracts/app";
import type { CompiledRouteRewrite } from "@ccr/core/routing/rewrite";
export type RouteSource = "builtin" | "custom" | "default" | "rule" | "subagent";
export type RouteSource = "builtin" | "custom" | "default" | "profile" | "rule" | "subagent";
export type ProviderModelRef = {
canonicalSelector: string;
@@ -11,6 +11,7 @@ export type RouteScriptInput = {
headers: Record<string, string | string[]>;
method: string;
model?: string;
profileId?: string;
sessionId?: string;
summary: {
hasImage: boolean;
@@ -23,7 +24,14 @@ export type RouteScriptInput = {
url: string;
};
export function buildRouteScriptInput(request: RouteRequest): RouteScriptInput {
export type BuildRouteScriptInputOptions = {
profileId?: string;
};
export function buildRouteScriptInput(
request: RouteRequest,
options: BuildRouteScriptInputOptions = {}
): RouteScriptInput {
const apiKeyId = readHeader(request.headers, "x-auth-api-key-id");
const input: RouteScriptInput = {
...(apiKeyId ? { apiKeyId } : {}),
@@ -32,6 +40,7 @@ export function buildRouteScriptInput(request: RouteRequest): RouteScriptInput {
headers: requestHeaders(request.headers),
method: request.method,
...(typeof request.body.model === "string" ? { model: request.body.model } : {}),
...(options.profileId ? { profileId: options.profileId } : {}),
...(request.sessionId ? { sessionId: request.sessionId } : {}),
summary: {
hasImage: containsImage(request.body),
@@ -3,7 +3,7 @@ import {
type RouterFallbackConfig
} from "@ccr/core/contracts/app";
import type { CompiledRouterRule } from "@ccr/core/routing/config-compiler";
import type { RouteDiagnostic, RouteModelRef } from "@ccr/core/routing/contracts";
import type { RouteDiagnostic, RouteModelRef, RouteSource } from "@ccr/core/routing/contracts";
import type { ModelRegistry } from "@ccr/core/routing/model-registry";
import {
compileScriptRouteRewrite,
@@ -28,32 +28,34 @@ export function normalizeRouteScriptResult(input: {
compiledRule: CompiledRouterRule;
defaultFallback: RouterFallbackConfig;
modelRegistry: ModelRegistry;
source?: RouteSource;
value: unknown;
}): NormalizedRouteScriptResult {
const { compiledRule, defaultFallback, modelRegistry, value } = input;
const source = input.source ?? "rule";
const rule = compiledRule.rule;
if (value === undefined || value === null || value === false) {
return { diagnostics: [], matched: false, rewrites: [] };
}
if (value !== true && !isRecord(value)) {
return invalid(rule.id, "Script result must be null, false, true, or an object.");
return invalid(rule.id, "Script result must be null, false, true, or an object.", source);
}
if (isRecord(value) && value.match === false) {
return { diagnostics: [], matched: false, rewrites: [] };
}
const resultBytes = Buffer.byteLength(JSON.stringify(value), "utf8");
if (resultBytes > maxScriptResultBytes) {
return invalid(rule.id, `Script result is ${resultBytes} bytes; the limit is ${maxScriptResultBytes} bytes.`);
return invalid(rule.id, `Script result is ${resultBytes} bytes; the limit is ${maxScriptResultBytes} bytes.`, source);
}
const result = isRecord(value) ? value : {};
const rawRewrites = result.rewrites;
if (rawRewrites !== undefined && (!Array.isArray(rawRewrites) || rawRewrites.length > maxScriptRewrites)) {
return invalid(rule.id, `Script result rewrites must be an array with at most ${maxScriptRewrites} entries.`);
return invalid(rule.id, `Script result rewrites must be an array with at most ${maxScriptRewrites} entries.`, source);
}
const dynamicResults = Array.isArray(rawRewrites) ? rawRewrites.map(compileScriptRouteRewrite) : [];
const rewriteError = dynamicResults.find((entry) => entry.error)?.error;
if (rewriteError) return invalid(rule.id, rewriteError);
if (rewriteError) return invalid(rule.id, rewriteError, source);
const rewrites = [
...compiledRule.rewrites,
...dynamicResults.flatMap((entry) => entry.rewrite ? [entry.rewrite] : [])
@@ -61,7 +63,7 @@ export function normalizeRouteScriptResult(input: {
const providerName = effectiveTargetProviderName(rewrites);
const dynamicModel = typeof result.model === "string" ? result.model.trim() : undefined;
if (result.model !== undefined && !dynamicModel) return invalid(rule.id, "Script result model must be a non-empty string.");
if (result.model !== undefined && !dynamicModel) return invalid(rule.id, "Script result model must be a non-empty string.", source);
const rewrittenModel = effectiveBodyModelRewriteValue(rewrites);
const hasModelRewrite = rewrites.some(isBodyModelCompiledRewrite);
const selectedModel = dynamicModel ?? rewrittenModel;
@@ -77,7 +79,7 @@ export function normalizeRouteScriptResult(input: {
message: `Router script returned unconfigured model "${selectedModel}".`,
model: selectedModel,
ruleId: rule.id,
source: "rule"
source
}],
matched: false,
rewrites: []
@@ -85,11 +87,11 @@ export function normalizeRouteScriptResult(input: {
}
const targetProvider = providerName ? modelRegistry.findProvider(providerName) : undefined;
if (targetProvider && model?.kind === "provider" && model.provider !== targetProvider) {
return invalid(rule.id, `Script model "${model.selector}" conflicts with target provider "${providerName}".`);
return invalid(rule.id, `Script model "${model.selector}" conflicts with target provider "${providerName}".`, source);
}
const fallbackResult = normalizeScriptFallback(result.fallback, rule.fallback ?? defaultFallback, modelRegistry);
if (fallbackResult.error) return invalid(rule.id, fallbackResult.error);
if (fallbackResult.error) return invalid(rule.id, fallbackResult.error, source);
return {
diagnostics: [],
fallback: fallbackResult.fallback,
@@ -135,13 +137,13 @@ function normalizeScriptFallback(
};
}
function invalid(ruleId: string, message: string): NormalizedRouteScriptResult {
function invalid(ruleId: string, message: string, source: RouteSource = "rule"): NormalizedRouteScriptResult {
return {
diagnostics: [{
code: "script-invalid-result",
message,
ruleId,
source: "rule"
source
}],
matched: false,
rewrites: []
@@ -4,6 +4,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { pathToFileURL } from "node:url";
import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime.ts";
test("generated Codex CLI middleware runtime is valid JavaScript", () => {
@@ -13,6 +14,18 @@ test("generated Codex CLI middleware runtime is valid JavaScript", () => {
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
});
test("generated Codex CLI middleware converts Windows SDK paths before URL scheme detection", () => {
const fn = evaluateRuntimeFunction("botGatewaySdkImportSpecifier");
const windowsPath = "C:\\Users\\macao\\AppData\\Local\\Programs\\Claude Code Router\\resources\\app.asar\\dist\\main\\bot-gateway-sdk\\dist\\index.js";
assert.equal(
fn(windowsPath),
"file:///C:/Users/macao/AppData/Local/Programs/Claude%20Code%20Router/resources/app.asar/dist/main/bot-gateway-sdk/dist/index.js"
);
assert.equal(fn("file:///tmp/sdk/index.js"), "file:///tmp/sdk/index.js");
assert.equal(fn("@the-next-ai/bot-gateway-sdk"), "@the-next-ai/bot-gateway-sdk");
});
test("Codex app-server uses ChatGPT's bundled Node as a signed supervisor", { skip: process.platform !== "darwin" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-signed-supervisor-"));
const runtimeFile = writeRuntimeScript(dir);
@@ -1042,6 +1055,33 @@ function writeRuntimeScript(dir) {
return file;
}
function evaluateRuntimeFunction(name) {
const source = extractRuntimeFunctionSource(codexCliMiddlewareRuntimeScript(), name);
return Function("path", "pathToFileURL", `${source}; return ${name};`)(path, pathToFileURL);
}
function extractRuntimeFunctionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1);
const openBrace = source.indexOf("{", start);
assert.notEqual(openBrace, -1);
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
const character = source[index];
if (character === "{") {
depth += 1;
} else if (character === "}") {
depth -= 1;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
}
throw new Error(`Unable to extract runtime function ${name}.`);
}
function writeFakeClaudeCli(dir) {
const fakeCli = path.join(dir, "fake-claude");
const outputFile = path.join(dir, "fake-claude-output.json");
@@ -126,6 +126,11 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage
);
record.model = "custom-model";
record.providerName = "custom-provider";
record.requestHeaders = {
"content-type": "application/json",
"user-agent": "openai-codex test",
"x-codex-session-id": "custom-pricing-session"
};
record.pricing = {
inputUsdPerMillionTokens: 2,
outputUsdPerMillionTokens: 8
@@ -141,6 +146,19 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage
let page = await store.list({ pageSize: 10 });
let detail = await store.getDetail({ id: page.items[0].id });
assert.equal(detail.costUsd, 6);
let analysis = await store.analyze({
range: "30d",
sessionAgent: "codex",
sessionId: "custom-pricing-session"
});
assert.equal(
analysis.selectedSession?.trace.runs.find((run) => run.id === analysis.selectedSession?.trace.rootRunId)?.costUsd,
6
);
assert.equal(
analysis.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.costUsd,
6
);
assert.equal(await store.updateFromRawTrace({
model: "custom-model",
@@ -151,6 +169,15 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage
page = await store.list({ pageSize: 10 });
detail = await store.getDetail({ id: page.items[0].id });
assert.equal(detail.costUsd, 12);
analysis = await store.analyze({
range: "30d",
sessionAgent: "codex",
sessionId: "custom-pricing-session"
});
assert.equal(
analysis.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.costUsd,
12
);
} finally {
await store.close();
rmSync(dir, { force: true, recursive: true });
@@ -987,6 +987,10 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async
assert.equal(selected.selectedSession?.trace.toolRunCount, 1);
assert.equal(selected.selectedSession?.trace.llmRunCount, 1);
assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true);
assert.equal(
selected.selectedSession?.trace.runs.find((run) => run.id === selected.selectedSession?.trace.rootRunId)?.status,
"success"
);
const inputPayload = await store.getTracePayload({
callId: "call-read",
@@ -1010,6 +1014,189 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async
}
});
test("RequestLogStore ignores subagent markers in tool definitions and placeholder text", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-subagent-detection-test-"));
try {
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
const baseTime = Date.now() - 5000;
async function recordClaudeRequest({ body, offsetMs, sessionId }) {
const startedAtMs = baseTime + offsetMs;
await store.record({
completedAt: new Date(startedAtMs + 100).toISOString(),
durationMs: 100,
method: "POST",
path: "/v1/messages",
providerName: "test-provider",
requestBody: Buffer.from(JSON.stringify({
...body,
model: "claude-test"
}), "utf8"),
requestHeaders: {
"content-type": "application/json",
"user-agent": "claude-cli test",
"x-ccr-route-reason": "default",
"x-ccr-routed-model": "test-provider/claude-test",
"x-claude-code-session-id": sessionId
},
requestId: `${sessionId}-request`,
responseBodyText: JSON.stringify({ model: "claude-test" }),
responseHeaders: { "content-type": "application/json" },
startedAt: new Date(startedAtMs).toISOString(),
statusCode: 200,
url: "http://127.0.0.1:3456/v1/messages"
});
}
await recordClaudeRequest({
body: {
messages: [{ content: "normal main-agent request", role: "user" }],
tools: [{
description: "Use <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL> when spawning an agent.",
input_schema: {
properties: {
prompt: {
description: "Start with <CCR-SUBAGENT-MODEL>Provider/model</CCR-SUBAGENT-MODEL>.",
type: "string"
}
},
type: "object"
},
name: "Agent"
}]
},
offsetMs: 0,
sessionId: "tool-description-session"
});
await recordClaudeRequest({
body: {
messages: [{ content: "normal request", role: "user" }],
system: "Example: <CCR-SUBAGENT-MODEL>Provider/model</CCR-SUBAGENT-MODEL>"
},
offsetMs: 1000,
sessionId: "placeholder-session"
});
await recordClaudeRequest({
body: {
messages: [{
content: "<CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL>\nInspect the repository.",
role: "user"
}]
},
offsetMs: 2000,
sessionId: "real-subagent-session"
});
const toolDescription = await store.analyze({
range: "30d",
sessionAgent: "claude-code",
sessionId: "tool-description-session"
});
assert.equal(toolDescription.selectedSession?.trace.subagentRunCount, 0);
assert.equal(toolDescription.selectedSession?.subagents.length, 0);
const placeholder = await store.analyze({
range: "30d",
sessionAgent: "claude-code",
sessionId: "placeholder-session"
});
assert.equal(placeholder.selectedSession?.trace.subagentRunCount, 0);
assert.equal(placeholder.selectedSession?.subagents.length, 0);
const realSubagent = await store.analyze({
range: "30d",
sessionAgent: "claude-code",
sessionId: "real-subagent-session"
});
assert.equal(realSubagent.selectedSession?.trace.subagentRunCount, 1);
assert.equal(realSubagent.selectedSession?.subagents[0]?.model, "Provider/claude-opus");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogStore distinguishes partial session failures from failed sessions", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-status-test-"));
try {
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
const baseTime = Date.now() - 5000;
async function recordAgentRequest({ offsetMs, requestId, sessionId, statusCode }) {
const startedAtMs = baseTime + offsetMs;
await store.record({
completedAt: new Date(startedAtMs + 100).toISOString(),
durationMs: 100,
...(statusCode >= 400 ? { error: "upstream request failed" } : {}),
method: "POST",
path: "/v1/chat/completions",
providerName: "test-provider",
providerProtocol: "openai_chat_completions",
requestBody: Buffer.from(JSON.stringify({
messages: [{ content: "continue task", role: "user" }],
model: "gpt-test",
session_id: sessionId
}), "utf8"),
requestHeaders: {
"content-type": "application/json",
"user-agent": "openai-codex test",
"x-codex-session-id": sessionId
},
requestId,
responseBodyText: JSON.stringify({ model: "gpt-test" }),
responseHeaders: { "content-type": "application/json" },
startedAt: new Date(startedAtMs).toISOString(),
statusCode,
url: "http://127.0.0.1:3456/v1/chat/completions"
});
}
await recordAgentRequest({
offsetMs: 0,
requestId: "mixed-failed",
sessionId: "session-mixed",
statusCode: 502
});
await recordAgentRequest({
offsetMs: 1000,
requestId: "mixed-recovered",
sessionId: "session-mixed",
statusCode: 200
});
await recordAgentRequest({
offsetMs: 2000,
requestId: "failed-only",
sessionId: "session-failed",
statusCode: 502
});
const mixed = await store.analyze({
range: "30d",
sessionAgent: "codex",
sessionId: "session-mixed"
});
const mixedTrace = mixed.selectedSession?.trace;
assert.equal(
mixedTrace?.runs.find((run) => run.id === mixedTrace.rootRunId)?.status,
"partial"
);
assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "error"), true);
assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "success"), true);
const failed = await store.analyze({
range: "30d",
sessionAgent: "codex",
sessionId: "session-failed"
});
const failedTrace = failed.selectedSession?.trace;
assert.equal(
failedTrace?.runs.find((run) => run.id === failedTrace.rootRunId)?.status,
"error"
);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogStore agent analysis cache ratio denominator includes cache tokens when total tokens omit cache", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-cache-ratio-test-"));
try {
@@ -4,7 +4,9 @@ import { chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync,
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { loadAppConfig } from "@ccr/core/config/config.ts";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { replacePersistedAppConfig } from "@ccr/core/config/config-repository.ts";
import { CONFIGDIR } from "@ccr/core/config/constants.ts";
import { applyProfileConfig, cleanupGeneratedBinBackups, resolveGrokSourceHome, resolveKimiSourceHome, restoreInactiveGlobalProfileConfigs, restoreGlobalProfileConfigsOnExit } from "@ccr/core/profiles/service.ts";
@@ -215,6 +217,150 @@ test("profile service does not overwrite invalid global Claude settings JSON", {
}
});
test("profile service honors the top-level profile disabled flag", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-profile-disabled-"));
try {
const settingsFile = path.join(root, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
writeFileSync(settingsFile, `${JSON.stringify({
env: {
USER_VALUE: "kept"
},
permissions: {
allow: ["Bash(echo:*)"]
}
}, null, 2)}\n`);
const profile = {
agent: "claude-code",
enabled: true,
env: {},
id: "top-level-disabled-claude-settings",
model: "Provider/model",
name: "Top Level Disabled Claude Settings",
scope: "global",
settingsFile,
smallFastModel: "",
surface: "auto"
};
const config = createDefaultAppConfig();
config.APIKEY = "ccr-profile-disabled-test";
config.APIKEYS = [{
createdAt: "2026-01-01T00:00:00.000Z",
id: `profile:${profile.id}`,
key: "ccr-profile-disabled-test",
name: "Profile: Top Level Disabled Claude Settings"
}];
config.Providers = [{
api_base_url: "https://example.test/v1",
api_key: "provider-key",
models: ["model"],
name: "Provider"
}];
config.profile.enabled = false;
config.profile.profiles = [profile];
const result = await applyProfileConfig(config);
const status = result.clients.find((client) => client.client === "claude-code");
assert.equal(result.enabled, false);
assert.equal(status?.enabled, false);
assert.equal(status?.ok, true);
const current = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(current.apiKeyHelper, undefined);
assert.deepEqual(current.env, {
USER_VALUE: "kept"
});
assert.deepEqual(current.permissions, {
allow: ["Bash(echo:*)"]
});
} finally {
restoreGlobalProfileConfigsOnExit([], { manageMarker: true });
rmSync(root, { force: true, recursive: true });
}
});
test("profile service does not rewrite user Claude settings for stale legacy profile flags without profiles", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-stale-legacy-profile-home-"));
const previousHome = process.env.HOME;
const takeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json");
try {
process.env.HOME = home;
assert.equal(os.homedir(), home);
const settingsFile = path.join(home, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
const originalSettings = `${JSON.stringify({
env: {
USER_VALUE: "original"
},
theme: "dark"
}, null, 2)}\n`;
const backupSettings = `${JSON.stringify({
env: {
USER_VALUE: "backup"
},
outputStyle: "backup"
}, null, 2)}\n`;
const userSettings = `${JSON.stringify({
apiKeyHelper: "/home/user/.claude-code-router/bin/ccr-claude-code-api-key-claude-code",
env: {
ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_BASE_URL: "http://127.0.0.1:3456",
CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456",
USER_VALUE: "kept"
},
model: "user-model",
outputStyle: "concise",
permissions: {
allow: ["Bash(echo:*)"]
}
}, null, 2)}\n`;
writeFileSync(`${settingsFile}.ccr-original`, originalSettings);
writeFileSync(`${settingsFile}.ccr-backup-2026-07-28T16-26-39-119Z`, backupSettings);
writeFileSync(settingsFile, userSettings);
writeFileSync(takeoverFile, `${JSON.stringify({
profiles: [{
agent: "claude-code",
id: "default-claude-code",
name: "Claude Code",
settingsFile: "~/.claude/settings.json"
}],
version: 1
}, null, 2)}\n`);
const backupNames = readdirSync(path.dirname(settingsFile)).filter((name) => name.startsWith("settings.json.ccr-backup-")).sort();
const staleLegacyConfig = createDefaultAppConfig();
staleLegacyConfig.profile.enabled = true;
staleLegacyConfig.profile.claudeCode.enabled = true;
staleLegacyConfig.profile.claudeCode.settingsFile = "~/.claude/settings.json";
staleLegacyConfig.profile.codex.enabled = true;
staleLegacyConfig.profile.profiles = [];
await replacePersistedAppConfig(staleLegacyConfig);
const loadedConfig = await loadAppConfig();
assert.equal(loadedConfig.profile.enabled, false);
assert.equal(loadedConfig.profile.claudeCode.enabled, false);
assert.equal(loadedConfig.profile.codex.enabled, false);
assert.deepEqual(loadedConfig.profile.profiles, []);
const result = await applyProfileConfig(loadedConfig);
assert.equal(result.enabled, false);
assert.equal(readFileSync(settingsFile, "utf8"), userSettings);
assert.deepEqual(
readdirSync(path.dirname(settingsFile)).filter((name) => name.startsWith("settings.json.ccr-backup-")).sort(),
backupNames
);
} finally {
restoreGlobalProfileConfigsOnExit([], { manageMarker: true });
rmSync(takeoverFile, { force: true });
rmSync(home, { force: true, recursive: true });
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
}
});
test("profile service does not rewrite Claude settings when only user-managed fields change", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-user-fields-"));
const takeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json");
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env.ts";
import { botGatewaySdkImportSpecifier } from "@ccr/core/agents/bot-gateway/sdk-import.ts";
function botGateway(overrides = {}) {
return {
@@ -129,3 +130,14 @@ test("botGatewayProfileEnv defaults iMessage to local auth", () => {
assert.equal(env.CCR_BOT_GATEWAY_AUTH_TYPE, "local");
assert.equal(JSON.parse(env.CCR_BOT_GATEWAY_CONFIG_JSON).transport, "websocket");
});
test("botGatewaySdkImportSpecifier converts Windows absolute paths before URL scheme detection", () => {
const windowsPath = "C:\\Users\\macao\\AppData\\Local\\Programs\\Claude Code Router\\resources\\app.asar\\dist\\main\\bot-gateway-sdk\\dist\\index.js";
assert.equal(
botGatewaySdkImportSpecifier(windowsPath),
"file:///C:/Users/macao/AppData/Local/Programs/Claude%20Code%20Router/resources/app.asar/dist/main/bot-gateway-sdk/dist/index.js"
);
assert.equal(botGatewaySdkImportSpecifier("file:///tmp/sdk/index.js"), "file:///tmp/sdk/index.js");
assert.equal(botGatewaySdkImportSpecifier("@the-next-ai/bot-gateway-sdk"), "@the-next-ai/bot-gateway-sdk");
});
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
prepareClaudeAppVmStorage,
resolveClaudeAppDefaultUserDataDirs,
resolveClaudeAppVmBundleDir,
resolveSharedClaudeAppVmSeedBundleDir
} from "@ccr/core/agents/claude-app/vm-storage.ts";
test("Claude App VM storage prepares profile bundle from the default Claude App data dir", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
writeVmBundle(sourceBundle, "default-vm");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.equal(result.action, "prepared");
assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "default-vm");
assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "default-vm");
});
});
test("Claude App VM storage can seed new profiles from an existing CCR profile", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceUserDataDir = path.join(configDir, "profiles", "source", "claude", ".claude-code-router", "claude-app-user-data", "source");
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
const sourceBundle = resolveClaudeAppVmBundleDir(sourceUserDataDir);
writeVmBundle(sourceBundle, "profile-vm");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.equal(result.action, "prepared");
assert.equal(result.sourceBundleDir, sourceBundle);
assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "profile-vm");
assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "profile-vm");
});
});
test("Claude App VM storage leaves an existing profile VM untouched", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir);
writeVmBundle(sourceBundle, "source-vm");
writeVmBundle(targetBundle, "target-vm");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.deepEqual(result, {
action: "skipped",
reason: "target-present",
targetBundleDir: targetBundle
});
assert.equal(readRootfs(targetBundle), "target-vm");
});
});
test("Claude App VM storage replaces incomplete temporary VM bundles", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir);
writeVmBundle(sourceBundle, "source-vm");
mkdirSync(path.join(targetBundle, ".wvm-tmp-123"), { recursive: true });
writeFileSync(path.join(targetBundle, ".wvm-tmp-123", "rootfs.img"), "partial-vm");
writeFileSync(path.join(targetBundle, ".cowork-adopted"), "marker");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.equal(result.action, "prepared");
assert.equal(readRootfs(targetBundle), "source-vm");
assert.equal(existsSync(path.join(targetBundle, ".wvm-tmp-123")), false);
});
});
function writeVmBundle(bundleDir, content) {
mkdirSync(bundleDir, { recursive: true });
writeFileSync(path.join(bundleDir, "rootfs.img"), content);
writeFileSync(path.join(bundleDir, "machineIdentifier"), "machine");
}
function readRootfs(bundleDir) {
return readFileSync(path.join(bundleDir, "rootfs.img"), "utf8");
}
function withRuntimeEnv(run) {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-app-vm-storage-"));
const previous = {
appData: process.env.CCR_INTERNAL_APP_DATA_DIR,
home: process.env.CCR_INTERNAL_HOME_DIR,
seed: process.env.CCR_CLAUDE_APP_VM_SEED_DIR,
seedDisabled: process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED
};
try {
process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(root, "app-data");
process.env.CCR_INTERNAL_HOME_DIR = path.join(root, "home");
delete process.env.CCR_CLAUDE_APP_VM_SEED_DIR;
delete process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED;
run(root);
} finally {
setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previous.appData);
setOptionalEnv("CCR_INTERNAL_HOME_DIR", previous.home);
setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DIR", previous.seed);
setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DISABLED", previous.seedDisabled);
rmSync(root, { force: true, recursive: true });
}
}
function setOptionalEnv(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
@@ -460,7 +460,7 @@ test("codex catalog keeps freeform apply_patch when provider advertises Response
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog enables apply_patch bridge for non-GPT models when Codex built-in route enables it", () => {
test("codex catalog enables apply_patch bridge for non-GPT models with legacy global Codex route enabled", () => {
const model = catalogModelFor({
Providers: [
{ name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
@@ -478,7 +478,7 @@ test("codex catalog enables apply_patch bridge for non-GPT models when Codex bui
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog automatically enables apply_patch bridge for non-GPT models when the Codex built-in route is off", () => {
test("codex catalog keeps apply_patch bridge for non-GPT models with legacy global Codex route disabled", () => {
const model = catalogModelFor({
Providers: [
{ name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
test("saving profiles synchronizes legacy profile enabled flags", async () => {
const testRoot = path.join(
process.env.CCR_INTERNAL_HOME_DIR,
`profile-legacy-sync-${process.pid}`
);
process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home");
process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data");
process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data");
const { createDefaultAppConfig } = await import("@ccr/core/config/default-config.ts");
const { loadPersistedAppConfig, replacePersistedAppConfig } = await import("@ccr/core/config/config-repository.ts");
const { loadAppConfig, saveAppConfig } = await import("@ccr/core/config/config.ts");
const config = createDefaultAppConfig();
config.profile.profiles = config.profile.profiles.filter((profile) => profile.agent !== "claude-code");
const savedWithoutClaude = await saveAppConfig(config);
assert.equal(savedWithoutClaude.profile.enabled, true);
assert.equal(savedWithoutClaude.profile.claudeCode.enabled, false);
assert.equal(savedWithoutClaude.profile.codex.enabled, true);
const rawWithoutClaude = await loadPersistedAppConfig();
assert.equal(rawWithoutClaude.profile.claudeCode.enabled, false);
assert.equal(rawWithoutClaude.profile.codex.enabled, true);
const topLevelDisabledConfig = createDefaultAppConfig();
topLevelDisabledConfig.profile.enabled = false;
const savedTopLevelDisabled = await saveAppConfig(topLevelDisabledConfig);
assert.equal(savedTopLevelDisabled.profile.enabled, false);
assert.equal(savedTopLevelDisabled.profile.claudeCode.enabled, false);
assert.equal(savedTopLevelDisabled.profile.codex.enabled, false);
assert.equal(savedTopLevelDisabled.profile.profiles.some((profile) => profile.enabled), true);
const rawTopLevelDisabled = await loadPersistedAppConfig();
assert.equal(rawTopLevelDisabled.profile.enabled, false);
assert.equal(rawTopLevelDisabled.profile.claudeCode.enabled, false);
assert.equal(rawTopLevelDisabled.profile.codex.enabled, false);
savedWithoutClaude.profile.profiles = [];
const savedWithoutProfiles = await saveAppConfig(savedWithoutClaude);
assert.equal(savedWithoutProfiles.profile.enabled, false);
assert.equal(savedWithoutProfiles.profile.claudeCode.enabled, false);
assert.equal(savedWithoutProfiles.profile.codex.enabled, false);
const rawWithoutProfiles = await loadPersistedAppConfig();
assert.equal(rawWithoutProfiles.profile.enabled, false);
assert.equal(rawWithoutProfiles.profile.claudeCode.enabled, false);
assert.equal(rawWithoutProfiles.profile.codex.enabled, false);
const staleLegacyConfig = createDefaultAppConfig();
staleLegacyConfig.profile.enabled = true;
staleLegacyConfig.profile.claudeCode.enabled = true;
staleLegacyConfig.profile.codex.enabled = true;
staleLegacyConfig.profile.profiles = [];
await replacePersistedAppConfig(staleLegacyConfig);
const loadedStaleLegacyConfig = await loadAppConfig();
assert.equal(loadedStaleLegacyConfig.profile.enabled, false);
assert.equal(loadedStaleLegacyConfig.profile.claudeCode.enabled, false);
assert.equal(loadedStaleLegacyConfig.profile.codex.enabled, false);
assert.deepEqual(loadedStaleLegacyConfig.profile.profiles, []);
const legacyBooleanConfig = createDefaultAppConfig();
legacyBooleanConfig.profile.claudeCode.managedCompact = true;
legacyBooleanConfig.profile.codex.managedCompact = true;
legacyBooleanConfig.profile.codex.showAllSessions = true;
legacyBooleanConfig.profile.profiles = legacyBooleanConfig.profile.profiles.map((profile) => {
const profileWithoutOptionalBooleans = { ...profile };
delete profileWithoutOptionalBooleans.managedCompact;
delete profileWithoutOptionalBooleans.showAllSessions;
return profileWithoutOptionalBooleans;
});
await replacePersistedAppConfig(legacyBooleanConfig);
const loadedLegacyBooleanConfig = await loadAppConfig();
assert.equal(loadedLegacyBooleanConfig.profile.claudeCode.managedCompact, true);
assert.equal(loadedLegacyBooleanConfig.profile.codex.managedCompact, true);
assert.equal(loadedLegacyBooleanConfig.profile.codex.showAllSessions, true);
});
@@ -174,7 +174,7 @@ test("Codex compact compat rewrites compaction trigger without context archive",
assert.doesNotMatch(transformed, /CCR ARCHIVED HISTORY ACCESS/);
});
test("Codex patch bridge automatically rewrites non-GPT models when the built-in route is disabled", () => {
test("Codex patch bridge rewrites non-GPT models with legacy global Codex route disabled", () => {
const result = prepareCodexApplyPatchBridgeRequest({
body: Buffer.from(JSON.stringify({
model: "openrouter/google/gemini-2.5-pro",
@@ -6,6 +6,7 @@ import test from "node:test";
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin.ts";
import { fetchUpstreamWithFallback } from "@ccr/core/gateway/upstream/executor.ts";
import { RequestRouteTraceRecorder } from "@ccr/core/observability/route-trace.ts";
import { profileApiKeyId } from "@ccr/core/profiles/api-key.ts";
import {
createClaudeCodeModelsResponseForTest,
fallbackRetryDelayAfterNetworkErrorForTest,
@@ -41,7 +42,7 @@ function createRouterPlugin(options = {}) {
"claude-code": { enabled: options.claudeCodeRuleEnabled ?? true },
codex: { enabled: options.codexRuleEnabled ?? true }
},
fallback: { mode: "off", models: [], retryCount: 1 },
fallback: options.routerFallback ?? { mode: "off", models: [], retryCount: 1 },
rules: options.routerRules ?? []
},
profile: {
@@ -50,6 +51,8 @@ function createRouterPlugin(options = {}) {
},
toolHub: options.toolHub,
virtualModelProfiles: options.virtualModelProfiles ?? []
}, {
scriptRuntime: options.scriptRuntime
});
return {
routeRequest(input) {
@@ -490,6 +493,350 @@ test("borrowed route bodies remain isolated from router mutations", async () =>
assert.equal(body.model, "claude-default");
});
test("profile routing rules match only the authenticated profile API key", async () => {
const plugin = createRouterPlugin({
authenticatedProfileId: null,
profiles: [
{
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Provider/claude-sonnet",
name: "Profile A",
routing: {
enabled: true,
enhancedRoute: true,
rules: [{
condition: { left: "request.header.x-task", operator: "==", right: "heavy" },
enabled: true,
id: "heavy",
name: "Heavy",
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/claude-opus" }],
type: "condition"
}]
},
scope: "ccr"
},
{
agent: "claude-code",
enabled: true,
id: "profile-b",
model: "Provider/claude-haiku",
name: "Profile B",
scope: "ccr"
}
]
});
const matched = await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"user-agent": "claude-code/1.0",
"x-auth-api-key-id": "profile:profile-a",
"x-task": "heavy"
},
method: "POST",
url: "/v1/messages"
});
const unmatched = await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"user-agent": "claude-code/1.0",
"x-auth-api-key-id": "profile:profile-b",
"x-task": "heavy"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(matched.body.model, "Provider/claude-opus");
assert.equal(matched.decision.reason, "profile:profile-a:rule:heavy");
assert.equal(matched.decision.source, "profile");
assert.equal(unmatched.body.model, "Provider/claude-haiku");
assert.equal(unmatched.decision.reason, "builtin:claude-code");
});
test("profile routing keeps identical conditions isolated by independent profile API keys", async () => {
const profileA = {
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Provider/claude-sonnet",
name: "Profile A",
routing: {
enabled: true,
enhancedRoute: true,
rules: [{
condition: { left: "request.header.x-task", operator: "==", right: "heavy" },
enabled: true,
id: "heavy-a",
name: "Heavy A",
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/claude-opus" }],
type: "condition"
}]
},
scope: "ccr"
};
const profileB = {
agent: "claude-code",
enabled: true,
id: "profile-b",
model: "Provider/claude-haiku",
name: "Profile B",
routing: {
enabled: true,
enhancedRoute: true,
rules: [{
condition: { left: "request.header.x-task", operator: "==", right: "heavy" },
enabled: true,
id: "heavy-b",
name: "Heavy B",
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/claude-haiku" }],
type: "condition"
}]
},
scope: "ccr"
};
const plugin = createRouterPlugin({
authenticatedProfileId: null,
profiles: [profileA, profileB]
});
const routeForProfile = (profile) => plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"user-agent": "claude-code/1.0",
"x-auth-api-key-id": profileApiKeyId(profile),
"x-task": "heavy"
},
method: "POST",
url: "/v1/messages"
});
const resultA = await routeForProfile(profileA);
const resultB = await routeForProfile(profileB);
const anonymous = await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"user-agent": "claude-code/1.0",
"x-task": "heavy"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(resultA.body.model, "Provider/claude-opus");
assert.equal(resultA.decision.reason, "profile:profile-a:rule:heavy-a");
assert.equal(resultA.decision.source, "profile");
assert.equal(resultB.body.model, "Provider/claude-haiku");
assert.equal(resultB.decision.reason, "profile:profile-b:rule:heavy-b");
assert.equal(resultB.decision.source, "profile");
assert.equal(anonymous.body.model, "claude-default");
assert.equal(anonymous.decision.reason, "default");
assert.equal(anonymous.decision.source, "default");
});
test("profile routing uses the global fallback for authenticated profile traffic", async () => {
const globalFallback = { mode: "retry", models: [], retryCount: 3 };
const plugin = createRouterPlugin({
profileModel: "Provider/claude-sonnet",
routerFallback: globalFallback,
profiles: [{
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Provider/claude-sonnet",
name: "Profile A",
routing: {
enabled: true,
enhancedRoute: true,
rules: []
},
scope: "ccr"
}]
});
const result = await plugin.routeRequest({
body: { messages: [], model: "Provider/claude-sonnet" },
headers: {},
method: "POST",
url: "/v1/messages"
});
assert.deepEqual(result.decision.fallback, globalFallback);
});
test("profile enhanced route switch disables the built-in Claude Code route when private rules are disabled", async () => {
const plugin = createRouterPlugin({
profiles: [{
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Provider/claude-sonnet",
name: "Profile A",
routing: {
enabled: false,
enhancedRoute: false,
rules: []
},
scope: "ccr"
}]
});
const result = await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"user-agent": "claude-code/1.0"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "claude-default");
assert.equal(result.decision.reason, "default");
assert.equal(result.decision.source, "default");
});
test("profile enhanced route switch disables the built-in Claude Code route when private rules are enabled", async () => {
const plugin = createRouterPlugin({
profiles: [{
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Provider/claude-sonnet",
name: "Profile A",
routing: {
enabled: true,
enhancedRoute: false,
rules: []
},
scope: "ccr"
}]
});
const result = await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"user-agent": "claude-code/1.0"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "claude-default");
assert.equal(result.decision.reason, "default");
assert.equal(result.decision.source, "default");
});
test("router rules can match the authenticated profile id through request.auth", async () => {
const plugin = createRouterPlugin({
authenticatedProfileId: "profile-a",
profiles: [{
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Provider/claude-sonnet",
name: "Profile A",
scope: "ccr"
}],
routerRules: [{
condition: { left: "request.auth.profileId", operator: "==", right: "profile-a" },
enabled: true,
id: "profile-auth",
name: "Profile auth",
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/claude-opus" }],
type: "condition"
}]
});
const result = await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-opus");
assert.equal(result.decision.reason, "rule:profile-auth");
});
test("router auth profile id uses the configured profile id instead of the API key slug", async () => {
const profile = {
agent: "claude-code",
enabled: true,
id: "Claude Work/Profile",
model: "Provider/claude-sonnet",
name: "Claude Work",
scope: "ccr"
};
const plugin = createRouterPlugin({
authenticatedProfileId: null,
profiles: [profile],
routerRules: [{
condition: { left: "request.auth.profileId", operator: "==", right: "Claude Work/Profile" },
enabled: true,
id: "profile-auth-raw-id",
name: "Profile auth raw id",
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/claude-opus" }],
type: "condition"
}]
});
const result = await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"x-auth-api-key-id": profileApiKeyId(profile)
},
method: "POST",
url: "/v1/messages"
});
assert.equal(profileApiKeyId(profile), "profile:Claude-Work-Profile");
assert.equal(result.body.model, "Provider/claude-opus");
assert.equal(result.decision.reason, "rule:profile-auth-raw-id");
});
test("route scripts receive the configured profile id instead of the API key slug", async () => {
const profile = {
agent: "claude-code",
enabled: true,
id: "Claude Work/Profile",
model: "Provider/claude-sonnet",
name: "Claude Work",
scope: "ccr"
};
let input;
const plugin = createRouterPlugin({
authenticatedProfileId: null,
profiles: [profile],
routerRules: [{
enabled: true,
id: "profile-script-auth",
name: "Profile script auth",
script: {
apiVersion: 1,
file: "/tmp/profile-script-auth.js",
language: "javascript",
timeoutMs: 1000
},
type: "script"
}],
scriptRuntime: {
execute: async (_ruleId, _script, context) => {
input = context;
return { durationMs: 1, status: "ok", value: false };
}
}
});
await plugin.routeRequest({
body: { messages: [], model: "claude-default" },
headers: {
"x-auth-api-key-id": profileApiKeyId(profile)
},
method: "POST",
url: "/v1/messages"
});
assert.equal(input.apiKeyId, "profile:Claude-Work-Profile");
assert.equal(input.profileId, "Claude Work/Profile");
});
test("built-in Codex route uses the authenticated profile instead of the first Codex profile", async () => {
const plugin = createRouterPlugin({
agent: "codex",
@@ -541,6 +888,39 @@ test("built-in Codex route uses the authenticated profile instead of the first C
assert.equal(result.decision.reason, "builtin:codex");
});
test("profile enhanced route switch disables the built-in Codex route", async () => {
const plugin = createRouterPlugin({
agent: "codex",
authenticatedProfileId: "codex",
profiles: [{
agent: "codex",
enabled: true,
id: "codex",
model: "Provider/gpt-5-codex",
name: "Codex",
routing: {
enabled: false,
enhancedRoute: false,
rules: []
},
scope: "ccr"
}]
});
const result = await plugin.routeRequest({
body: {
model: "gpt-5"
},
headers: {
"user-agent": "Codex Desktop/0.144.0"
},
method: "POST",
url: "/v1/responses"
});
assert.equal(result.body.model, "gpt-5");
assert.equal(result.decision.reason, "default");
});
test("built-in Codex route preserves the requested model when the authenticated profile does not match", async () => {
const plugin = createRouterPlugin({
agent: "codex",
@@ -1636,7 +2016,7 @@ test("built-in Codex route stays inactive when profile model is unset", async ()
assert.equal(result.decision.reason, "default");
});
test("built-in agent route stays off after the user disables it", async () => {
test("global built-in agent preference no longer disables the profile-level built-in route", async () => {
const plugin = createRouterPlugin({
claudeCodeRuleEnabled: false,
profileModel: "Provider/claude-sonnet"
@@ -1653,8 +2033,8 @@ test("built-in agent route stays off after the user disables it", async () => {
url: "/v1/messages"
});
assert.equal(result.body.model, "claude-default");
assert.equal(result.decision.reason, "default");
assert.equal(result.body.model, "Provider/claude-sonnet");
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("built-in Claude Code route injects subagent model instructions into Agent and Task tools", async () => {
@@ -1972,10 +2352,21 @@ test("built-in Claude Code route skips subagent instruction injection when no mo
assert.equal(workflowTool.input_schema.properties.script.description, "Workflow script.");
});
test("disabled built-in Claude Code route does not inject Agent tool instructions", async () => {
test("disabled profile enhanced route does not inject Agent tool instructions", async () => {
const plugin = createRouterPlugin({
claudeCodeRuleEnabled: false,
profileModel: "Provider/claude-sonnet"
profiles: [{
agent: "claude-code",
enabled: true,
id: "claude-code-profile",
model: "Provider/claude-sonnet",
name: "Claude Code",
routing: {
enabled: false,
enhancedRoute: false,
rules: []
},
scope: "global"
}]
});
const result = await plugin.routeRequest({
body: {
@@ -249,6 +249,79 @@ test("router config compilation accepts unrestricted Node.js script rules", () =
assert.equal(compiled.rules[1].diagnostics[0].code, "script-source-invalid");
});
test("router config compilation disables Node.js script rules in profile routing", () => {
const config = routingConfig({
profile: {
enabled: true,
profiles: [{
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Primary/alpha",
name: "Profile A",
routing: {
enabled: true,
enhancedRoute: true,
rules: [{
enabled: true,
id: "profile-script",
name: "Profile script",
script: {
apiVersion: 1,
file: "/tmp/profile-route.js",
language: "javascript",
timeoutMs: 1000
},
type: "script"
}]
},
scope: "ccr"
}]
}
});
const compiled = compileRouterConfig(config);
assert.equal(compiled.profileRoutings[0].rules[0].active, false);
assert.equal(compiled.profileRoutings[0].rules[0].diagnostics[0].code, "script-api-unsupported");
assert.match(compiled.profileRoutings[0].rules[0].diagnostics[0].message, /does not support Node\.js script rules/);
});
test("router config compilation ignores rules from disabled profile routing", () => {
const config = routingConfig({
profile: {
enabled: true,
profiles: [{
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Primary/alpha",
name: "Profile A",
routing: {
enabled: false,
enhancedRoute: true,
rules: [{
condition: { left: "request.header.x-task", operator: "==", right: "heavy" },
enabled: true,
id: "disabled-profile-rule",
name: "Disabled profile rule",
rewrites: [{ key: "request.body.model", operation: "set", value: "Primary/missing" }],
type: "condition"
}]
},
scope: "ccr"
}]
}
});
const compiled = compileRouterConfig(config);
assert.equal(compiled.profileRoutings[0].active, false);
assert.deepEqual(compiled.profileRoutings[0].rules, []);
assert.deepEqual(compiled.profileRoutings[0].diagnostics, []);
assert.equal(compiled.diagnostics.some((diagnostic) => diagnostic.ruleId === "disabled-profile-rule"), false);
});
test("dynamic script rewrites cannot mutate protected headers without breaking trusted static config", () => {
const rewrite = {
key: "request.header.authorization",
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
pruneInactiveProfileApiKeysFromList,
profileApiKeyId,
profileIdFromApiKeyId,
syncProfileApiKeys
} from "@ccr/core/profiles/api-key.ts";
test("profile API key ids are stable, sanitized, and profile-scoped", () => {
assert.equal(
profileApiKeyId({ agent: "claude-code", id: "Claude Work/Profile", name: "Claude Work" }),
"profile:Claude-Work-Profile"
);
assert.equal(
profileApiKeyId({ agent: "codex", id: "", name: "Codex Work" }),
"profile:Codex-Work"
);
assert.equal(
profileApiKeyId({ agent: "grok", id: "", name: "" }),
"profile:grok"
);
assert.equal(profileApiKeyId(" Team Profile ++ "), "profile:Team-Profile");
});
test("profile API key ids expose their sanitized profile key segment", () => {
assert.equal(profileIdFromApiKeyId("profile:Claude-Work-Profile"), "Claude-Work-Profile");
assert.equal(profileIdFromApiKeyId(" profile:with-space "), "with-space");
assert.equal(profileIdFromApiKeyId("general-key"), undefined);
assert.equal(profileIdFromApiKeyId(undefined), undefined);
});
test("profile API key sync creates stable independent keys per enabled profile", () => {
const generalKey = {
createdAt: "2026-01-01T00:00:00.000Z",
id: "general-key",
key: "general-key",
name: "General key"
};
const profileA = {
agent: "claude-code",
enabled: true,
id: "profile-a",
model: "Provider/model",
name: "Profile A",
scope: "ccr"
};
const profileB = {
agent: "codex",
enabled: true,
id: "profile-b",
model: "Provider/model",
name: "Profile B",
scope: "ccr"
};
let generated = 0;
const first = syncProfileApiKeys([generalKey], [profileA, profileB], {
generateKey: () => `generated-profile-key-${++generated}`,
now: () => "2026-01-02T00:00:00.000Z"
});
assert.equal(first.changed, true);
assert.deepEqual(first.apiKeys.map((apiKey) => apiKey.id), [
"general-key",
"profile:profile-a",
"profile:profile-b"
]);
assert.equal(first.tokens.get("profile-a"), "generated-profile-key-1");
assert.equal(first.tokens.get("profile-b"), "generated-profile-key-2");
assert.notEqual(first.tokens.get("profile-a"), first.tokens.get("profile-b"));
const renamedProfileA = { ...profileA, name: "Profile A Renamed" };
const second = syncProfileApiKeys(first.apiKeys, [renamedProfileA, profileB], {
generateKey: () => {
throw new Error("existing profile keys should be reused");
}
});
assert.equal(second.changed, true);
assert.equal(second.tokens.get("profile-a"), "generated-profile-key-1");
assert.equal(second.tokens.get("profile-b"), "generated-profile-key-2");
assert.equal(
second.apiKeys.find((apiKey) => apiKey.id === profileApiKeyId(profileA))?.name,
"Profile: Profile A Renamed"
);
const pruned = pruneInactiveProfileApiKeysFromList(second.apiKeys, [
renamedProfileA,
{ ...profileB, enabled: false }
]);
assert.equal(pruned.changed, true);
assert.deepEqual(pruned.apiKeys.map((apiKey) => apiKey.id), [
"general-key",
"profile:profile-a"
]);
assert.equal(
pruned.apiKeys.find((apiKey) => apiKey.id === profileApiKeyId(profileA))?.key,
"generated-profile-key-1"
);
});
@@ -12,7 +12,8 @@ import { detectedProviderFromHeaders, newApiKeyUsageAccountConfig, newApiUserSel
import {
checkGatewayProviderConnectivity,
isProviderProtocolEndpointSupportedForProbe,
probeGatewayProvider
probeGatewayProvider,
probeGatewayProviderCandidates
} from "@ccr/core/providers/probe.ts";
test("protocol support probe does not treat Gemini auth errors as every protocol", () => {
@@ -218,6 +219,99 @@ test("provider probe exposes image and video capabilities when their endpoints r
);
});
test("candidate protocol probe carries the entered API key and Authorization fallback", async (t) => {
const previousFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (input, init) => {
const url = new URL(String(input));
const headers = new Headers(init?.headers);
calls.push({
authorization: headers.get("authorization"),
pathname: url.pathname,
protocol: url.pathname.includes("/messages")
? "anthropic"
: url.pathname.includes(":generateContent")
? "gemini"
: "openai",
xApiKey: headers.get("x-api-key"),
xGoogApiKey: headers.get("x-goog-api-key")
});
return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), {
headers: { "content-type": "application/json" },
status: 401
});
};
t.after(() => {
globalThis.fetch = previousFetch;
});
await probeGatewayProviderCandidates({
apiKey: "sk-probe-key",
candidates: [{
baseUrl: "http://127.0.0.1:49124",
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"],
source: "custom"
}],
forceRefresh: true,
mode: "protocols",
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"]
});
assert.deepEqual(
calls.map((call) => call.protocol),
["openai", "anthropic", "gemini"]
);
assert.equal(calls[0]?.authorization, "Bearer sk-probe-key");
assert.equal(calls[1]?.authorization, "Bearer sk-probe-key");
assert.equal(calls[1]?.xApiKey, "sk-probe-key");
assert.equal(calls[2]?.authorization, "Bearer sk-probe-key");
assert.equal(calls[2]?.xGoogApiKey, "sk-probe-key");
});
test("model discovery carries Authorization fallback for protocol-specific API keys", async (t) => {
const previousFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (input, init) => {
const url = new URL(String(input));
const headers = new Headers(init?.headers);
calls.push({
authorization: headers.get("authorization"),
key: url.searchParams.get("key"),
pathname: url.pathname,
xApiKey: headers.get("x-api-key"),
xGoogApiKey: headers.get("x-goog-api-key")
});
return new Response(JSON.stringify({ data: [] }), {
headers: { "content-type": "application/json" },
status: 200
});
};
t.after(() => {
globalThis.fetch = previousFetch;
});
await probeGatewayProvider({
apiKey: "Bearer sk-model-key",
baseUrl: "http://127.0.0.1:49124",
forceRefresh: true,
mode: "models",
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"]
});
const modelCalls = calls.filter((call) => call.pathname.endsWith("/models"));
assert.equal(modelCalls.length >= 3, true);
assert.equal(calls.every((call) => call.authorization === "Bearer sk-model-key"), true);
assert.equal(modelCalls.some((call) => call.xApiKey === "sk-model-key"), true);
assert.equal(
modelCalls.some((call) => call.key === "sk-model-key" && call.xGoogApiKey === "sk-model-key"),
true
);
});
test("connectivity probe applies provider plugin auth for local agent imports", async (t) => {
const previousFetch = globalThis.fetch;
let called = false;
@@ -134,6 +134,21 @@ test("route script input derives every documented routing summary field", () =>
});
});
test("route script input uses the provided profile id instead of parsing the API key slug", () => {
const input = buildRouteScriptInput({
body: { model: "Provider/alpha" },
headers: { "X-Auth-Api-Key-Id": "profile:Claude-Work-Profile" },
log: console,
method: "POST",
url: "/v1/messages"
}, {
profileId: "Claude Work/Profile"
});
assert.equal(input.apiKeyId, "profile:Claude-Work-Profile");
assert.equal(input.profileId, "Claude Work/Profile");
});
test("route scripts receive frozen per-request input and a stable hash helper", async () => {
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
const script = routeScript(`
@@ -506,6 +521,49 @@ test("the route-script test service validates, executes, and previews a custom d
}
});
test("the route-script test service exposes the configured profile id", async () => {
const profile = {
agent: "claude-code",
enabled: true,
id: "Claude Work/Profile",
model: "Provider/alpha",
name: "Claude Work",
scope: "ccr"
};
const script = routeScript(`
return {
rewrites: [{ key: "request.body.profileId", operation: "set", value: input.profileId }]
};
`);
try {
const result = await gatewayService.testRouteScript({
...routingConfig(),
profile: {
enabled: true,
profiles: [profile]
}
}, {
request: {
body: { messages: [], model: "Provider/alpha" },
headers: { "x-auth-api-key-id": "profile:Claude-Work-Profile" },
method: "POST",
url: "/v1/messages"
},
script
});
assert.equal(result.ok, true);
assert.equal(result.matched, true);
assert.deepEqual(result.output.rewrites, [{
key: "request.body.profileId",
operation: "set",
value: "Claude Work/Profile"
}]);
} finally {
await gatewayService.stop();
}
});
test("dynamic script model deletion overrides an earlier static model rewrite", async () => {
const runtime = new RouteScriptRuntime({ workerCount: 1, workerFile });
const script = routeScript(`
@@ -9960,7 +9960,30 @@ function serveProjectFileResponse(runtime, projectId, row, filePath) {
const previewHtml = injectOmelettePreviewScripts(dependencySafeHtml, headers["x-ccr-preview-version"], previewPollUrl(projectId, filePath));
return textResponse(200, previewHtml, headers);
}
return binaryResponse(200, body, headers);
return binaryResponse(200, patchClaudeDesignProjectFileBody(body, filePath, contentType), headers);
}
function patchClaudeDesignProjectFileBody(body, filePath, contentType) {
if (!isDeckStageProjectScript(filePath, contentType)) {
return body;
}
const source = body.toString("utf8");
const patched = patchDeckStageThumbnailCloneVisibility(source);
return patched === source ? body : Buffer.from(patched, "utf8");
}
function isDeckStageProjectScript(filePath, contentType) {
return /(?:^|\/)deck-stage\.js$/i.test(String(filePath || "")) &&
(headerIncludes(contentType, "javascript") || headerIncludes(contentType, "text/plain") || !contentType);
}
function patchDeckStageThumbnailCloneVisibility(source) {
const current = "box-sizing:border-box;overflow:hidden;visibility:visible;opacity:1;";
const fixed = "box-sizing:border-box;overflow:hidden;display:block!important;visibility:visible!important;opacity:1!important;";
if (source.includes(fixed)) {
return source;
}
return source.replace(current, fixed);
}
function isHtmlProjectFile(filePath, contentType) {
@@ -15013,6 +15036,418 @@ try {
var frameIds = typeof WeakMap === 'function' ? new WeakMap() : null;
var nextFrameId = 1;
var blankPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
function dataUrlToBytes(dataUrl) {
try {
var base64 = String(dataUrl || blankPng).split(',', 2)[1] || '';
var raw = atob(base64);
var bytes = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
} catch (e) {
return new Uint8Array(0);
}
}
function nativeImageFromDataUrl(dataUrl) {
var url = dataUrl || blankPng;
return {
toDataURL: function() { return url; },
toPNG: function() { return dataUrlToBytes(url); }
};
}
function serializableCaptureRect(rect) {
return {
height: Number(rect && rect.height) || 0,
width: Number(rect && rect.width) || 0,
x: Number(rect && rect.x) || 0,
y: Number(rect && rect.y) || 0
};
}
function isCapturedPngDataUrl(value) {
return typeof value === 'string' && /^data:image\\/png;base64,/i.test(value) && value !== blankPng;
}
function numericRectValue(rect, key, fallback) {
var value = rect && Number(rect[key]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function waitForFramePaint(win) {
return new Promise(function(resolve) {
try {
var raf = win && win.requestAnimationFrame ? win.requestAnimationFrame.bind(win) : root.requestAnimationFrame && root.requestAnimationFrame.bind(root);
if (!raf) {
setTimeout(resolve, 0);
return;
}
raf(function() { raf(function() { resolve(); }); });
} catch (e) {
setTimeout(resolve, 0);
}
});
}
function frameCaptureViewport(frame, win, doc, rect) {
var rootElement = doc && doc.documentElement;
var body = doc && doc.body;
var frameRect;
try { frameRect = frame.getBoundingClientRect(); } catch (e) { frameRect = null; }
var viewportWidth = Math.max(
1,
Math.round(
Number(win && win.innerWidth) ||
Number(rootElement && rootElement.clientWidth) ||
Number(body && body.clientWidth) ||
Number(frame && frame.clientWidth) ||
Number(frameRect && frameRect.width) ||
1280
)
);
var viewportHeight = Math.max(
1,
Math.round(
Number(win && win.innerHeight) ||
Number(rootElement && rootElement.clientHeight) ||
Number(body && body.clientHeight) ||
Number(frame && frame.clientHeight) ||
Number(frameRect && frameRect.height) ||
720
)
);
var pageWidth = Math.max(
viewportWidth,
Math.round(Number(rootElement && rootElement.scrollWidth) || 0),
Math.round(Number(body && body.scrollWidth) || 0)
);
var pageHeight = Math.max(
viewportHeight,
Math.round(Number(rootElement && rootElement.scrollHeight) || 0),
Math.round(Number(body && body.scrollHeight) || 0)
);
var x = Math.max(0, Math.round(Number(rect && rect.x) || 0));
var y = Math.max(0, Math.round(Number(rect && rect.y) || 0));
var width = Math.max(1, Math.round(numericRectValue(rect, 'width', viewportWidth - x)));
var height = Math.max(1, Math.round(numericRectValue(rect, 'height', viewportHeight - y)));
return {
height: Math.min(height, Math.max(1, pageHeight - y)),
pageHeight: pageHeight,
pageWidth: pageWidth,
width: Math.min(width, Math.max(1, pageWidth - x)),
x: x,
y: y
};
}
function prepareCaptureClone(doc, size) {
var clone = doc.documentElement.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
try {
var scripts = clone.querySelectorAll('script');
for (var i = 0; i < scripts.length; i++) scripts[i].remove();
} catch (e) {}
var head = clone.querySelector('head');
if (!head) {
head = doc.createElement('head');
clone.insertBefore(head, clone.firstChild);
}
var base = doc.createElement('base');
base.setAttribute('href', doc.baseURI || doc.location && doc.location.href || '');
head.insertBefore(base, head.firstChild);
var style = doc.createElement('style');
style.textContent = 'html,body{margin:0!important;width:' + size.pageWidth + 'px!important;min-width:' + size.pageWidth + 'px!important;height:' + size.pageHeight + 'px!important;min-height:' + size.pageHeight + 'px!important;overflow:hidden!important;}*{animation:none!important;transition:none!important;}';
head.appendChild(style);
return clone;
}
function capturedStyleSheetText(sheet) {
try {
var rules = sheet && sheet.cssRules;
if (!rules) return '';
var text = '';
for (var i = 0; i < rules.length; i++) {
text += rules[i].cssText + '\\n';
}
return text;
} catch (e) {
return '';
}
}
function capturedShadowStyleText(shadowRoot) {
var text = '';
try {
var sheets = shadowRoot && shadowRoot.adoptedStyleSheets || [];
for (var i = 0; i < sheets.length; i++) {
text += capturedStyleSheetText(sheets[i]);
}
} catch (e) {}
return text
.replace(/:host\\(([^)]*)\\)/g, '[data-ccr-shadow-host]$1')
.replace(/:host\\b/g, '[data-ccr-shadow-host]');
}
function appendCapturedShadowStyle(shadowRoot, cloneHost, doc) {
var text = capturedShadowStyleText(shadowRoot);
if (!text) return;
var style = doc.createElement('style');
style.setAttribute('data-ccr-captured-shadow-styles', 'true');
style.textContent = text;
cloneHost.appendChild(style);
}
function inlineOpenShadowRoots(sourceNode, cloneNode, doc) {
if (!sourceNode || !cloneNode) return;
if (sourceNode.nodeType === 1 && sourceNode.shadowRoot) {
while (cloneNode.firstChild) cloneNode.removeChild(cloneNode.firstChild);
try { cloneNode.setAttribute('data-ccr-shadow-host', 'true'); } catch (e) {}
appendCapturedShadowStyle(sourceNode.shadowRoot, cloneNode, doc);
var shadowChildren = Array.prototype.slice.call(sourceNode.shadowRoot.childNodes || []);
var shadowClones = [];
for (var i = 0; i < shadowChildren.length; i++) {
var shadowClone = shadowChildren[i].cloneNode(true);
shadowClones.push(shadowClone);
cloneNode.appendChild(shadowClone);
}
for (var j = 0; j < shadowChildren.length; j++) {
inlineOpenShadowRoots(shadowChildren[j], shadowClones[j], doc);
}
return;
}
var sourceChildren = Array.prototype.slice.call(sourceNode.childNodes || []);
var cloneChildren = Array.prototype.slice.call(cloneNode.childNodes || []);
var count = Math.min(sourceChildren.length, cloneChildren.length);
for (var k = 0; k < count; k++) {
inlineOpenShadowRoots(sourceChildren[k], cloneChildren[k], doc);
}
}
function captureFrameDataUrl(frame, rect) {
return captureFrameDataUrlFromParent(frame, rect).then(function(dataUrl) {
if (isCapturedPngDataUrl(dataUrl)) return dataUrl;
return captureFrameDataUrlViaEval(frame, rect).then(function(evalDataUrl) {
return isCapturedPngDataUrl(evalDataUrl) ? evalDataUrl : dataUrl;
}, function() {
return dataUrl;
});
}, function() {
return captureFrameDataUrlViaEval(frame, rect);
});
}
function captureFrameDataUrlViaEval(frame, rect) {
var code = '(' + captureCurrentDocumentDataUrl.toString() + ')(' +
JSON.stringify(serializableCaptureRect(rect || {})) + ',' +
JSON.stringify(blankPng) +
')';
return postEval(frame, code, 5000);
}
function captureFrameDataUrlFromParent(frame, rect) {
return new Promise(function(resolve) {
try {
var win = frameSource(frame);
var doc = win && win.document;
if (!doc || !doc.documentElement) {
resolve(blankPng);
return;
}
var size = frameCaptureViewport(frame, win, doc, rect || {});
var clone = prepareCaptureClone(doc, size);
inlineOpenShadowRoots(doc.documentElement, clone, doc);
var serialized = new XMLSerializer().serializeToString(clone);
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + size.width + '" height="' + size.height + '" viewBox="0 0 ' + size.width + ' ' + size.height + '"><foreignObject x="' + (-size.x) + '" y="' + (-size.y) + '" width="' + size.pageWidth + '" height="' + size.pageHeight + '">' + serialized + '</foreignObject></svg>';
var image = new Image();
var settled = false;
function finish(value) {
if (settled) return;
settled = true;
resolve(value || blankPng);
}
var timer = setTimeout(function() { finish(blankPng); }, 3000);
image.onload = function() {
clearTimeout(timer);
try {
var scale = Math.max(1, Math.min(2, Number(root.devicePixelRatio) || 1));
var maxPixels = 2000000;
while (size.width * size.height * scale * scale > maxPixels && scale > 1) {
scale = Math.max(1, scale / 2);
}
var canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(size.width * scale));
canvas.height = Math.max(1, Math.round(size.height * scale));
var ctx = canvas.getContext('2d');
if (!ctx) {
finish(blankPng);
return;
}
ctx.scale(scale, scale);
ctx.drawImage(image, 0, 0);
finish(canvas.toDataURL('image/png'));
} catch (e) {
finish(blankPng);
}
};
image.onerror = function() {
clearTimeout(timer);
finish(blankPng);
};
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
} catch (e) {
resolve(blankPng);
}
});
}
function captureCurrentDocumentDataUrl(rect, fallbackPng) {
return new Promise(function(resolve) {
try {
function cssTextFromStyleSheet(sheet) {
try {
var rules = sheet && sheet.cssRules;
if (!rules) return '';
var text = '';
for (var i = 0; i < rules.length; i++) {
text += rules[i].cssText + '\\n';
}
return text;
} catch (e) {
return '';
}
}
function shadowStyleText(shadowRoot) {
var text = '';
try {
var sheets = shadowRoot && shadowRoot.adoptedStyleSheets || [];
for (var i = 0; i < sheets.length; i++) {
text += cssTextFromStyleSheet(sheets[i]);
}
} catch (e) {}
return text
.replace(/:host\\(([^)]*)\\)/g, '[data-ccr-shadow-host]$1')
.replace(/:host\\b/g, '[data-ccr-shadow-host]');
}
function appendShadowStyle(shadowRoot, cloneHost) {
var text = shadowStyleText(shadowRoot);
if (!text) return;
var style = doc.createElement('style');
style.setAttribute('data-ccr-captured-shadow-styles', 'true');
style.textContent = text;
cloneHost.appendChild(style);
}
function inlineShadows(sourceNode, cloneNode) {
if (!sourceNode || !cloneNode) return;
if (sourceNode.nodeType === 1 && sourceNode.shadowRoot) {
while (cloneNode.firstChild) cloneNode.removeChild(cloneNode.firstChild);
try { cloneNode.setAttribute('data-ccr-shadow-host', 'true'); } catch (e) {}
appendShadowStyle(sourceNode.shadowRoot, cloneNode);
var shadowChildren = Array.prototype.slice.call(sourceNode.shadowRoot.childNodes || []);
var shadowClones = [];
for (var i = 0; i < shadowChildren.length; i++) {
var shadowClone = shadowChildren[i].cloneNode(true);
shadowClones.push(shadowClone);
cloneNode.appendChild(shadowClone);
}
for (var j = 0; j < shadowChildren.length; j++) {
inlineShadows(shadowChildren[j], shadowClones[j]);
}
return;
}
var sourceChildren = Array.prototype.slice.call(sourceNode.childNodes || []);
var cloneChildren = Array.prototype.slice.call(cloneNode.childNodes || []);
var count = Math.min(sourceChildren.length, cloneChildren.length);
for (var k = 0; k < count; k++) {
inlineShadows(sourceChildren[k], cloneChildren[k]);
}
}
var doc = document;
var rootElement = doc && doc.documentElement;
var body = doc && doc.body;
if (!rootElement) {
resolve(fallbackPng);
return;
}
var viewportWidth = Math.max(
1,
Math.round(
Number(window.innerWidth) ||
Number(rootElement.clientWidth) ||
Number(body && body.clientWidth) ||
1280
)
);
var viewportHeight = Math.max(
1,
Math.round(
Number(window.innerHeight) ||
Number(rootElement.clientHeight) ||
Number(body && body.clientHeight) ||
720
)
);
var pageWidth = Math.max(
viewportWidth,
Math.round(Number(rootElement.scrollWidth) || 0),
Math.round(Number(body && body.scrollWidth) || 0)
);
var pageHeight = Math.max(
viewportHeight,
Math.round(Number(rootElement.scrollHeight) || 0),
Math.round(Number(body && body.scrollHeight) || 0)
);
var x = Math.max(0, Math.round(Number(rect && rect.x) || 0));
var y = Math.max(0, Math.round(Number(rect && rect.y) || 0));
var width = Math.max(1, Math.round(Number(rect && rect.width) > 0 ? Number(rect.width) : viewportWidth - x));
var height = Math.max(1, Math.round(Number(rect && rect.height) > 0 ? Number(rect.height) : viewportHeight - y));
width = Math.min(width, Math.max(1, pageWidth - x));
height = Math.min(height, Math.max(1, pageHeight - y));
var clone = rootElement.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
try {
var scripts = clone.querySelectorAll('script');
for (var i = 0; i < scripts.length; i++) scripts[i].remove();
} catch (e) {}
var head = clone.querySelector('head');
if (!head) {
head = doc.createElement('head');
clone.insertBefore(head, clone.firstChild);
}
var base = doc.createElement('base');
base.setAttribute('href', doc.baseURI || location.href || '');
head.insertBefore(base, head.firstChild);
var style = doc.createElement('style');
style.textContent = 'html,body{margin:0!important;width:' + pageWidth + 'px!important;min-width:' + pageWidth + 'px!important;height:' + pageHeight + 'px!important;min-height:' + pageHeight + 'px!important;overflow:hidden!important;}*{animation:none!important;transition:none!important;}';
head.appendChild(style);
inlineShadows(rootElement, clone);
var serialized = new XMLSerializer().serializeToString(clone);
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '" viewBox="0 0 ' + width + ' ' + height + '"><foreignObject x="' + (-x) + '" y="' + (-y) + '" width="' + pageWidth + '" height="' + pageHeight + '">' + serialized + '</foreignObject></svg>';
var image = new Image();
var settled = false;
function finish(value) {
if (settled) return;
settled = true;
resolve(value || fallbackPng);
}
var timer = setTimeout(function() { finish(fallbackPng); }, 3000);
image.onload = function() {
clearTimeout(timer);
try {
var scale = Math.max(1, Math.min(2, Number(window.devicePixelRatio) || 1));
var maxPixels = 2000000;
while (width * height * scale * scale > maxPixels && scale > 1) {
scale = Math.max(1, scale / 2);
}
var canvas = doc.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
var ctx = canvas.getContext('2d');
if (!ctx) {
finish(fallbackPng);
return;
}
ctx.scale(scale, scale);
ctx.drawImage(image, 0, 0);
finish(canvas.toDataURL('image/png'));
} catch (e) {
finish(fallbackPng);
}
};
image.onerror = function() {
clearTimeout(timer);
finish(fallbackPng);
};
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
} catch (e) {
resolve(fallbackPng);
}
});
}
function isFrame(node) {
if (!node || node.nodeType !== 1) return false;
var tag = String(node.tagName || '').toLowerCase();
@@ -15281,17 +15716,12 @@ try {
};
}
if (typeof proto.capturePage !== 'function') {
proto.capturePage = function() {
return Promise.resolve({
toDataURL: function() { return blankPng; },
toPNG: function() {
try {
var raw = atob(blankPng.split(',')[1]);
var bytes = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
} catch (e) { return new Uint8Array(0); }
}
proto.capturePage = function(rect) {
var frame = this;
return waitForFramePaint(frameSource(frame)).then(function() {
return captureFrameDataUrl(frame, rect);
}).then(nativeImageFromDataUrl, function() {
return nativeImageFromDataUrl(blankPng);
});
};
}
+11 -53
View File
@@ -2,7 +2,6 @@ import {
AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentFilterValue,
ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppSaveConfigOptions, AppUpdateStatus,
AppLanguagePreference, applyProviderProbeResult, AppToast, BotGatewaySavedConfig, buildExtensionList, claudeDesignRoutingConfigFromDraft,
buildRouterConditionPath,
ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft,
createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis,
copyTextToClipboard, createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft,
@@ -19,7 +18,7 @@ import {
isTraySupportedPlatform,
LayoutGroup, mergeModelDisplayNames, mergeModelMetadata, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels, modelMetadataForModels,
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProxyConfig,
normalizeProfileItem, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeProfileItem, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
OverviewWidgetConfig, parseProviderAccountDraft, pluginConfigPatchFromSettingsDraft,
providerCredentialsFromDraft,
@@ -28,9 +27,9 @@ import {
profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
providerBaseUrl, providerCapabilitiesForProtocols, providerCapabilitiesForSave, providerConnectivityApiKeyFromDraft, providerConnectivityProviderPlugins, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerProtocolOptions, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot,
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, removeLocalAgentProviderPluginsForProvider, RouterRule, SettingsPageId,
routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig,
uniqueProviderProtocols, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect,
ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, removeLocalAgentProviderPluginsForProvider, RouterRule, routingRuleFromDraft, SettingsPageId,
setProviderPresets, splitLines, translateAppErrorMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig,
uniqueProviderProtocols, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect,
useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId,
VirtualModelDraft, virtualModelProfileFromDraft, virtualModelProfilesUseMediaTools
} from "./shared/index";
@@ -38,7 +37,7 @@ import { preserveEqualPollingSnapshot, startVisiblePolling } from "./shared/poll
import {
AppDialogStack, LightToast, MainLayout, OnboardingLayout, shouldCheckForUpdateOnOpen
} from "./components/index";
import { hasAvailableGatewayModels, ROUTER_SCRIPT_API_VERSION } from "@ccr/core/contracts/app";
import { hasAvailableGatewayModels } from "@ccr/core/contracts/app";
type ProfileOpenDialogState = {
busy?: "" | "cli" | "app";
@@ -1749,43 +1748,17 @@ function App() {
return;
}
const rewrites = routingRuleDraft.rewrites.map(routingRewriteFromDraftRow);
const commonRule = {
enabled: routingRuleDraft.enabled,
fallback: normalizeRouterFallbackConfig(routingRuleDraft.fallback),
id: uniqueRoutingRuleId(draftConfig.Router.rules),
name: routingRuleDraft.name.trim()
};
const rule: RouterRule = routingRuleDraft.type === "script"
? {
...commonRule,
script: {
apiVersion: ROUTER_SCRIPT_API_VERSION,
file: routingRuleDraft.scriptFile.trim(),
language: "javascript",
timeoutMs: Number(routingRuleDraft.scriptTimeoutMs)
},
type: "script"
}
: {
...commonRule,
condition: {
left: buildRouterConditionPath(routingRuleDraft.conditionSource, routingRuleDraft.conditionField),
operator: routingRuleDraft.conditionOperator,
right: routingRuleDraft.conditionRight.trim()
},
rewrites,
type: "condition"
};
const rule = routingRuleFromDraft(
routingRuleDraft,
draftConfig.Router.rules,
routingEditIndex === undefined ? undefined : draftConfig.Router.rules[routingEditIndex]
);
updateConfig((config) => {
if (routingEditIndex === undefined) {
config.Router.rules = [...config.Router.rules, rule];
} else {
config.Router.rules[routingEditIndex] = {
...rule,
id: config.Router.rules[routingEditIndex]?.id ?? rule.id
};
config.Router.rules[routingEditIndex] = rule;
}
return config;
});
@@ -3097,21 +3070,6 @@ function App() {
moveRule: moveRoutingRule,
providers: draftConfig.Providers,
removeRule: setRoutingDeleteIndex,
updateBuiltInRule: (agent, patch) => updateConfig((config) => {
config.Router.builtInRules = normalizeRouterBuiltInRules(config.Router.builtInRules);
if (agent === "claude-code") {
config.Router.builtInRules["claude-code"] = {
...config.Router.builtInRules["claude-code"],
...patch
};
} else {
config.Router.builtInRules.codex = {
...config.Router.builtInRules.codex,
...patch
};
}
return config;
}),
updateFallback: (fallback) => updateConfig((config) => {
config.Router.fallback = normalizeRouterFallbackConfig(fallback);
return config;
@@ -3517,7 +3517,7 @@ function AgentSessionDetailCard({
<AnalysisEmptyState label={t("No session requests")} />
) : (
<div className={cn("max-h-[260px]", agentListFrameClassName)}>
<table className={cn("min-w-[980px]", agentListTableClassName)}>
<table className={cn("min-w-[1060px]", agentListTableClassName)}>
<thead className={agentListHeadClassName}>
<tr>
<th className="px-3 py-2 font-semibold">{t("Time")}</th>
@@ -3525,7 +3525,8 @@ function AgentSessionDetailCard({
<th className="px-3 py-2 font-semibold">{t("Route")}</th>
<th className="px-3 py-2 font-semibold">{t("Model")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Tools")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Tokens")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Token")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Duration")}</th>
</tr>
</thead>
@@ -3538,6 +3539,7 @@ function AgentSessionDetailCard({
<td className="max-w-[300px] px-3 py-2" title={`${request.provider}/${request.model}`}>{request.provider}/{request.model}</td>
<td className="px-3 py-2 text-right" title={request.tools.join(", ")}>{formatCompactNumber(request.toolCallCount)}</td>
<td className="px-3 py-2 text-right">{formatCompactNumber(request.totalTokens)}</td>
<td className="px-3 py-2 text-right">{formatUsdCost(request.costUsd ?? 0)}</td>
<td className="px-3 py-2 text-right">{formatDuration(request.durationMs)}</td>
</tr>
))}
@@ -3557,19 +3559,22 @@ function AgentSessionDetailCard({
const agentListSurfaceClassName = "rounded-md border border-border/70 bg-card/70 shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
const agentListFrameClassName = cn("overflow-auto", agentListSurfaceClassName);
const agentListTableClassName = "w-full border-collapse text-left text-[11px]";
const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur";
const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur [&_th]:min-w-[64px] [&_th]:whitespace-nowrap";
const agentListBodyClassName = "divide-y divide-border/50";
function agentListRowClassName({
danger,
selected
selected,
warning
}: {
danger?: boolean;
selected?: boolean;
warning?: boolean;
} = {}) {
return cn(
"bg-card/40 transition-colors hover:bg-muted/30",
danger && "bg-rose-500/5 hover:bg-rose-500/10",
warning && "bg-amber-500/5 hover:bg-amber-500/10",
selected && "bg-teal-500/10 shadow-[inset_2px_0_0_rgba(20,184,166,0.7)] hover:bg-teal-500/15"
);
}
@@ -3598,22 +3603,26 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
<AnalysisEmptyState label={t("No trace runs")} />
) : (
<div className={cn("max-h-[420px]", agentListFrameClassName)}>
<table className={cn("min-w-[1180px]", agentListTableClassName)}>
<table className={cn("min-w-[1260px]", agentListTableClassName)}>
<thead className={agentListHeadClassName}>
<tr>
<th className="px-3 py-2 font-semibold">{t("Run")}</th>
<th className="px-3 py-2 font-semibold">{t("Timeline")}</th>
<th className="px-3 py-2 font-semibold">{t("Status")}</th>
<th className="px-3 py-2 font-semibold">{t("Target")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Tokens")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Token")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Cache")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Concurrency")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Duration")}</th>
</tr>
</thead>
<tbody className={agentListBodyClassName}>
{trace.runs.map((run) => (
<tr className={agentListRowClassName({ danger: run.status === "error" })} key={run.id}>
<tr className={agentListRowClassName({
danger: run.status === "error",
warning: run.status === "partial"
})} key={run.id}>
<td className="max-w-[360px] px-3 py-2">
<div className="flex min-w-0 items-center gap-2" style={{ paddingLeft: `${Math.min(run.depth, 8) * 16}px` }}>
<span className={cn("h-2 w-2 shrink-0 rounded-full", traceRunDotClass(run))} />
@@ -3636,8 +3645,8 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
</div>
</td>
<td className="px-3 py-2">
<Badge className={cn("border", run.status === "error" ? "border-rose-200 bg-rose-50 text-rose-700" : "border-emerald-200 bg-emerald-50 text-emerald-700")} variant="outline">
{t(run.status === "error" ? "Error" : "Success")}
<Badge className={cn("border", traceRunStatusBadgeClass(run.status))} variant="outline">
{t(traceRunStatusLabel(run.status))}
</Badge>
</td>
<td className="max-w-[260px] px-3 py-2" title={traceRunTarget(run)}>
@@ -3645,6 +3654,7 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
</td>
<td className="px-3 py-2 text-right">{run.totalTokens > 0 ? formatCompactNumber(run.totalTokens) : "-"}</td>
<td className="px-3 py-2 text-right">{run.cacheReadTokens + run.cacheWriteTokens > 0 ? formatCompactNumber(run.cacheReadTokens + run.cacheWriteTokens) : "-"}</td>
<td className="px-3 py-2 text-right">{run.costUsd !== undefined ? formatUsdCost(run.costUsd) : "-"}</td>
<td className="px-3 py-2 text-right">{formatCompactNumber(run.concurrentRequests)}</td>
<td className="px-3 py-2 text-right">{formatDuration(run.durationMs)}</td>
</tr>
@@ -4000,6 +4010,7 @@ function traceRunBarStyle(run: AgentAnalysisTraceRun, traceDurationMs: number):
function traceRunDotClass(run: AgentAnalysisTraceRun): string {
if (run.status === "error") return "bg-rose-500";
if (run.status === "partial") return "bg-amber-500";
if (run.kind === "agent") return "bg-teal-500";
if (run.kind === "route") return "bg-cyan-500";
if (run.kind === "subagent") return "bg-amber-500";
@@ -4009,6 +4020,7 @@ function traceRunDotClass(run: AgentAnalysisTraceRun): string {
function traceRunBarClass(run: AgentAnalysisTraceRun): string {
if (run.status === "error") return "bg-rose-500";
if (run.status === "partial") return "bg-amber-500";
if (run.kind === "agent") return "bg-teal-500";
if (run.kind === "route") return "bg-cyan-500";
if (run.kind === "subagent") return "bg-amber-500";
@@ -4016,6 +4028,18 @@ function traceRunBarClass(run: AgentAnalysisTraceRun): string {
return "bg-blue-500";
}
function traceRunStatusBadgeClass(status: AgentAnalysisTraceRun["status"]): string {
if (status === "error") return "border-rose-200 bg-rose-50 text-rose-700";
if (status === "partial") return "border-amber-200 bg-amber-50 text-amber-700";
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
function traceRunStatusLabel(status: AgentAnalysisTraceRun["status"]): string {
if (status === "error") return "Error";
if (status === "partial") return "Partial failure";
return "Success";
}
function formatRouteReason(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) {
@@ -4041,7 +4065,7 @@ function AgentSessionsCard({
<AnalysisEmptyState label={t("No session activity")} />
) : (
<div className={cn("h-full", agentListFrameClassName)}>
<table className={cn("min-w-[1260px]", agentListTableClassName)}>
<table className={cn("min-w-[1420px]", agentListTableClassName)}>
<thead className={agentListHeadClassName}>
<tr>
<th className="px-3 py-2 font-semibold">{t("Session")}</th>
@@ -4054,6 +4078,8 @@ function AgentSessionsCard({
<th className="px-3 py-2 text-right font-semibold">{t("Tools")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Subagents")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Errors")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Cache rate")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th>
<th className="px-3 py-2 font-semibold">{t("Models")}</th>
<th className="px-3 py-2 font-semibold">{t("Providers")}</th>
<th className="px-3 py-2 font-semibold">{t("UA")}</th>
@@ -4077,6 +4103,8 @@ function AgentSessionsCard({
<td className="px-3 py-2 text-right">{formatCompactNumber(session.toolCallCount)}</td>
<td className="px-3 py-2 text-right">{formatCompactNumber(session.subagentCallCount)}</td>
<td className="px-3 py-2 text-right">{formatCompactNumber(session.errorCount)}</td>
<td className="px-3 py-2 text-right">{formatPercent(session.cacheRatio)}</td>
<td className="px-3 py-2 text-right font-semibold">{formatUsdCost(session.costUsd)}</td>
<td className="max-w-[240px] px-3 py-2" title={session.models.join(", ")}>{session.models.join(", ") || "-"}</td>
<td className="max-w-[220px] px-3 py-2" title={session.providers.join(", ")}>{session.providers.join(", ") || "-"}</td>
<td className="max-w-[220px] px-3 py-2 font-mono" title={session.userAgent}>{compactUserAgent(session.userAgent)}</td>
@@ -4143,7 +4171,7 @@ function UsageAnalysisCard({
{visibleColumns.map((column) => (
<th className="px-3 py-2 font-semibold" key={column.key}>{column.label}</th>
))}
<th className="px-3 py-2 text-right font-semibold">{t("Tokens")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("Token")}</th>
{showCost ? <th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th> : null}
<th className="px-3 py-2 text-right font-semibold">{t("Requests")}</th>
{showTokenBreakdown ? <th className="px-3 py-2 text-right font-semibold">{t("Input")}</th> : null}
@@ -782,7 +782,7 @@ function logTableColumnLabel(columnId: LogTableColumnId, t: (value: string) => s
case "credential":
return t("Credential");
case "tokens":
return t("令牌");
return t("Token");
case "duration":
return t("持续时间");
}
@@ -849,7 +849,7 @@ function LogMobileCard({
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px]">
<LogCompactMetric label={t("令牌")} value={tokenSummary} />
<LogCompactMetric label={t("Token")} value={tokenSummary} />
<LogCompactMetric label={t("持续时间")} value={formatDuration(item.durationMs)} />
{hasCredentialInfo ? <LogCompactMetric label={t("Credential")} value={logCredentialCellLabel(item)} /> : null}
<LogCompactMetric label={t("Provider")} value={item.provider || "-"} />
@@ -1,17 +1,19 @@
import {
AddProfileDraft, AgentLogo, AnimatedIconSwap, AnimatedPopover, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, BotHandoffScanTarget, Button,
AddProfileDraft, AddRoutingRuleDraft, AgentLogo, AnimatedIconSwap, AnimatedPopover, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, BotHandoffScanTarget, Button,
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, CircleAlert, Copy,
createRoutingRuleDraft, createRoutingRuleDraftFromRule,
cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion,
normalizeProfileScope, normalizeProfileSurface, Pencil, Plus, PopoverContent,
profileAgentLabel, profileAgentOptions, ProfileConfig, type ProfileAgentOption, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
Play, Power, RefreshCw, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, useLayoutEffect, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig,
copyTextToClipboard, validateProfileEnvRows,
copyTextToClipboard, formatRouterRuleCondition, formatRouterRuleTarget, isRoutingRuleDraftSubmittable, routerRuleTypeLabel, routingRuleFromDraft, type RouterRule, validateProfileEnvRows,
useCallback, useEffect, useMemo, useRef, useState, X
} from "../shared/index";
import { PopoverPortal } from "@/components/ui/popover";
import { Tooltip } from "@/components/ui/tooltip";
import { ModelMultiSelector, ModelSelector } from "./model-selector";
import { AddRoutingRuleDialog } from "./routing";
const useClientLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -723,7 +725,7 @@ export function AddProfileForm({
].filter(Boolean).length;
const advancedSummary = advancedIssueCount > 0
? t("Advanced settings need attention")
: t("Paths, bot, compact, and env");
: t("Paths, routing, bot, compact, and env");
const handleAppPathDrop = useCallback((event: ReactDragEvent<HTMLElement>) => {
if (!showAppPathField) {
return;
@@ -977,6 +979,11 @@ export function AddProfileForm({
transition={{ duration: 0.16 }}
>
<div className="mt-3 grid grid-cols-1 gap-3 rounded-md border border-border bg-background/60 p-3 sm:grid-cols-2">
<ProfileRoutingSettings
draft={draft}
onChange={onChange}
providers={providers}
/>
{showAppPathField && appPathLabel ? (
<Field className="sm:col-span-2" label={t(appPathLabel)} requirement="optional" requirementLabel={optionalFieldLabel}>
<div className={cn(
@@ -1055,6 +1062,190 @@ export function AddProfileForm({
);
}
function ProfileRoutingSettings({
draft,
onChange,
providers
}: {
draft: AddProfileDraft;
onChange: (patch: Partial<AddProfileDraft>) => void;
providers: GatewayProviderConfig[];
}) {
const t = useAppText();
const [ruleDialog, setRuleDialog] = useState<{ draft: AddRoutingRuleDraft; index?: number }>();
const canSubmitRule = ruleDialog ? isRoutingRuleDraftSubmittable(ruleDialog.draft) : false;
const showEnhancedRoute = draft.agent === "claude-code" || draft.agent === "codex";
const showRoutingControls = draft.routingEnabled || showEnhancedRoute;
const enhancedRouteDescription = draft.agent === "codex"
? t("Enhanced route description Codex")
: t("Enhanced route description Claude Code");
function openAddRuleDialog() {
setRuleDialog({
draft: createRoutingRuleDraft()
});
}
function openEditRuleDialog(index: number) {
const rule = draft.routingRules[index];
if (!rule) {
return;
}
setRuleDialog({
draft: createProfileRoutingRuleDraftFromRule(rule),
index
});
}
function updateRuleDialog(patch: Partial<AddRoutingRuleDraft>) {
setRuleDialog((current) => current ? { ...current, draft: { ...current.draft, ...patch } } : current);
}
function submitRuleDialog() {
if (!ruleDialog || !canSubmitRule) {
return;
}
const rule = routingRuleFromDraft(
ruleDialog.draft,
draft.routingRules,
ruleDialog.index === undefined ? undefined : draft.routingRules[ruleDialog.index]
);
const routingRules = ruleDialog.index === undefined
? [...draft.routingRules, rule]
: draft.routingRules.map((item, index) => index === ruleDialog.index ? rule : item);
onChange({
routingEnabled: true,
routingRules
});
setRuleDialog(undefined);
}
function removeRule(index: number) {
onChange({
routingRules: draft.routingRules.filter((_, ruleIndex) => ruleIndex !== index)
});
}
return (
<div className="sm:col-span-2 rounded-md border border-border bg-muted/20 p-3">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold">{t("Profile routing")}</div>
{draft.routingEnabled ? (
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{`${draft.routingRules.length} ${t(draft.routingRules.length === 1 ? "route" : "routes")}`}
</div>
) : null}
</div>
<Toggle
checked={draft.routingEnabled}
onChange={(routingEnabled) => onChange({ routingEnabled })}
/>
</div>
{showRoutingControls ? (
<div className="mt-3 grid grid-cols-1 gap-3 border-t border-border/70 pt-3">
{showEnhancedRoute ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
<span className="flex min-w-0 items-center gap-1.5">
<span className="text-[12px] font-medium">{t("Enhanced route")}</span>
<Tooltip
aria-label={enhancedRouteDescription}
className="h-5 w-5 items-center justify-center rounded-full text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
content={enhancedRouteDescription}
contentClassName="w-[260px] max-w-[calc(100vw-64px)] whitespace-normal px-2.5 py-2 text-left font-medium leading-4"
side="right"
tabIndex={0}
>
<button
aria-label={enhancedRouteDescription}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25"
type="button"
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</Tooltip>
</span>
<Toggle
checked={draft.routingEnhancedRoute}
onChange={(routingEnhancedRoute) => onChange({ routingEnhancedRoute })}
/>
</div>
) : null}
{draft.routingEnabled ? (
<div className="rounded-md border border-border bg-background p-3">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-[12px] font-medium">{t("Profile routes")}</span>
<Button onClick={openAddRuleDialog} size="sm" type="button" variant="outline">
<Plus className="h-3.5 w-3.5" />
{t("Add")}
</Button>
</div>
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
{draft.routingRules.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-2 text-[12px] text-muted-foreground">
{t("No routing rules configured")}
</div>
) : draft.routingRules.map((rule, index) => (
<div className="grid min-w-0 grid-cols-[1fr_auto] gap-2 rounded-md border border-border px-3 py-2" key={`${rule.id}-${index}`}>
<button
className="min-w-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/25"
onClick={() => openEditRuleDialog(index)}
type="button"
>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-[12px] font-semibold">{rule.name || t("Unnamed")}</span>
<Badge variant={rule.enabled ? "success" : "outline"}>{t(rule.enabled ? "Enabled" : "Disabled")}</Badge>
<Badge variant="outline">{t(routerRuleTypeLabel(rule.type))}</Badge>
</div>
<div className="mt-1 min-w-0 truncate text-[11px] text-muted-foreground" title={formatRouterRuleCondition(rule)}>
{formatRouterRuleCondition(rule)}
</div>
<div className="mt-0.5 min-w-0 truncate font-mono text-[11px] text-muted-foreground" title={formatRouterRuleTarget(rule)}>
{formatRouterRuleTarget(rule)}
</div>
</button>
<div className="flex shrink-0 items-center gap-1">
<Button aria-label={t("Edit")} onClick={() => openEditRuleDialog(index)} size="iconSm" title={t("Edit")} type="button" variant="ghost">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button aria-label={t("Remove")} onClick={() => removeRule(index)} size="iconSm" title={t("Remove")} type="button" variant="ghost">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
{ruleDialog ? (
<AddRoutingRuleDialog
canSubmit={canSubmitRule}
draft={ruleDialog.draft}
mode={ruleDialog.index === undefined ? "add" : "edit"}
onChange={updateRuleDialog}
onClose={() => setRuleDialog(undefined)}
onSubmit={submitRuleDialog}
allowedRuleTypes={["condition"]}
providers={providers}
/>
) : null}
</div>
);
}
function createProfileRoutingRuleDraftFromRule(rule: RouterRule): AddRoutingRuleDraft {
const draft = createRoutingRuleDraftFromRule(rule);
return draft.type === "condition"
? draft
: {
...createRoutingRuleDraft(),
enabled: rule.enabled,
name: rule.name
};
}
function ProfileFieldHint({ children }: { children: ReactNode }) {
return <div className="text-[11px] leading-4 text-amber-700 dark:text-amber-300">{children}</div>;
}
+50 -104
View File
@@ -4,9 +4,9 @@ import {
CardHeader, Check, CircleAlert, clampNumber, cn, createRouteModelOptions, createRoutingRewriteDraftRow,
Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle,
disclosureSpringTransition, Field, formatRouterRuleCondition, formatRouterRuleTarget, GatewayProviderConfig, Input,
AppI18nContext, appCopy, ExternalLink, FolderOpen, Info, motion, normalizeRouteScriptSampleRequest, normalizeRouterFallbackConfig, Pencil, Plus, Route, RouterFallbackConfig,
RouterBuiltInAgentRuleId, RouterFallbackMode, routerConditionSourceOptions, routerFallbackModeOptions, RouterRule, routerRewriteOperationOptions, routerRuleOperatorOptions,
RouterBuiltInAgentRuleConfig, routerRuleTypeOptions,
AppI18nContext, appCopy, ExternalLink, FolderOpen, motion, normalizeRouteScriptSampleRequest, normalizeRouterFallbackConfig, Pencil, Plus, Route, RouterFallbackConfig,
RouterFallbackMode, routerConditionSourceOptions, routerFallbackModeOptions, RouterRule, routerRewriteOperationOptions, routerRuleOperatorOptions,
routerRuleTypeOptions,
RouteTargetControl, routingRuleRowMatchesQuery, Search, SelectControl, Toggle, translateOptions,
Textarea, Trash2, uniqueStrings, useAppText, useContext, useMemo, useRef, useState, X
} from "../shared/index";
@@ -23,7 +23,6 @@ export function RoutingView({
moveRule,
providers,
removeRule,
updateBuiltInRule,
updateFallback,
updateRule
}: {
@@ -33,7 +32,6 @@ export function RoutingView({
moveRule: (index: number, direction: -1 | 1) => void;
providers: GatewayProviderConfig[];
removeRule: (index: number) => void;
updateBuiltInRule: (agent: RouterBuiltInAgentRuleId, patch: Partial<RouterBuiltInAgentRuleConfig>) => void;
updateFallback: (fallback: RouterFallbackConfig) => void;
updateRule: (index: number, patch: Partial<RouterRule>) => void;
}) {
@@ -103,7 +101,7 @@ export function RoutingView({
<div className="divide-y divide-border/60">
<AnimatePresence initial={false}>
{visibleRules.map((row) => {
const rowSourceLabel = row.builtInAgent ? t(row.sourceLabel) : row.sourceLabel;
const rowSourceLabel = row.sourceLabel;
const rowTarget = row.target === "Profile model unset" ? t(row.target) : row.target;
const toggleDisabledReason = row.toggleDisabledReason ? t(row.toggleDisabledReason) : undefined;
return (
@@ -114,25 +112,22 @@ export function RoutingView({
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[12px] font-semibold">{row.name || t("Unnamed")}</div>
{row.builtInAgent ? <BuiltInRouteInfoIcon agent={row.builtInAgent} /> : null}
{row.builtInAgent ? <Badge variant="outline">{t("Built-in")}</Badge> : row.readonly ? <Badge variant="outline">{t("Plugin")}</Badge> : null}
{row.readonly ? <Badge variant="outline">{t("Plugin")}</Badge> : null}
</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={`${rowSourceLabel}: ${row.ruleId}`}>
{rowSourceLabel}: {row.ruleId}
</div>
</div>
<div className="min-w-0">
{!row.builtInAgent ? (
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline">{t(row.typeLabel)}</Badge>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={row.condition}>
{row.condition}
</span>
</div>
) : null}
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline">{t(row.typeLabel)}</Badge>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={row.condition}>
{row.condition}
</span>
</div>
</div>
<div className="min-w-0 truncate font-mono text-[11px] text-muted-foreground" title={row.builtInAgent ? undefined : rowTarget}>
{row.builtInAgent ? null : rowTarget}
<div className="min-w-0 truncate font-mono text-[11px] text-muted-foreground" title={rowTarget}>
{rowTarget}
</div>
<div className="flex min-w-0 items-center gap-2">
<Tooltip
@@ -148,9 +143,7 @@ export function RoutingView({
checked={row.enabled}
disabled={row.readonly || row.toggleDisabled}
onChange={(enabled) => {
if (row.builtInAgent) {
updateBuiltInRule(row.builtInAgent, { enabled });
} else if (row.index !== undefined) {
if (row.index !== undefined) {
updateRule(row.index, { enabled });
}
}}
@@ -158,34 +151,30 @@ export function RoutingView({
</Tooltip>
</div>
<div className="flex items-center justify-end gap-1">
{!row.builtInAgent ? (
<>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("up")}`} disabled={row.readonly || row.index === undefined || row.index === 0} onClick={() => row.index !== undefined && moveRule(row.index, -1)} size="iconSm" title={t("Move up")} type="button" variant="ghost">
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("down")}`} disabled={row.readonly || row.index === undefined || row.index === row.ruleCount - 1} onClick={() => row.index !== undefined && moveRule(row.index, 1)} size="iconSm" title={t("Move down")} type="button" variant="ghost">
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
aria-label={`${t("Edit")} ${row.name || t("rule")}`}
disabled={row.readonly || row.index === undefined}
onClick={() => {
if (row.index !== undefined) {
editRule(row.index);
}
}}
size="iconSm"
title={t("Edit rule")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Remove")} ${row.name || t("rule")}`} disabled={row.readonly || row.index === undefined} onClick={() => row.index !== undefined && removeRule(row.index)} size="iconSm" title={t("Remove rule")} type="button" variant="ghost">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
) : null}
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("up")}`} disabled={row.readonly || row.index === undefined || row.index === 0} onClick={() => row.index !== undefined && moveRule(row.index, -1)} size="iconSm" title={t("Move up")} type="button" variant="ghost">
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("down")}`} disabled={row.readonly || row.index === undefined || row.index === row.ruleCount - 1} onClick={() => row.index !== undefined && moveRule(row.index, 1)} size="iconSm" title={t("Move down")} type="button" variant="ghost">
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
aria-label={`${t("Edit")} ${row.name || t("rule")}`}
disabled={row.readonly || row.index === undefined}
onClick={() => {
if (row.index !== undefined) {
editRule(row.index);
}
}}
size="iconSm"
title={t("Edit rule")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Remove")} ${row.name || t("rule")}`} disabled={row.readonly || row.index === undefined} onClick={() => row.index !== undefined && removeRule(row.index)} size="iconSm" title={t("Remove rule")} type="button" variant="ghost">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</AnimatedListItem>
);
@@ -201,56 +190,6 @@ export function RoutingView({
);
}
function BuiltInRouteInfoIcon({ agent }: { agent: RouterBuiltInAgentRuleId }) {
const t = useAppText();
const copy = useContext(AppI18nContext);
const description = builtInRouteDescription(agent, t);
const docsUrl = builtInRouteDocsUrl(agent, copy === appCopy.zh ? "zh" : "en");
return (
<Tooltip
aria-label={description}
className="h-5 w-5 items-center justify-center rounded-full text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
content={(
<>
<span>{description}</span>
<a
className="ml-1 inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
href={docsUrl}
onClick={(event) => {
event.preventDefault();
openExternalUrl(docsUrl);
}}
rel="noreferrer"
target="_blank"
>
{t("Docs")}
<ExternalLink className="h-3 w-3" />
</a>
</>
)}
contentClassName="w-[232px] px-2.5 py-2 text-left font-medium leading-4"
interactive
side="right"
tabIndex={0}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</Tooltip>
);
}
function builtInRouteDescription(agent: RouterBuiltInAgentRuleId, t: (value: string) => string): string {
return agent === "claude-code"
? t("Identifies the Claude Code user-agent to provide deep Claude Code integration.")
: t("Identifies the Codex user-agent to provide deep Codex integration.");
}
function builtInRouteDocsUrl(agent: RouterBuiltInAgentRuleId, language: "en" | "zh"): string {
const path = language === "zh" ? "/configuration/routing" : "/en/configuration/routing";
const hash = agent === "claude-code" ? "claude-code" : "codex";
return `https://ccrdesk.top${path}#${hash}`;
}
function openExternalUrl(url: string) {
if (window.ccr?.openExternal) {
void window.ccr.openExternal(url).catch(() => undefined);
@@ -259,7 +198,7 @@ function openExternalUrl(url: string) {
window.open(url, "_blank", "noopener,noreferrer");
}
function RouterFallbackControl({
export function RouterFallbackControl({
className,
fallback,
label,
@@ -433,6 +372,7 @@ export function DeleteRoutingRuleDialog({
}
export function AddRoutingRuleDialog({
allowedRuleTypes,
canSubmit,
draft,
mode,
@@ -441,6 +381,7 @@ export function AddRoutingRuleDialog({
onSubmit,
providers
}: {
allowedRuleTypes?: Array<AddRoutingRuleDraft["type"]>;
canSubmit: boolean;
draft: AddRoutingRuleDraft;
mode: "add" | "edit";
@@ -453,7 +394,12 @@ export function AddRoutingRuleDialog({
const copy = useContext(AppI18nContext);
const conditionSourceOptions = translateOptions(routerConditionSourceOptions, t);
const rewriteOperationOptions = translateOptions(routerRewriteOperationOptions, t);
const ruleTypeOptions = translateOptions(routerRuleTypeOptions, t);
const ruleTypeOptions = translateOptions(
allowedRuleTypes?.length
? routerRuleTypeOptions.filter((option) => allowedRuleTypes.includes(option.value))
: routerRuleTypeOptions,
t
);
const [scriptBusy, setScriptBusy] = useState<"submit" | "test" | "validate">();
const [scriptMessage, setScriptMessage] = useState<{ ok: boolean; text: string }>();
const scriptFileInputRef = useRef<HTMLInputElement>(null);
@@ -563,7 +509,7 @@ export function AddRoutingRuleDialog({
<Field className="sm:col-span-2" label={t("Name")}>
<Input value={draft.name} onChange={(event) => onChange({ name: event.target.value })} />
</Field>
<Field className="sm:col-span-2" label={t("Rule type")}>
{ruleTypeOptions.length > 1 ? <Field className="sm:col-span-2" label={t("Rule type")}>
<SelectControl
onChange={(type) => onChange({
type: type as AddRoutingRuleDraft["type"],
@@ -578,7 +524,7 @@ export function AddRoutingRuleDialog({
options={ruleTypeOptions}
value={draft.type}
/>
</Field>
</Field> : null}
{draft.type === "condition" ? <Field className="sm:col-span-2" label={t("Condition")}>
<div className="rounded-md border border-border bg-muted/20 p-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[160px_minmax(0,1fr)_112px_minmax(0,1fr)]">
@@ -590,7 +536,7 @@ export function AddRoutingRuleDialog({
<Input
className="font-mono text-[12px]"
onChange={(event) => onChange({ conditionField: event.target.value })}
placeholder={draft.conditionSource.endsWith(".header") ? "x-api-key" : "model"}
placeholder={draft.conditionSource === "request.auth" ? "profileId" : draft.conditionSource.endsWith(".header") ? "x-api-key" : "model"}
value={draft.conditionField}
/>
<SelectControl
+34 -15
View File
@@ -116,7 +116,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
profile: "Agent Profiles",
providers: "Providers",
models: "Models",
routing: "Routing",
routing: "Global Routing",
server: "Server",
"virtual-models": "Fusion"
},
@@ -227,6 +227,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Stream": "Stream",
"Streaming": "Streaming",
"Non-streaming": "Non-streaming",
"Token": "Token",
"令牌": "Tokens",
"成本": "Cost",
"持续时间": "Duration",
@@ -433,7 +434,12 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Idle seconds must be between 30 and 86400.": "Idle seconds must be between 30 and 86400.",
"No request logs match the current filters.": "No request logs match the current filters.",
"No request logs yet.": "No request logs yet.",
"Paths, routing, bot, compact, and env": "Paths, routing, bot, compact, and env",
"Paths, bot, compact, and env": "Paths, bot, compact, and env",
"Enhanced route": "Enhanced route",
"Enhanced route description Claude Code": "CCR built-in Claude Code routing optimizes requests to third-party models for this profile.",
"Enhanced route description Codex": "CCR built-in Codex routing optimizes requests to third-party models for this profile.",
"Enhanced route off": "Enhanced route off",
"Request logs are off": "Request logs are off",
"Request logs record gateway requests and make payload inspection available.": "Request logs record gateway requests and make payload inspection available.",
"Select an existing bot or turn Bot off.": "Select an existing bot or turn Bot off.",
@@ -646,7 +652,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
profile: "Agent 配置档案",
providers: "供应商",
models: "模型",
routing: "路由",
routing: "全局路由",
server: "服务",
"virtual-models": "Fusion"
},
@@ -875,7 +881,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Cache": "缓存",
"Cache rate": "缓存率",
"Cache ratio": "缓存率",
"Cache tokens": "缓存令牌",
"Cache tokens": "缓存 Token",
"Cache write": "缓存写入",
"Cancel": "取消",
"Channel": "频道",
@@ -1040,6 +1046,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Model overrides are optional; empty fields keep Claude Code defaults.": "模型设置是可选项;留空会保留 Claude Code 默认设置。",
"Display name": "显示名称",
"Double click to copy": "双击复制",
"Duration": "持续时间",
"Edit": "编辑",
"Edit bot": "编辑 Bot",
"Edit API Key": "编辑 API 密钥",
@@ -1115,7 +1122,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Idle seconds": "空闲秒数",
"Idle seconds must be between 30 and 86400.": "空闲秒数必须在 30 到 86400 之间。",
"Input": "输入",
"Input tokens": "输入令牌",
"Input tokens": "输入 Token",
"Integration ID": "集成 ID",
"Install": "安装",
"Install and restart": "安装并重启",
@@ -1203,7 +1210,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Open profiles": "查看配置档案",
"Open providers": "查看供应商",
"Output": "输出",
"Output tokens": "输出令牌",
"Output tokens": "输出 Token",
"Observability": "可观测",
"Off": "关闭",
"On failure": "失败时",
@@ -1295,6 +1302,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Profile stopping is only available in the Electron app.": "配置档案停止功能仅在 Electron App 中可用。",
"Profile ready": "配置档案已就绪",
"Password": "密码",
"Partial failure": "部分失败",
"Recent Errors": "最近错误",
"Recent Requests": "最近请求",
"Refresh": "刷新",
@@ -1334,8 +1342,13 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Retry": "继续重试",
"Retry attempts": "重试尝试",
"Weighted order": "按权重排序",
"Paths, routing, bot, compact, and env": "路径、路由、Bot、压缩和环境变量",
"Paths, bot, compact, and env": "路径、Bot、压缩和环境变量",
"Ready to route": "可以开始路由",
"Enhanced route": "增强路由",
"Enhanced route description Claude Code": "CCR 内置的 Claude Code 路由会针对该配置使用三方模型时的请求做优化。",
"Enhanced route description Codex": "CCR 内置的 Codex 路由会针对该配置使用三方模型时的请求做优化。",
"Enhanced route off": "增强路由已关闭",
"Restart proxy": "重启代理",
"Route": "路由",
"Route graph": "路由链路图",
@@ -1343,6 +1356,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Route node": "路由节点",
"Route trace": "路由轨迹",
"Route Observability": "路由可观测",
"Routing disabled": "路由未启用",
"route": "条路由",
"routes": "条路由",
"View route graph": "查看路由链路图",
"Hover a node to inspect routing operations": "将鼠标悬停在节点上查看路由操作",
"Hover over a route node to inspect its operations.": "将鼠标悬停在路由节点上查看操作详情。",
@@ -1515,7 +1531,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"AI Fuel Cockpit": "AI 燃料仪表",
"Token Calendar Poster": "Token 日历海报",
"Spend Receipt": "消费小票",
"tokens routed through CCR": "通过 CCR 路由的令牌",
"tokens routed through CCR": "通过 CCR 路由的 Token",
"Top model": "最高频模型",
"Top provider": "最高频供应商",
"No provider activity": "暂无供应商活动",
@@ -1556,7 +1572,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Subagent": "子代理",
"Subagent Routing": "Subagent 路由",
"Subagent calls": "Subagent 调用",
"Subagents": "Subagent",
"Subagents": "子代理",
"Success": "成功",
"Success rate": "成功率",
"System proxy": "系统代理",
@@ -1568,15 +1584,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Thread": "线程",
"Thread ID": "线程 ID",
"Thinking": "思考",
"Token Mix": "令牌构成",
"Token": "Token",
"Token Mix": "Token 构成",
"Total": "总计",
"Total tokens": "总令牌",
"Total tokens": "总 Token",
"Username": "用户名",
"Today": "今天",
"Token threshold": "令牌阈值",
"Token threshold": "Token 阈值",
"Truncated": "已截断",
"Tokens": "令牌",
"tokens": "令牌",
"Tokens": "Token",
"tokens": "Token",
"day": "天",
"days": "天",
"Tool": "工具",
@@ -1799,10 +1816,10 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"5h quota": "5 小时额度",
"Granted balance": "赠送余额",
"Individual limit": "个人额度",
"Lifetime tokens": "累计令牌",
"Lifetime tokens": "累计 Token",
"Manual resets": "主动重置次数",
"Monthly budget": "月度预算",
"Peak daily tokens": "日峰值令牌",
"Peak daily tokens": "日峰值 Token",
"Primary quota": "主额度",
"Secondary quota": "副额度",
"Topped-up balance": "充值余额",
@@ -1831,7 +1848,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Copy API key": "复制 API 密钥",
"Model settings": "模型设置",
"Context, pricing, reasoning, web search, and image": "上下文、价格、推理、网页搜索与图片",
"Context window (tokens)": "上下文窗口(令牌",
"Context window (tokens)": "上下文窗口(Token",
"Custom pricing": "自定义价格",
"Pricing": "价格",
"Preset": "预设",
@@ -2088,6 +2105,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Previous": "上一步",
"Previous step": "上一步",
"Provider setup": "供应商设置",
"Profile routes": "配置级路由",
"Profile routing": "配置级路由",
"Proxy not running": "代理未运行",
"Proxy status": "代理状态",
"Proxy CA certificate is trusted.": "Proxy CA 证书已信任。",
+3 -2
View File
@@ -213,11 +213,12 @@ export const routerRuleTypeOptions: Array<{ label: string; value: RouterRuleType
{ label: "Node.js script", value: "script" }
];
export type RouterConditionSource = "request.header" | "request.body";
export type RouterConditionSource = "request.header" | "request.body" | "request.auth";
export const routerConditionSourceOptions: Array<{ label: string; value: RouterConditionSource }> = [
{ label: "request.header", value: "request.header" },
{ label: "request.body", value: "request.body" }
{ label: "request.body", value: "request.body" },
{ label: "request.auth", value: "request.auth" }
];
export const routerRuleOperatorOptions: Array<{ label: string; value: RouterRuleOperator }> = [
+87 -1
View File
@@ -19,6 +19,7 @@ import type {
GatewayProviderConfig,
ProfileConfig,
ProfileOpenSurface,
ProfileRoutingConfig,
CodexProfileConfigFormat,
ProfileScope,
ProfileSurface,
@@ -30,6 +31,7 @@ import {
import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common";
import { virtualModelProfileModelNames } from "./providers";
import { normalizeRouterRules } from "./routing";
import { endpointFromHostPort } from "./services";
import { keyValueRowsFromRecord, recordFromKeyValueRows, stringRecordValue, validateProfileEnvRows } from "./virtual-models";
import { isGatewayProviderEnabled } from "@ccr/core/contracts/app";
@@ -397,6 +399,65 @@ function createBotGatewayDraft(botGateway?: BotGatewayRuntimeConfig) {
};
}
function createProfileRoutingDraft(routing?: ProfileConfig["routing"]): Pick<AddProfileDraft, "routingEnabled" | "routingEnhancedRoute" | "routingRules"> {
const normalized = normalizeProfileRoutingConfig(routing);
return {
routingEnabled: Boolean(normalized?.enabled),
routingEnhancedRoute: normalized?.enhancedRoute ?? true,
routingRules: normalized?.rules ?? []
};
}
export function normalizeProfileRoutingConfig(value: unknown): ProfileRoutingConfig | undefined {
if (value === false) {
return {
enabled: false,
enhancedRoute: true,
rules: []
};
}
if (value === true) {
return {
enabled: true,
enhancedRoute: true,
rules: []
};
}
if (!isPlainRecord(value)) {
return undefined;
}
return {
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
enhancedRoute: typeof value.enhancedRoute === "boolean"
? value.enhancedRoute
: typeof value.useEnhancedRoute === "boolean"
? value.useEnhancedRoute
: typeof value.builtInRoute === "boolean"
? value.builtInRoute
: typeof value.builtinRoute === "boolean"
? value.builtinRoute
: typeof value.useBuiltInRoute === "boolean"
? value.useBuiltInRoute
: true,
rules: (normalizeRouterRules(value.rules) ?? []).filter((rule) => rule.type !== "script")
};
}
function profileRoutingConfigFromDraft(draft: AddProfileDraft): ProfileRoutingConfig | undefined {
const rules = draft.routingRules.filter((rule) => rule.type !== "script").map((rule) => ({ ...rule }));
const supportsEnhancedRoute = draft.agent === "claude-code" || draft.agent === "codex";
const hasEnhancedRouteConfig = supportsEnhancedRoute && draft.routingEnhancedRoute === false;
const hasRoutingConfig = draft.routingEnabled || rules.length > 0 || hasEnhancedRouteConfig;
if (!hasRoutingConfig) {
return undefined;
}
return {
enabled: draft.routingEnabled,
enhancedRoute: supportsEnhancedRoute ? draft.routingEnhancedRoute : true,
rules
};
}
export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code", name?: string): AddProfileDraft {
const surface = agent === "zcode" || agent === "claude-design" ? "app" : "cli";
return {
@@ -414,6 +475,7 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code"
opusModel: "",
providerId: "claude-code-router",
providerName: "Claude Code Router",
...createProfileRoutingDraft(),
scope: "ccr",
settingsFile: "~/.claude/settings.json",
showAllSessions: false,
@@ -447,6 +509,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
const surface = normalizeProfileSurfaceForForm(profile.surface);
return {
...createProfileDraft("claude-code", profile.name),
...createProfileRoutingDraft(profile.routing),
...botDraft,
appPath: profile.appPath ?? "",
botConfigId,
@@ -467,6 +530,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") {
return {
...createProfileDraft(profile.agent, profile.name),
...createProfileRoutingDraft(profile.routing),
availableModels: profile.agent === "kimi"
? uniqueStrings([profile.model, ...(profile.availableModels ?? [])].map(normalizeProfileClientModel).filter(Boolean))
: [],
@@ -479,6 +543,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
if (profile.agent === "claude-design") {
return {
...createProfileDraft("claude-design", profile.name),
...createProfileRoutingDraft(profile.routing),
envRows: [],
model: "",
scope: "ccr",
@@ -488,6 +553,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurfaceForForm(profile.surface);
return {
...createProfileDraft(profile.agent, profile.name),
...createProfileRoutingDraft(profile.routing),
...botDraft,
appPath: profile.appPath ?? "",
botConfigId,
@@ -572,6 +638,7 @@ export function profileConfigFromDraft(
}
}
: {};
const routing = profileRoutingConfigFromDraft(draft);
return normalizeProfileItem({
agent: draft.agent,
appPath: draft.appPath,
@@ -593,6 +660,7 @@ export function profileConfigFromDraft(
opusModel: draft.opusModel,
providerId: draft.providerId,
providerName: draft.providerName,
...(routing ? { routing } : {}),
scope: draft.scope,
settingsFile: draft.settingsFile,
showAllSessions: draft.agent === "zcode" || draft.agent === "opencode" || draft.agent === "kilo" || draft.agent === "claude-design" ? false : draft.showAllSessions,
@@ -1133,6 +1201,14 @@ export function profileSummaryItems(
: profile.managedCompact
? [{ label: t("CCR managed compact"), value: t("Enabled") }]
: [];
const routing = normalizeProfileRoutingConfig(profile.routing);
const routingParts = [
...(routing?.enabled ? [`${routing.rules.length} ${t(routing.rules.length === 1 ? "route" : "routes")}`] : []),
routing?.enhancedRoute === false ? t("Enhanced route off") : ""
].filter(Boolean);
const routingSummaryItems = routingParts.length > 0
? [{ label: t("Routing"), value: routingParts.join(" · ") }]
: [];
const displayProfileModel = (value: string) => profileModelDisplayValue(
value,
parseProfileModelValue(value, config.Providers, config.virtualModelProfiles ?? []),
@@ -1162,6 +1238,7 @@ export function profileSummaryItems(
{ label: t("Model"), value: modelValue },
...aliasItems,
...managedCompactItems,
...routingSummaryItems,
...botSummaryItems,
...appPathSummaryItems,
...envSummaryItems
@@ -1177,13 +1254,15 @@ export function profileSummaryItems(
value: String(uniqueStrings([profile.model, ...(profile.availableModels ?? [])].filter(Boolean)).length)
}]
: []),
...routingSummaryItems,
...envSummaryItems
];
}
if (profile.agent === "claude-design") {
return [
{ label: t("Entry mode"), value: t("App only") }
{ label: t("Entry mode"), value: t("App only") },
...routingSummaryItems
];
}
@@ -1192,6 +1271,7 @@ export function profileSummaryItems(
{ label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" },
...(profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]),
...managedCompactItems,
...routingSummaryItems,
...appPathSummaryItems,
...botSummaryItems,
...envSummaryItems
@@ -1211,6 +1291,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
const env = isPlainRecord(profile.env) ? stringRecordValue(profile.env) : {};
const botGateway = surface !== "cli" ? normalizeBotGatewayRuntimeConfig(profile.botGateway) : undefined;
const botConfigId = surface !== "cli" ? stringValue(profile.botConfigId) : "";
const routing = normalizeProfileRoutingConfig(profile.routing);
if (agent === "claude-code") {
const appPath = profile.appPath?.trim() || "";
return {
@@ -1227,6 +1308,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
model,
name,
opusModel: stringValue(profile.opusModel) || "",
...(routing ? { routing } : {}),
scope,
settingsFile: profile.settingsFile?.trim() || "~/.claude/settings.json",
sonnetModel: stringValue(profile.sonnetModel) || "",
@@ -1243,6 +1325,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
id: profile.id || `profile-${index + 1}`,
model,
name,
...(routing ? { routing } : {}),
scope: "ccr",
surface: "cli"
};
@@ -1255,6 +1338,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
id: profile.id || `profile-${index + 1}`,
model: "",
name,
...(routing ? { routing } : {}),
scope: "ccr",
surface: "app"
};
@@ -1277,6 +1361,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
name,
providerId: profile.providerId?.trim() || "claude-code-router",
providerName: profile.providerName?.trim() || "Claude Code Router",
...(routing ? { routing } : {}),
scope,
showAllSessions: agent === "zcode" || agent === "opencode" || agent === "kilo" ? false : Boolean(profile.showAllSessions),
surface
@@ -1413,6 +1498,7 @@ export function normalizeUnknownProfileItem(value: Record<string, unknown>, inde
: undefined,
providerId: typeof value.providerId === "string" ? value.providerId : undefined,
providerName: typeof value.providerName === "string" ? value.providerName : undefined,
routing: normalizeProfileRoutingConfig(value.routing ?? value.route),
scope: typeof value.scope === "string" ? normalizeProfileScope(value.scope) : "global",
settingsFile: typeof value.settingsFile === "string" ? value.settingsFile : undefined,
showAllSessions: typeof value.showAllSessions === "boolean"
+36 -1
View File
@@ -5,6 +5,7 @@ import openCodeLogoUrl from "@/assets/agent-logos/opencode.ico";
import zcodeLogoUrl from "@/assets/agent-logos/zcode.png";
import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico";
import {
ROUTER_SCRIPT_API_VERSION,
ROUTER_SCRIPT_MAX_TIMEOUT_MS
} from "@ccr/core/contracts/app";
import type {
@@ -420,6 +421,40 @@ export function routingRewriteFromDraftRow(row: RoutingRewriteDraftRow): RouterR
};
}
export function routingRuleFromDraft(
draft: AddRoutingRuleDraft,
existingRules: RouterRule[],
existingRule?: RouterRule
): RouterRule {
const commonRule = {
enabled: draft.enabled,
fallback: normalizeRouterFallbackConfig(draft.fallback),
id: existingRule?.id ?? uniqueRoutingRuleId(existingRules),
name: draft.name.trim()
};
return draft.type === "script"
? {
...commonRule,
script: {
apiVersion: ROUTER_SCRIPT_API_VERSION,
file: draft.scriptFile.trim(),
language: "javascript" as const,
timeoutMs: Number(draft.scriptTimeoutMs)
},
type: "script"
}
: {
...commonRule,
condition: {
left: buildRouterConditionPath(draft.conditionSource, draft.conditionField),
operator: draft.conditionOperator,
right: draft.conditionRight.trim()
},
rewrites: draft.rewrites.map(routingRewriteFromDraftRow),
type: "condition"
};
}
function normalizeRouterModelRewrite(rewrite: RouterRuleRewrite): RouterRuleRewrite {
return isModelRewriteKey(rewrite.key) && rewrite.value
? { ...rewrite, value: normalizeProviderModelSelector(rewrite.value) }
@@ -1656,7 +1691,7 @@ export async function probeProviderCandidates(
): Promise<ProviderProbeCandidateResult | undefined> {
const mode = options.mode ?? "protocols";
return await window.ccr?.probeProviderCandidates({
apiKey: mode === "connectivity" || mode === "models" ? apiKey : undefined,
apiKey: apiKey || undefined,
candidates,
mode,
models: mode === "connectivity" ? models : [],
+13 -91
View File
@@ -6,9 +6,7 @@ import {
} from "@ccr/core/contracts/app";
import type {
AppConfig,
ProfileConfig,
RouteScriptSampleRequest,
RouterBuiltInAgentRuleId,
RouterBuiltInRulesConfig,
RouterConfig,
RouterFallbackConfig,
@@ -559,95 +557,19 @@ export function claudeDesignRoutingConfigFromDraft(draft: ClaudeDesignRoutingDra
}
export function buildRoutingRuleRows(config: AppConfig): RoutingRuleRow[] {
return [
...buildBuiltInAgentRoutingRows(config),
...config.Router.rules.map((rule, index): RoutingRuleRow => ({
condition: formatRouterRuleCondition(rule),
enabled: rule.enabled,
index,
key: `router-${rule.id}-${index}`,
name: rule.name || "Unnamed",
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: rule.id,
sourceLabel: "Router",
target: formatRouterRuleTarget(rule),
typeLabel: routerRuleTypeLabel(rule.type)
}))
];
}
export function buildBuiltInAgentRoutingRows(config: AppConfig): RoutingRuleRow[] {
return routerBuiltInAgentRuleIds.map((agent): RoutingRuleRow => {
const target = routerBuiltInAgentRouteTarget(config, agent);
const toggleDisabledReason = routerBuiltInAgentRuleDisabledReason(config, agent);
return {
builtInAgent: agent,
condition: `request.header.user-agent contains ${routerBuiltInAgentUserAgentNeedle(agent)}`,
enabled: routerBuiltInAgentRuleIsActive(config, agent),
key: `builtin-agent-${agent}`,
name: routerBuiltInAgentRuleName(agent),
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: `builtin-agent-${agent}`,
sourceLabel: "Built-in",
target: target ? `set request.body.model = ${target}` : "Profile model unset",
toggleDisabled: Boolean(toggleDisabledReason),
toggleDisabledReason,
typeLabel: "Condition"
};
});
}
const routerBuiltInAgentRuleIds: RouterBuiltInAgentRuleId[] = ["claude-code", "codex"];
export function routerBuiltInAgentRuleIsActive(config: AppConfig, agent: RouterBuiltInAgentRuleId): boolean {
return routerBuiltInAgentRulePreferenceEnabled(config, agent) &&
Boolean(routerBuiltInAgentProfile(config, agent)) &&
Boolean(routerBuiltInAgentRouteTarget(config, agent));
}
export function routerBuiltInAgentRulePreferenceEnabled(config: AppConfig, agent: RouterBuiltInAgentRuleId): boolean {
return config.Router.builtInRules?.[agent]?.enabled !== false;
}
export function routerBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltInAgentRuleId): ProfileConfig | undefined {
if (config.profile.enabled === false) {
return undefined;
}
return config.profile.profiles.find((profile) =>
profile.enabled &&
profile.agent === agent &&
Boolean(profile.model.trim())
);
}
export function routerBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string {
return routerBuiltInAgentProfile(config, agent)?.model.trim() || "";
}
export function routerBuiltInAgentRuleDisabledReason(config: AppConfig, agent: RouterBuiltInAgentRuleId): string | undefined {
if (config.profile.enabled === false) {
return "Agent profiles are disabled.";
}
const agentName = routerBuiltInAgentRuleName(agent);
const enabledProfile = config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent);
if (!enabledProfile) {
return `Enable a ${agentName} profile before enabling this built-in route.`;
}
const profile = routerBuiltInAgentProfile(config, agent);
if (!profile) {
return `Set a model on the ${agentName} profile before enabling this built-in route.`;
}
return undefined;
}
export function routerBuiltInAgentRuleName(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "Claude Code" : "Codex";
}
export function routerBuiltInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "claude" : "codex";
return config.Router.rules.map((rule, index): RoutingRuleRow => ({
condition: formatRouterRuleCondition(rule),
enabled: rule.enabled,
index,
key: `router-${rule.id}-${index}`,
name: rule.name || "Unnamed",
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: rule.id,
sourceLabel: "Router",
target: formatRouterRuleTarget(rule),
typeLabel: routerRuleTypeLabel(rule.type)
}));
}
export function buildPluginRoutingRows(plugin: AppConfig["plugins"][number], pluginIndex: number): RoutingRuleRow[] {
+4 -2
View File
@@ -27,8 +27,8 @@ import type {
ProfileConfig,
ProfileScope,
ProfileSurface,
RouterBuiltInAgentRuleId,
RouterFallbackConfig,
RouterRule,
RouterRuleOperator,
RouterRuleRewriteOperation,
RouterRuleType,
@@ -184,6 +184,9 @@ export type AddProfileDraft = {
opusModel: string;
providerId: string;
providerName: string;
routingEnabled: boolean;
routingEnhancedRoute: boolean;
routingRules: RouterRule[];
scope: ProfileScope;
settingsFile: string;
showAllSessions: boolean;
@@ -427,7 +430,6 @@ export type PluginSettingsDraft = {
};
export type RoutingRuleRow = {
builtInAgent?: RouterBuiltInAgentRuleId;
condition: string;
enabled: boolean;
index?: number;
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { RequestLogPage } from "@ccr/core/contracts/app.ts";
import type { AgentAnalysisSessionRow, AgentAnalysisTraceRun, RequestLogEntry, RequestLogPage } from "@ccr/core/contracts/app.ts";
import { AgentAnalysisView } from "@ccr/ui/pages/home/components/dashboard.tsx";
import { LogsView } from "@ccr/ui/pages/home/components/network-logs.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import { createEmptyAgentAnalysis } from "@ccr/ui/pages/home/shared/usage.ts";
const emptyLogPage: RequestLogPage = {
generatedAt: "2026-07-23T00:00:00.000Z",
@@ -20,6 +22,39 @@ const emptyLogPage: RequestLogPage = {
totalPages: 1
};
const sampleRequestLogEntry: RequestLogEntry = {
cacheReadTokens: 0,
cacheWriteTokens: 0,
client: "claude-code",
costUsd: 0.01,
createdAt: "2026-07-23T00:00:00.000Z",
credentialChain: [],
credentialSaturated: false,
durationMs: 120,
id: 1,
inputTokens: 100,
isStream: true,
method: "POST",
model: "claude-sonnet-4",
ok: true,
outputTokens: 50,
path: "/v1/messages",
provider: "anthropic",
reasoningTokens: 0,
requestBody: { encoding: "utf8", sizeBytes: 2, text: "{}", truncated: false },
requestHeaders: {},
requestId: "req-token-copy",
routeAttemptCount: 1,
routeHopCount: 1,
routeTraceTruncated: false,
retryAttempts: [],
responseBody: { encoding: "utf8", sizeBytes: 2, text: "{}", truncated: false },
responseHeaders: {},
statusCode: 200,
totalTokens: 150,
url: "/v1/messages"
};
test("LogsView keeps disabled request logs discoverable with an enable action", () => {
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.zh}>
@@ -59,3 +94,162 @@ test("LogsView explains filtered empty results and translates page sizes", () =>
assert.match(html, /25 \/ page/);
assert.doesNotMatch(html, /\/ 页/);
});
test("LogsView keeps Chinese token column copy as Token", () => {
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.zh}>
<LogsView
error=""
filter={{ page: 1, pageSize: 25, status: "all" }}
loading={false}
page={{ ...emptyLogPage, items: [sampleRequestLogEntry], total: 1 }}
refreshLogs={() => undefined}
updateFilter={() => undefined}
/>
</AppI18nContext.Provider>
);
assert.match(html, /Token/);
assert.doesNotMatch(html, /令牌/);
});
test("AgentAnalysisView keeps session headings horizontal and shows cache rate and cost", () => {
const session: AgentAnalysisSessionRow = {
agent: "claude-code",
avgDurationMs: 420,
cacheRatio: 0.375,
cacheReadTokens: 300,
cacheTokens: 300,
cacheWriteTokens: 100,
client: "claude-code",
costUsd: 1.25,
durationMs: 900,
errorCount: 0,
id: "session-cache-cost",
inputTokens: 500,
lastSeenAt: "2026-07-23T00:01:00.000Z",
maxConcurrentRequests: 1,
maxDurationMs: 500,
models: ["claude-sonnet-4"],
outputTokens: 100,
p50DurationMs: 420,
p95DurationMs: 500,
p99DurationMs: 500,
providers: ["anthropic"],
requestCount: 2,
sessionCount: 1,
startedAt: "2026-07-23T00:00:00.000Z",
subagentCallCount: 0,
successRate: 1,
toolCallCount: 1,
topTools: [{ count: 1, name: "Read" }],
totalTokens: 1000
};
const snapshot = {
...createEmptyAgentAnalysis("24h"),
scannedRequestCount: 2,
selectedSession: {
endpoints: [],
errors: [],
models: [],
requests: [{
agent: session.agent,
cacheReadTokens: 300,
cacheWriteTokens: 100,
client: session.client,
concurrentRequests: 1,
costUsd: 0.25,
createdAt: session.startedAt,
durationMs: 420,
id: 42,
inputTokens: 500,
method: "POST",
model: "claude-sonnet-4",
ok: true,
outputTokens: 100,
path: "/v1/messages",
provider: "anthropic",
requestId: "request-with-cost",
routeReason: "default",
sessionId: session.id,
statusCode: 200,
toolCallCount: 1,
tools: ["Read"],
totalTokens: 1000
}],
routes: [],
session,
statusCodes: [],
subagents: [],
tools: [],
totals: session,
trace: {
agent: session.agent,
durationMs: session.durationMs,
endedAt: session.lastSeenAt,
errorCount: 1,
id: `${session.agent}:${session.id}`,
llmRunCount: 0,
maxDepth: 0,
rootRunId: `agent:${session.agent}:${session.id}`,
runCount: 1,
runs: [{
agent: session.agent,
cacheReadTokens: session.cacheReadTokens,
cacheWriteTokens: session.cacheWriteTokens,
concurrentRequests: session.maxConcurrentRequests,
costUsd: 0.75,
depth: 0,
durationMs: session.durationMs,
endedAt: session.lastSeenAt,
id: `agent:${session.agent}:${session.id}`,
inputTokens: session.inputTokens,
kind: "agent",
name: "Claude Code session",
offsetMs: 0,
outputTokens: session.outputTokens,
sessionId: session.id,
startedAt: session.startedAt,
status: "partial",
totalTokens: session.totalTokens
} satisfies AgentAnalysisTraceRun],
sessionId: session.id,
startedAt: session.startedAt,
subagentRunCount: 0,
toolRunCount: 0
}
},
sessions: [session]
};
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.zh}>
<AgentAnalysisView
agentFilter="all"
error=""
loading={false}
range="24h"
refreshAnalysis={() => undefined}
setAgentFilter={() => undefined}
setRange={() => undefined}
setSelectedSession={() => undefined}
snapshot={snapshot}
/>
</AppI18nContext.Provider>
);
assert.match(html, /持续时间/);
assert.match(html, /子代理/);
assert.match(html, /缓存率/);
assert.match(html, /38%/);
assert.match(html, /成本/);
assert.match(html, /Token/);
assert.doesNotMatch(html, /令牌/);
assert.match(html, /\$1\.25/);
assert.match(html, /\$0\.25/);
assert.match(html, /\$0\.75/);
assert.match(html, /部分失败/);
assert.match(html, /border-amber-200/);
assert.match(html, /min-w-\[64px\]/);
assert.match(html, /whitespace-nowrap/);
});
@@ -104,5 +104,5 @@ test("Onboarding profile step does not prompt to save before continuing", () =>
assert.doesNotMatch(html, /Save this agent profile to continue/);
assert.doesNotMatch(html, /Choose an agent, model, and required profile settings/);
assert.doesNotMatch(html, /provider identity/);
assert.match(html, /Paths, bot, compact, and env/);
assert.match(html, /Paths, routing, bot, compact, and env/);
});
@@ -3,6 +3,7 @@ import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { formatCodexResetCardExpiry, formatCodexResetCardNumber, OverviewView } from "@ccr/ui/pages/home/components/dashboard.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import { parseStatusBucketDate } from "@ccr/ui/pages/home/shared/controls.tsx";
import { providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts";
import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app.ts";
@@ -74,6 +75,34 @@ test("OverviewView renders every overview widget type", () => {
assert.match(html, /Spend Receipt/);
});
test("OverviewView keeps Chinese token copy as Token", () => {
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.zh}>
<OverviewView
overviewWidgets={[
{ enabled: true, id: "metric-total", metric: "total-tokens", size: "1:1", type: "metric", variant: "card" },
{ enabled: true, id: "trend", size: "3:2", type: "usage-trend", variant: "composed" },
{ enabled: true, id: "activity", size: "4:2", type: "token-activity", variant: "heatmap" },
{ enabled: true, id: "token-mix", size: "2:2", type: "token-mix", variant: "donut" },
{ enabled: true, id: "share-usage", size: "1:4", type: "share-usage-wrapped", variant: "card" },
{ enabled: true, id: "share-receipt", size: "1:4", type: "share-spend-receipt", variant: "card" }
]}
providerAccounts={accountSnapshots()}
refreshProviderAccounts={() => undefined}
setUsageRange={() => undefined}
usageRange="30d"
usageStats={usageStats("30d")}
onWidgetsChange={() => undefined}
/>
</AppI18nContext.Provider>
);
assert.match(html, /Token/);
assert.match(html, /Token 构成/);
assert.match(html, /总 Token/);
assert.doesNotMatch(html, /令牌/);
});
test("overview status dates accept ISO usage buckets", () => {
assert.equal(parseStatusBucketDate("2026-06-20T00:00:00.000Z")?.toISOString(), "2026-06-20T00:00:00.000Z");
});
+177 -1
View File
@@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import type { ProfileConfig } from "@ccr/core/contracts/app.ts";
import { AddProfileForm, DeleteProfileDialog, ProfileView } from "@ccr/ui/pages/home/components/profiles.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import { createProfileDraft, isProfileDraftSubmittable, normalizeUnknownProfileItem, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts";
import { createProfileDraft, createProfileDraftFromProfile, isProfileDraftSubmittable, normalizeUnknownProfileItem, profileConfigFromDraft, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts";
import { appConfigFixture } from "../fixtures/index.ts";
const profile: ProfileConfig = {
@@ -61,6 +61,91 @@ test("AddProfileForm does not show the profile requirements panel", () => {
assert.doesNotMatch(html, /Profile guidance/);
});
test("AddProfileForm keeps profile routing inside Advanced settings", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
<AddProfileForm
botConfigs={config.botConfigs}
draft={createProfileDraft("claude-code")}
error=""
onChange={() => undefined}
onCreateBot={() => undefined}
providers={config.Providers}
virtualModelProfiles={config.virtualModelProfiles}
/>
);
assert.match(html, /Advanced settings/);
assert.doesNotMatch(html, /Profile routing/);
assert.doesNotMatch(html, /Routing disabled/);
assert.doesNotMatch(html, /Enhanced route/);
});
test("AddProfileForm shows profile-level enhanced route controls when private routing is disabled", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
<AddProfileForm
botConfigs={config.botConfigs}
draft={createProfileDraft("claude-code")}
error=""
mode="edit"
onChange={() => undefined}
onCreateBot={() => undefined}
providers={config.Providers}
virtualModelProfiles={config.virtualModelProfiles}
/>
);
const advancedSettingsIndex = html.indexOf("Advanced settings");
const profileRoutingIndex = html.indexOf("Profile routing");
assert.ok(advancedSettingsIndex >= 0);
assert.ok(profileRoutingIndex > advancedSettingsIndex);
assert.doesNotMatch(html, /Routing disabled/);
assert.match(html, /Enhanced route/);
assert.match(html, /CCR built-in Claude Code routing optimizes requests to third-party models for this profile\./);
});
test("AddProfileForm shows private profile routes when profile routing is enabled", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
<AddProfileForm
botConfigs={config.botConfigs}
draft={{ ...createProfileDraft("claude-code"), routingEnabled: true }}
error=""
mode="edit"
onChange={() => undefined}
onCreateBot={() => undefined}
providers={config.Providers}
virtualModelProfiles={config.virtualModelProfiles}
/>
);
assert.match(html, /Profile routing/);
assert.match(html, /Enhanced route/);
assert.match(html, /Profile routes/);
assert.match(html, /CCR built-in Claude Code routing optimizes requests to third-party models for this profile\./);
assert.match(html, /data-ui-tooltip-trigger/);
});
test("AddProfileForm uses Codex-specific enhanced route info for Codex profiles", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
<AddProfileForm
botConfigs={config.botConfigs}
draft={{ ...createProfileDraft("codex"), routingEnabled: true }}
error=""
mode="edit"
onChange={() => undefined}
onCreateBot={() => undefined}
providers={config.Providers}
virtualModelProfiles={config.virtualModelProfiles}
/>
);
assert.match(html, /Enhanced route/);
assert.match(html, /CCR built-in Codex routing optimizes requests to third-party models for this profile\./);
});
test("AddProfileForm marks required and optional fields", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
@@ -285,6 +370,29 @@ test("profileSummaryItems omits disabled profile properties from cards", () => {
assert.match(enabledItems.map((item) => item.label).join(" "), /CCR managed compact/);
});
test("profileSummaryItems shows disabled profile enhanced route without private routing status", () => {
const config = appConfigFixture();
const items = profileSummaryItems({
agent: "claude-code",
enabled: true,
id: "claude-main",
model: "openai/gpt-5.2",
name: "Claude Main",
routing: {
enabled: false,
enhancedRoute: false,
rules: []
},
scope: "ccr",
surface: "cli"
}, config, (value) => value);
const text = items.map((item) => `${item.label} ${item.value}`).join(" ");
assert.doesNotMatch(text, /Routing disabled/);
assert.match(text, /Enhanced route off/);
assert.match(items.map((item) => item.label).join(" "), /Routing/);
});
test("detected CHATGPT_APP_PATH is used as the Codex profile default", () => {
const detectedPath = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT";
const draft = profileDraftWithDetectedAppPath(createProfileDraft("codex"), ` ${detectedPath} `);
@@ -325,6 +433,74 @@ test("persisted Grok profiles are normalized to the supported launch scope", ()
assert.equal(profile?.surface, "cli");
});
test("profile routing survives profile draft round trip", () => {
const profile = normalizeUnknownProfileItem({
agent: "claude-code",
enabled: true,
id: "claude-work",
model: "Provider/sonnet",
name: "Claude Work",
routing: {
enabled: true,
enhancedRoute: false,
rules: [{
condition: { left: "request.auth.profileId", operator: "==", right: "claude-work" },
enabled: true,
id: "auth-profile",
name: "Auth profile",
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/opus" }],
type: "condition"
}]
},
scope: "ccr",
surface: "cli"
}, 0);
assert.equal(profile?.routing?.enhancedRoute, false);
assert.equal(profile?.routing?.rules[0]?.id, "auth-profile");
const draft = createProfileDraftFromProfile(profile);
const saved = profileConfigFromDraft(draft, [profile], profile);
assert.equal(saved.routing?.enabled, true);
assert.equal(saved.routing?.enhancedRoute, false);
assert.equal(saved.routing?.rules[0]?.condition?.left, "request.auth.profileId");
});
test("disabled private profile routing persists the profile enhanced route switch", () => {
const draft = {
...createProfileDraft("claude-code"),
model: "Provider/sonnet",
routingEnabled: false,
routingEnhancedRoute: false
};
const saved = profileConfigFromDraft(draft, [], undefined);
assert.equal(saved.routing?.enabled, false);
assert.equal(saved.routing?.enhancedRoute, false);
assert.deepEqual(saved.routing?.rules, []);
});
test("disabled profile routing preserves existing private rules without enabling them", () => {
const draft = {
...createProfileDraft("claude-code"),
model: "Provider/sonnet",
routingEnabled: false,
routingRules: [{
condition: { left: "request.header.x-task", operator: "==", right: "heavy" },
enabled: true,
id: "heavy",
name: "Heavy",
rewrites: [{ key: "request.body.model", operation: "set", value: "Provider/opus" }],
type: "condition"
}]
};
const saved = profileConfigFromDraft(draft, [], undefined);
assert.equal(saved.routing?.enabled, false);
assert.equal(saved.routing?.rules[0]?.id, "heavy");
});
test("OpenCode profiles support local CLI and App configuration", () => {
const draft = createProfileDraft("opencode");
assert.equal(draft.name, "OpenCode");
+9 -48
View File
@@ -1,13 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildBuiltInAgentRoutingRows,
buildRoutingRuleRows,
normalizeRouteScriptSampleRequest,
normalizeRouterRules,
routerBuiltInAgentProfile,
routerBuiltInAgentRouteTarget,
routerBuiltInAgentRuleDisabledReason,
routerBuiltInAgentRuleIsActive
normalizeRouterRules
} from "@ccr/ui/pages/home/shared/routing.ts";
import {
createRoutingRuleDraft,
@@ -16,63 +12,28 @@ import {
} from "@ccr/ui/pages/home/shared/providers.ts";
import { appConfigFixture } from "../fixtures/index.ts";
test("Codex built-in route accepts a later enabled profile with a configured model", () => {
test("global routing rows omit Claude Code and Codex built-in profile routes", () => {
const config = appConfigFixture();
config.profile.profiles = [
{
agent: "codex",
agent: "claude-code",
enabled: true,
id: "codex",
model: "",
name: "Codex",
id: "claude",
model: "Provider/claude-sonnet",
name: "Claude",
scope: "ccr"
},
{
agent: "codex",
enabled: true,
id: "bs-2",
id: "codex",
model: "uuroute/gpt-5.5",
name: "BS",
scope: "ccr"
}
];
assert.equal(routerBuiltInAgentProfile(config, "codex")?.id, "bs-2");
assert.equal(routerBuiltInAgentRouteTarget(config, "codex"), "uuroute/gpt-5.5");
assert.equal(routerBuiltInAgentRuleDisabledReason(config, "codex"), undefined);
assert.equal(routerBuiltInAgentRuleIsActive(config, "codex"), true);
assert.equal(
buildBuiltInAgentRoutingRows(config).find((row) => row.builtInAgent === "codex")?.target,
"set request.body.model = uuroute/gpt-5.5"
);
});
test("Codex built-in route asks for a model only when every enabled Codex profile is unset", () => {
const config = appConfigFixture();
config.profile.profiles = [
{
agent: "codex",
enabled: true,
id: "codex",
model: " ",
name: "Codex",
scope: "ccr"
},
{
agent: "codex",
enabled: true,
id: "bs-2",
model: "",
name: "BS",
scope: "ccr"
}
];
assert.equal(
routerBuiltInAgentRuleDisabledReason(config, "codex"),
"Set a model on the Codex profile before enabling this built-in route."
);
assert.equal(routerBuiltInAgentRuleIsActive(config, "codex"), false);
assert.deepEqual(buildRoutingRuleRows(config), []);
});
test("routing UI preserves the Node.js script file and timeout", () => {