Merge branch 'dev/3.1'
Some checks are pending
Docs / Build (push) Waiting to run
Docs / Deploy (push) Blocked by required conditions

This commit is contained in:
musi 2026-07-02 22:09:01 +08:00
commit 0f54b7d7a3
36 changed files with 6430 additions and 169 deletions

View file

@ -27,7 +27,7 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray",
export const cssInput = path.join(rendererRoot, "styles", "globals.css");
export const cssOutput = path.join(rendererAssetsDir, "main.css");
export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js");
const lightweightMcpBundleNames = ["fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
const lightweightMcpBundleMaxBytes = 128 * 1024;
const forbiddenLightweightMcpInputs = [
{ prefix: "src/main/", reason: "main-process modules can pull in config, Electron, or native storage side effects" },
@ -120,6 +120,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
entryPoints: [
path.join(projectRoot, "src", "main", "main.ts"),
path.join(projectRoot, "src", "main", "browser-preload.ts"),
path.join(projectRoot, "src", "server", "mcp", "browser-web-search-proxy-mcp.ts"),
path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"),
path.join(projectRoot, "src", "server", "mcp", "fusion-tool-fallback-mcp.ts"),
path.join(projectRoot, "src", "main", "preload.ts")

View file

@ -2,52 +2,130 @@
title: Overview Dashboard
pageTitle: Overview Dashboard
eyebrow: Detailed Configuration
lead: Customize the CCR home dashboard for system status, account balance, requests, tokens, cost, model distribution, and share cards.
lead: Customize the CCR home dashboard to inspect system status, account balance, requests, tokens, cost, and model distribution.
---
## Top Controls
## When To Use It
| Field | Capability |
| Scenario | What to inspect |
| --- | --- |
| Usage over time | Changes the statistics range used by the dashboard. |
| Today / 24h / 7d / 30d | Available time windows. Widgets recalculate requests, tokens, cost, and trends for the selected range. |
| Edit widgets | Enters layout editing mode. |
| Reset layout | Restores the default overview layout. |
| Done | Leaves editing mode and keeps the current widget configuration. |
| Check gateway health | System status, success rate, errors, average latency |
| Estimate recent spend | Requests, input / output / cache tokens, estimated cost |
| Compare upstream usage | Provider analysis, model distribution, client analysis |
| Watch account quota | Balance, subscription quota, remaining quota, account status |
| Report or share usage | AI Usage Wrapped, CCR Route Map, Model Leaderboard, Spend Receipt, and other share cards |
## Widget Editing
## Time Range
In editing mode, the left `Components` panel adds widgets, the middle `Preview` panel shows the current layout, and the right `Component properties` panel edits the selected widget.
The `Usage over time` control at the top drives every widget that depends on usage stats. After you switch ranges, requests, tokens, cost, trends, distribution, and share cards are recomputed for the selected window.
| Field | Capability |
| Option | Window |
| --- | --- |
| Components | List of widgets that can be added. |
| Preview | Current dashboard layout. Widgets can be dragged to reorder. |
| Component properties | Configuration for the selected widget. |
| Component category | Changes the widget category, such as status, account, metric, trend, activity, breakdown, analysis, or share card. |
| Data | Selects the data shown by the widget, such as requests, tokens, cost, account, client analysis, or provider analysis. |
| Widget size | Controls the widget's grid width and height. |
| Style | Changes visual style, such as cards, compact, bar, line, ring, and more. |
| Remove widget | Removes the selected widget from the overview. |
| `Today` | Current local date from 00:00 to now, bucketed hourly. |
| `24h` | Last 24 hours, bucketed hourly. |
| `7d` | Last 7 days, bucketed daily. |
| `30d` | Last 30 days, bucketed daily. |
## Widget Types
The account balance widget does not use this time range. It shows the latest snapshot returned by provider account connectors.
| Widget | Capability |
## Edit Layout
Click the pencil button in the upper-right corner to enter editing mode. Editing mode has three columns:
| Area | Purpose |
| --- | --- |
| Status component | Shows a system status timeline for recent gateway health. |
| Account component | Shows provider account balance, quota, or usage. Requires provider `Fetch usage`. |
| Metric component | Shows requests, total tokens, input tokens, output tokens, cache tokens, cache ratio, estimated cost, success rate, errors, or average latency. |
| Trend component | Shows usage trend over time. |
| Activity component | Shows token activity as a heatmap. |
| Breakdown component | Shows Token mix or Model distribution. |
| Analysis component | Shows Client Analysis or Provider Analysis. |
| Share card | Generates shareable PNG cards such as AI Usage Wrapped, CCR Route Map, Model Leaderboard, AI Fuel Cockpit, Token Calendar Poster, and Spend Receipt. |
| Components | Left palette. Click a template to add it to the dashboard. |
| Preview | Middle layout preview. Drag widgets to reorder them or click a widget to select it. |
| Component properties | Right property panel for changing type, data, size, style, or removing the selected widget. |
## Data Sources
Common operations:
| Data | Source |
1. Add a widget: click a template in `Components`.
2. Reorder widgets: drag them in `Preview`.
3. Resize a widget: select it, then drag the right, bottom, or bottom-right resize handle.
4. Change data: use `Component category` and `Data` in `Component properties`.
5. Change presentation: choose `Widget size` and `Style`.
6. Save the result: click `Done`; the layout is persisted in app configuration.
7. Restore defaults: click `Reset layout` while editing.
Removing a widget only removes that card from the overview layout. It does not delete request logs, providers, account connectors, or upstream configuration. If all widgets are removed, the page shows `No widgets configured`.
## Widget Catalog
Sizes are written as `width:height`, with both dimensions from `1` to `4`. The overview grid has up to 4 columns on desktop and collapses automatically on narrow screens.
| Widget | Data | Default size | Default style | Styles |
| --- | --- | --- | --- | --- |
| Status component | System status | `4:1` | Timeline | Timeline, Compact |
| Account component | All accounts or one account | `4:2` | Cards | Cards, Compact, Bars, Ring, Semicircle, Arc, Nested rings |
| Metric component | Requests, tokens, cost | `1:1` | Cards | Cards, Compact, Bar, Ring |
| Trend component | Usage over time | `3:2` | Composed | Composed, Area, Line, Bar |
| Activity component | Token activity | `4:2` | Heatmap | Heatmap |
| Breakdown component | Token distribution / Model distribution | Token distribution: `1:2`; Model distribution: `2:2` | Token distribution: Bars; Model distribution: Pie | Bars, Stacked, Donut, Pie |
| Analysis component | Client Analysis / Provider Analysis | `2:2` | Table | Table, Compact |
| Share card | AI Usage Wrapped, CCR Route Map, Model Leaderboard, AI Fuel Cockpit, Token Calendar Poster, Spend Receipt | `1:4` | Card | Card |
Size constraints:
| Rule | Reason |
| --- | --- |
| Requests, tokens, cost, success rate, latency | Request logs and usage stats. |
| Account balance, quota, status messages | Provider `Fetch usage` configuration. |
| Client analysis, provider analysis, model distribution | Client, provider, model, and token data from request logs. |
| Agent analysis data | Agent observability settings and agent execution traces. |
| Share cards have a minimum size of `1:4`. | PNG export uses a vertical poster ratio and needs enough height. |
| The account widget has a minimum size of `2:2` when showing All accounts with the Compact style. | Multi-account lists need readable space. |
| Legacy aliases are still accepted: `small` -> `1:1`, `medium` / `large` -> `2:2`, `wide` -> `3:2`, `full` -> `4:1` or `4:2`. | Backward compatibility for older config. |
## Metric Data
`metric` widgets use the `metric` field to choose the displayed value.
| `metric` | Meaning |
| --- | --- |
| `requests` | Request count |
| `total-tokens` | Total tokens |
| `input-tokens` | Input tokens |
| `output-tokens` | Output tokens |
| `cache-tokens` | Cache tokens |
| `cache-ratio` | Cache ratio |
| `estimated-cost` | Estimated cost, calculated from model pricing data |
| `success-rate` | Success rate |
| `errors` | Error count |
| `avg-latency` | Average latency |
## Account Widget
The account widget reads provider account / usage connectors. To show balance or remaining quota, first enable and test `Fetch usage` in provider configuration.
| Data selection | Behavior |
| --- | --- |
| `All accounts` | Shows every available account snapshot. |
| One account | Shows only one provider or credential snapshot. The internal config value is usually `provider` or `provider::credentialId`. |
If the account widget is empty, check:
1. Whether the provider has an account / usage connector configured.
2. Whether the `Fetch usage` test succeeds.
3. Whether the API key or account endpoint is still valid.
4. Whether the selected account was deleted or renamed.
## Share Cards
Share card widgets can export PNGs through the download button in the card header. The desktop app uses native export when available; browser environments fall back to frontend canvas export. The exported image size is `1080 x 1350`.
| Card | `type` | Content |
| --- | --- | --- |
| AI Usage Wrapped | `share-usage-wrapped` | Total tokens, requests, estimated cost, cache ratio, longest activity streak, top model, top provider, peak day. |
| CCR Route Map | `share-route-map` | Main client-to-provider/model route relationships, plus client, provider, and model counts. |
| Model Leaderboard | `share-model-leaderboard` | Models ranked by tokens. |
| AI Fuel Cockpit | `share-fuel-cockpit` | Up to 3 account quota gauges. Requires account / usage connectors. |
| Token Calendar Poster | `share-token-calendar` | Contribution-calendar style token activity poster. |
| Spend Receipt | `share-spend-receipt` | Estimated cost, requests, tokens, latency, and success rate for the selected range. |
## Data Sources And Troubleshooting
| Symptom | Likely cause | What to do |
| --- | --- | --- |
| Requests, tokens, or cost are 0 | No requests went through CCR in the selected range, or usage capture has not recorded data yet. | Try `24h` / `7d`, and confirm the client is actually using CCR. |
| Cost shows `$0.00` | The model has no pricing data, or usage is very small. | Check model catalog matching and provider model names; values under 0.01 USD are shown with extra decimals. |
| Success rate or errors look unexpected | The overview only aggregates request results captured by CCR. | Compare with records on the Logs page. |
| Account balance is empty | No account connector exists, or `Fetch usage` failed. | Test account / usage field mapping in provider configuration. |
| Distribution charts have no data | Request logs lack model, provider, or token information. | Confirm requests go through CCR and upstream responses include token usage. |
| PNG export fails | Canvas export is unavailable, the element has no size, or the save dialog was canceled. | Retry in the desktop app, and make sure the card is visible and not resized too small. |

View file

@ -13,7 +13,7 @@ The top navigation is split into four standalone pages:
| --- | --- |
| [Documentation](./) | Product positioning, architecture overview, and reading path |
| [Quick Start](guides/) | From installation and provider setup to connecting an agent |
| [Detailed Configuration](configuration/providers/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
| [Detailed Configuration](configuration/overview/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
| [Q&A](troubleshooting/) | Request logs, observability panel, and common questions |
Bot platform guides are child pages under Detailed Configuration. Each platform has its own page so platform dashboard fields, callback URLs, signatures, and FAQs can be expanded independently.
@ -24,5 +24,5 @@ If this is your first time using CCR:
1. Start with [Quick Start](guides/) to connect a provider and Agent Config.
2. Use the app's request logs to confirm whether requests are passing through CCR.
3. Open [Detailed Configuration](configuration/providers/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
3. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
4. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions.

View file

@ -2,52 +2,130 @@
title: 概览仪表盘
pageTitle: 概览仪表盘
eyebrow: 详细配置
lead: 自定义 CCR 首页组件查看系统状态、账户余额、请求量、tokens、成本、模型分布和可分享卡片
lead: 自定义 CCR 首页仪表盘,查看系统状态、账户余额、请求量、令牌、成本和模型分布
---
## 顶部控件
## 适用场景
| 字段 | 代表的能力 |
| 场景 | 看什么 |
| --- | --- |
| 按时间查看用量 | 切换仪表盘使用的统计时间范围。 |
| 今天 / 24 小时 / 7 天 / 30 天 | 可选统计窗口。不同组件会根据这个窗口重新计算请求、tokens、成本和趋势。 |
| 编辑组件 | 进入布局编辑模式。 |
| 重置布局 | 恢复默认概览布局。 |
| 完成 | 退出布局编辑模式并保留当前组件配置。 |
| 检查网关是否正常 | 系统状态、成功率、错误数、平均延迟 |
| 估算今日或近期消耗 | 请求数、输入 / 输出 / 缓存令牌、估算成本 |
| 比较上游使用情况 | 供应商分析、模型分布、客户端分析 |
| 关注账户额度 | 账户余额、套餐额度、剩余额度、账户状态 |
| 汇报或分享用量 | AI 用量年报、CCR 路由图、模型排行榜、消费小票等分享卡片 |
## 组件区
## 时间范围
编辑模式下,左侧“组件”用于添加组件,中间“预览”展示当前布局,右侧“组件属性”编辑选中组件
顶部的 `按时间查看用量` 控制所有依赖用量统计的组件。切换后,请求、令牌、成本、趋势、分布和分享卡片会按新的窗口重新聚合
| 字段 | 代表的能力 |
| 选项 | 统计窗口 |
| --- | --- |
| 组件 | 可添加组件列表。 |
| 预览 | 当前仪表盘布局。组件可以拖拽排序。 |
| 组件属性 | 当前选中组件的配置区域。 |
| 组件类型 | 切换组件大类,例如状态、账户、指标、趋势、活跃度、构成、分析或分享卡片。 |
| 数据 | 选择组件展示的数据例如请求数、tokens、成本、账户、客户端分析或供应商分析。 |
| 组件大小 | 控制组件占用的网格宽高。 |
| 样式 | 切换组件展示样式,例如卡片、紧凑、柱状图、折线图、圆环等。 |
| 移除组件 | 从概览中移除当前组件。 |
| `今天` | 当前本地日期从 00:00 到现在,按小时分桶。 |
| `24 小时` | 最近 24 小时,按小时分桶。 |
| `7 天` | 最近 7 天,按天分桶。 |
| `30 天` | 最近 30 天,按天分桶。 |
## 组件类型
账户余额组件不按这个时间窗口重算,它展示供应商账户连接器最近一次获取到的快照。
| 组件 | 代表的能力 |
## 编辑布局
点击右上角铅笔按钮进入编辑模式。编辑模式分为三栏:
| 区域 | 作用 |
| --- | --- |
| 状态组件 | 展示系统状态时间线,帮助判断网关近期是否正常。 |
| 账户组件 | 展示供应商账户余额、套餐额度或用量。需要供应商开启“获取用量”。 |
| 指标组件 | 展示请求数、总 tokens、输入 tokens、输出 tokens、缓存 tokens、缓存比例、估算成本、成功率、错误数或平均延迟。 |
| 趋势组件 | 展示按时间聚合的用量趋势。 |
| 活跃度组件 | 展示 token 活跃度热力图。 |
| 构成组件 | 展示 Token 构成或模型分布。 |
| 分析组件 | 展示客户端分析或供应商分析。 |
| 分享卡片 | 生成适合分享的 PNG 卡片,例如 AI Usage Wrapped、CCR Route Map、Model Leaderboard、AI Fuel Cockpit、Token Calendar Poster 和 Spend Receipt。 |
| 组件 | 左侧组件库,点击任一组件即可添加到当前仪表盘。 |
| 预览 | 中间布局预览。可以拖拽组件排序,也可以点击组件选中它。 |
| 组件属性 | 右侧属性面板,用于修改类型、数据、尺寸、样式或移除组件。 |
## 数据来源
常用操作:
| 数据 | 来源 |
1. 添加组件:在左侧 `组件` 中点击组件模板。
2. 调整顺序:在 `预览` 中拖拽组件。
3. 调整尺寸:选中组件后拖动右侧、底部或右下角的缩放手柄。
4. 修改数据:在 `组件属性` 里切换 `组件类型``数据`
5. 修改展示:在 `组件大小``样式` 中选择合适的布局。
6. 保存结果:点击 `完成` 退出编辑模式;布局会保存在应用配置中。
7. 恢复默认:编辑模式下点击 `重置布局`
移除组件只会从概览布局中删除该卡片,不会删除请求日志、供应商、账户连接器或任何上游配置。如果所有组件都被移除,页面会显示 `未配置组件`
## 组件目录
尺寸使用 `宽:高` 表示,宽高范围都是 `1``4`。概览网格在桌面端最多 4 列,在窄屏上会自动折叠。
| 组件 | 可选数据 | 默认尺寸 | 默认样式 | 可选样式 |
| --- | --- | --- | --- | --- |
| 状态组件 | 系统状态 | `4:1` | 时间线 | 时间线、紧凑 |
| 账户组件 | 所有账户或指定账户 | `4:2` | 卡片 | 卡片、紧凑、横条、圆环、半圆、弧形、内外圆 |
| 指标组件 | 请求、Token、成本 | `1:1` | 卡片 | 卡片、紧凑、柱状图、圆环 |
| 趋势组件 | 按时间查看用量 | `3:2` | 组合图 | 组合图、面积图、折线图、柱状图 |
| 活跃度组件 | Token 活跃度 | `4:2` | 热力图 | 热力图 |
| 构成组件 | Token 分布 / 模型分布 | Token 分布:`1:2`;模型分布:`2:2` | Token 分布:横条;模型分布:饼图 | 横条、堆叠、环形图、饼图 |
| 分析组件 | 客户端分析 / 供应商分析 | `2:2` | 表格 | 表格、紧凑 |
| 分享卡片 | AI 用量年报、CCR 路由图、模型排行榜、AI 燃料仪表、Token 日历海报、消费小票 | `1:4` | 卡片 | 卡片 |
尺寸约束:
| 规则 | 原因 |
| --- | --- |
| 请求数、tokens、成本、成功率、延迟 | 请求日志和用量统计。 |
| 账户余额、套餐额度、状态消息 | 供应商“获取用量”配置。 |
| 客户端分析、供应商分析、模型分布 | 请求日志中的客户端、供应商、模型和 token 信息。 |
| Agent 分析相关数据 | 设置中的“Agent 观测”和 Agent 执行链路数据。 |
| 分享卡片最小高度是 `1:4`。 | 导出图片使用竖版海报比例,需要足够高度。 |
| 账户组件在展示所有账户且使用“紧凑”样式时,最小尺寸是 `2:2`。 | 多账户列表需要保留可读空间。 |
| 旧版尺寸别名仍可被解析:`small` -> `1:1``medium` / `large` -> `2:2``wide` -> `3:2``full` -> `4:1``4:2`。 | 用于兼容旧配置。 |
## 指标数据
`metric` 组件通过 `metric` 字段选择要展示的指标。
| `metric` | 含义 |
| --- | --- |
| `requests` | 请求数 |
| `total-tokens` | 总令牌 |
| `input-tokens` | 输入令牌 |
| `output-tokens` | 输出令牌 |
| `cache-tokens` | 缓存令牌 |
| `cache-ratio` | 缓存率 |
| `estimated-cost` | 估算成本,按模型价格信息计算 |
| `success-rate` | 成功率 |
| `errors` | 错误数 |
| `avg-latency` | 平均延迟 |
## 账户组件
账户组件读取供应商配置里的账户 / 用量连接器。要让它显示余额或剩余额度,需要先在供应商配置中启用并测试 `获取用量`
| 数据选择 | 行为 |
| --- | --- |
| `所有账户` | 展示所有可用账户快照。 |
| 指定账户 | 只展示某个供应商或某个凭据的账户快照。内部配置值格式通常是 `provider``provider::credentialId`。 |
如果账户组件为空,优先检查:
1. 供应商是否配置了账户 / 用量连接器。
2. `获取用量` 测试是否成功。
3. API Key 或账户接口是否仍有效。
4. 当前选择的指定账户是否已经被删除或重命名。
## 分享卡片
分享卡片组件可以通过右上角下载按钮导出 PNG。桌面 App 会优先使用原生导出能力,浏览器环境会退回到前端 canvas 导出。导出图片尺寸为 `1080 x 1350`
| 卡片 | `type` | 内容 |
| --- | --- | --- |
| AI 用量年报 | `share-usage-wrapped` | 总令牌、请求数、估算成本、缓存率、最长连续、最高频模型、最高频供应商、峰值日期。 |
| CCR 路由图 | `share-route-map` | 客户端到供应商 / 模型的主要路由关系,以及客户端、供应商、模型数量。 |
| 模型排行榜 | `share-model-leaderboard` | 按令牌排序的模型排行榜。 |
| AI 燃料仪表 | `share-fuel-cockpit` | 最多 3 个账户额度仪表,依赖账户 / 用量连接器。 |
| Token 日历海报 | `share-token-calendar` | 类似贡献日历的 Token 活跃度海报。 |
| 消费小票 | `share-spend-receipt` | 当前时间范围内的估算成本、请求、令牌、延迟和成功率小票。 |
## 数据来源与排障
| 现象 | 可能原因 | 处理方式 |
| --- | --- | --- |
| 请求、令牌或成本为 0 | 当前时间范围内没有经过 CCR 的请求,或用量捕获尚未记录。 | 切换到 `24h` / `7d`,确认客户端请求确实走 CCR。 |
| 成本显示为 `$0.00` | 模型没有价格信息,或用量过小。 | 检查模型目录和供应商模型名是否能匹配价格;低于 0.01 美元会显示更多小数。 |
| 成功率、错误数不符合预期 | 只统计 CCR 捕获到的请求结果。 | 对照日志页面中的请求记录。 |
| 账户余额为空 | 没有账户连接器,或 `获取用量` 失败。 | 到供应商配置中测试账户 / 用量字段映射。 |
| 分布图没有数据 | 请求日志里缺少模型、供应商或 token 信息。 | 确认请求经过 CCR并检查上游响应是否返回 token usage。 |
| PNG 导出失败 | 浏览器不支持 canvas 导出、元素尺寸为空,或用户取消了保存。 | 在桌面 App 中重试,确保卡片可见且没有被缩到过小。 |

View file

@ -13,7 +13,7 @@ lead: 了解 CCR 的定位、能力边界和文档结构。需要动手配置时
| --- | --- |
| [文档](./) | 产品定位、架构概览、阅读路径 |
| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 |
| [详细配置](configuration/provider/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
| [详细配置](configuration/overview/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
| [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 |
Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。
@ -24,7 +24,7 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独
1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。
2. App 的请求日志页面展示请求是否经过 CCR。
3. [详细配置](configuration/provider/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
3. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
4. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。
这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。

View file

@ -15,7 +15,7 @@ export const docsContent = {
navItems: [
{ label: "文档", href: "/", pageKey: "documentation" },
{ label: "快速开始", href: "/guides/", pageKey: "guides" },
{ label: "详细配置", href: "/configuration/provider/", pageKey: "configuration" },
{ label: "详细配置", href: "/configuration/overview/", pageKey: "configuration" },
{ label: "Q&A", href: "/troubleshooting/", pageKey: "troubleshooting" },
],
pages: {
@ -77,7 +77,7 @@ export const docsContent = {
"Bot 与 IM 接力 Agent",
"配置数据库位置",
],
active: "供应商配置",
active: "概览仪表盘",
},
],
expandableSidebarItems: ["Fusion 组合模型", "Bot 与 IM 接力 Agent"],
@ -163,7 +163,7 @@ export const docsContent = {
navItems: [
{ label: "Documentation", href: "/en/", pageKey: "documentation" },
{ label: "Quick Start", href: "/en/guides/", pageKey: "guides" },
{ label: "Detailed Configuration", href: "/en/configuration/providers/", pageKey: "configuration" },
{ label: "Detailed Configuration", href: "/en/configuration/overview/", pageKey: "configuration" },
{ label: "Q&A", href: "/en/troubleshooting/", pageKey: "troubleshooting" },
],
pages: {
@ -225,7 +225,7 @@ export const docsContent = {
"Bots And IM Agent Relay",
"Config Database Location",
],
active: "Provider Config",
active: "Overview Dashboard",
},
],
expandableSidebarItems: ["Fusion Models", "Bots And IM Agent Relay"],

View file

@ -1,5 +1,5 @@
---
const target = "/configuration/provider/";
const target = "/configuration/overview/";
---
<html lang="zh-CN">
@ -8,10 +8,10 @@ const target = "/configuration/provider/";
<meta http-equiv="refresh" content={`0;url=${target}`} />
<link rel="canonical" href={target} />
<script is:inline>
window.location.replace("/configuration/provider/");
window.location.replace("/configuration/overview/");
</script>
</head>
<body>
<a href={target}>供应商配置</a>
<a href={target}>概览仪表盘</a>
</body>
</html>

View file

@ -1,5 +1,5 @@
---
const target = "/en/configuration/providers/";
const target = "/en/configuration/overview/";
---
<html lang="en">
@ -8,10 +8,10 @@ const target = "/en/configuration/providers/";
<meta http-equiv="refresh" content={`0;url=${target}`} />
<link rel="canonical" href={target} />
<script is:inline>
window.location.replace("/en/configuration/providers/");
window.location.replace("/en/configuration/overview/");
</script>
</head>
<body>
<a href={target}>Provider Config</a>
<a href={target}>Overview Dashboard</a>
</body>
</html>

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "claude-code-router",
"version": "3.0.5",
"version": "3.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-code-router",
"version": "3.0.5",
"version": "3.0.6",
"license": "MIT",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.3",

View file

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

View file

@ -55,6 +55,7 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
const env: NodeJS.ProcessEnv = {
...process.env,
...profileEnv(profile),
...claudeCodeModelEnv(profile),
...(config ? botGatewayProfileEnv(config, profile, "app") : {}),
CLAUDE_CONFIG_DIR: settingsDir,
CLAUDE_USER_DATA_DIR: userDataDir,
@ -314,6 +315,35 @@ function profileEnv(profile: ProfileConfig): Record<string, string> {
}, {});
}
function claudeCodeModelEnv(profile: ProfileConfig): Record<string, string> {
const env: Record<string, string> = {};
const model = normalizeClientModel(profile.model);
if (model) {
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
}
const smallFastModel = normalizeClientModel(profile.smallFastModel);
if (smallFastModel) {
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
}
return env;
}
function normalizeClientModel(value: string | undefined): string {
const trimmed = value?.trim() || "";
if (!trimmed) {
return "";
}
const commaIndex = trimmed.indexOf(",");
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
const provider = trimmed.slice(0, commaIndex).trim();
const model = trimmed.slice(commaIndex + 1).trim();
return provider && model ? `${provider}/${model}` : "";
}
return trimmed;
}
function isEnvName(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}

View file

@ -87,7 +87,8 @@ async function main() {
async function runClaudeCodeCliWrapper(args) {
const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude");
log("claude_code_wrapper_start", { realCli, args });
const realArgs = claudeCodeCliWrapperArgs(args);
log("claude_code_wrapper_start", { realCli, args, realArgs });
const captureStdout = shouldCaptureClaudeCodeCliStdout(args);
const remoteSync = createRemoteSyncClient({
args,
@ -96,7 +97,7 @@ async function runClaudeCodeCliWrapper(args) {
title: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") || "Claude Code"
});
const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN");
const child = childProcess.spawn(realCli, args, {
const child = childProcess.spawn(realCli, realArgs, {
env: {
...withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
...claudeCodeUtcTimezoneEnvOverride()
@ -145,6 +146,79 @@ async function runClaudeCodeCliWrapper(args) {
process.exitCode = code;
}
function claudeCodeCliWrapperArgs(args) {
const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || nonEmptyEnv("ANTHROPIC_MODEL");
if (!model || claudeCodeArgsHaveModel(args) || claudeCodeArgsShouldSkipModelInjection(args)) {
return args;
}
return ["--model", model, ...args];
}
function claudeCodeArgsHaveModel(args) {
for (const arg of args) {
if (arg === "--model" || arg === "-m" || arg.startsWith("--model=")) {
return true;
}
}
return false;
}
function claudeCodeArgsShouldSkipModelInjection(args) {
if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v")) {
return true;
}
const command = firstClaudeCodePositionalArg(args);
return Boolean(command && new Set([
"config",
"doctor",
"help",
"install",
"login",
"logout",
"mcp",
"update",
"upgrade",
"version"
]).has(command.toLowerCase()));
}
function firstClaudeCodePositionalArg(args) {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--") {
return undefined;
}
if (!arg.startsWith("-")) {
return arg;
}
if (claudeCodeOptionTakesValue(arg) && !arg.includes("=")) {
index += 1;
}
}
return undefined;
}
function claudeCodeOptionTakesValue(arg) {
return new Set([
"--add-dir",
"--append-system-prompt",
"--config",
"--continue",
"--debug-to",
"--fallback-model",
"--model",
"--output-format",
"--permission-mode",
"--resume",
"--settings",
"--system-prompt",
"-c",
"-m",
"-p",
"-r"
]).has(arg);
}
function shouldCaptureClaudeCodeCliStdout(args) {
if (boolEnv("CCR_CLAUDE_CODE_CAPTURE_STDOUT") || boolEnv("CODEXL_CLAUDE_CODE_CAPTURE_STDOUT")) {
return true;
@ -736,7 +810,14 @@ async function runClaudeCodeBotWorker(args) {
try {
const server = new ClaudeCodeAppServer(options);
server.ensureBotBridgeRegistered();
log("claude_bot_worker_start", { workspaceName: options.workspaceName, pid: process.pid, lockPath: lock.path });
log("claude_bot_worker_start", {
workspaceName: options.workspaceName,
pid: process.pid,
lockPath: lock.path,
claudeConfigDir: nonEmptyEnv("CLAUDE_CONFIG_DIR"),
claudeUserDataDir: currentClaudeAppUserDataDir(),
model: nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || agentEnv(codexRuntimeAgent(), "MODEL") || ""
});
await waitForTerminationSignal();
await botBridge().stop();
log("claude_bot_worker_stop", { pid: process.pid });
@ -1295,13 +1376,23 @@ class ClaudeCodeAppServer {
return null;
}
if (!entry.claudeSessionId && !entry.claudeAppSessionId) return null;
const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || "");
if (!botSessionEntryMatchesCurrentProfile(entry, appSession)) {
log("bot_gateway_session_scope_skip", {
conversationKeyPrefix: key.slice(0, 80),
threadId: entry.threadId || "",
claudeConfigDir: entry.claudeConfigDir || appSession.claudeConfigDir || "",
claudeAppSessionFile: entry.claudeAppSessionFile || "",
expectedUserDataDir: currentClaudeAppUserDataDir()
});
return null;
}
const thread = this.createThread({
cwd: entry.cwd || process.cwd(),
model: entry.model || undefined,
workspaceKind: "local",
claudeConfigDir: entry.claudeConfigDir || null
});
const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || "");
this.replaceThreadId(thread, entry.threadId || thread.id);
thread.sessionId = entry.sessionId || thread.id;
thread.claudeSessionId = entry.claudeSessionId || appSession.cliSessionId || null;
@ -1537,7 +1628,15 @@ class ClaudeCodeAppServer {
if (!thread || !turn) return;
const started = Date.now();
const command = claudeCommand(work);
log("claude_turn_spawn", { threadId: work.threadId, turnId: work.turnId, command: command.command, args: command.args });
log("claude_turn_spawn", {
threadId: work.threadId,
turnId: work.turnId,
command: command.command,
args: command.args,
cwd: work.cwd,
claudeConfigDir: work.claudeConfigDir || "",
expectedUserDataDir: currentClaudeAppUserDataDir()
});
const child = childProcess.spawn(command.command, command.args, {
cwd: work.cwd,
env: command.env,
@ -3414,9 +3513,9 @@ function latestClaudeAppLocalAgentSession() {
}
function claudeAppLocalAgentSessions() {
const baseDir = nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR");
const baseDir = currentClaudeAppUserDataDir();
if (!baseDir) return [];
const root = path.join(expandHome(baseDir), "local-agent-mode-sessions");
const root = path.join(baseDir, "local-agent-mode-sessions");
const files = listClaudeAppSessionFiles(root, 6);
const sessions = [];
for (const file of files) {
@ -3449,6 +3548,37 @@ function claudeAppLocalAgentSessions() {
return sessions;
}
function currentClaudeAppUserDataDir() {
return expandHome(nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR") || "");
}
function botSessionEntryMatchesCurrentProfile(entry, appSession) {
const expectedUserDataDir = currentClaudeAppUserDataDir();
if (!expectedUserDataDir) return true;
const candidates = [
entry && entry.claudeConfigDir,
entry && entry.claudeAppSessionFile,
entry && entry.cwd,
appSession && appSession.claudeConfigDir
];
return candidates.some((candidate) => pathIsInside(candidate, expectedUserDataDir));
}
function pathIsInside(candidate, parentDir) {
const child = expandHome(String(candidate || ""));
const parent = expandHome(String(parentDir || ""));
if (!child || !parent) return false;
const childPath = normalizeComparablePath(path.resolve(child));
const parentPath = normalizeComparablePath(path.resolve(parent));
if (childPath === parentPath) return true;
const relative = path.relative(parentPath, childPath);
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
}
function normalizeComparablePath(value) {
return process.platform === "win32" ? value.toLowerCase() : value;
}
function resolveClaudeAppLocalAgentSession(selector) {
const query = String(selector || "").trim();
if (!query) return null;

View file

@ -415,11 +415,18 @@ function virtualModelProfileSupportsFusionWebSearch(profile: VirtualModelProfile
return (profile.tools ?? []).some((tool) => {
const name = tool.name.trim();
return name === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME ||
name.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`);
return fusionWebSearchToolNameMatches(name);
});
}
function fusionWebSearchToolNameMatches(name: string): boolean {
const normalized = name.toLowerCase().replace(/[-.]/g, "_");
return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME ||
normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`) ||
normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) ||
normalized.includes("search_web");
}
function recordValue(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ import { ensureCcrCliLauncher } from "./profile-launch-service";
import { proxyService } from "../server/proxy/service";
import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import { browserWebSearchMcpService } from "./electron-web-search-mcp";
import windowsManager from "./windows";
const gotTheLock = app.requestSingleInstanceLock();
@ -27,6 +28,7 @@ if (!gotTheLock) {
}
function startPrimaryInstance(): void {
gatewayService.setBrowserWebSearchMcpIntegration(browserWebSearchMcpService);
deepLinkService.register();
deepLinkService.handleArgv(process.argv);

View file

@ -182,6 +182,7 @@ function buildClaudeCodeLaunchPlan(
env: {
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
CCR_PROFILE_SURFACE: surface,
...claudeCodeModelEnv(profile),
...claudeCodeUtcTimezoneEnvOverride()
},
profile,
@ -219,6 +220,35 @@ function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli
return value === "cli" || value === "app" ? value : "auto";
}
function claudeCodeModelEnv(profile: ProfileConfig): Record<string, string> {
const env: Record<string, string> = {};
const model = normalizeClientModel(profile.model);
if (model) {
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
}
const smallFastModel = normalizeClientModel(profile.smallFastModel);
if (smallFastModel) {
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
}
return env;
}
function normalizeClientModel(value: string | undefined): string {
const trimmed = value?.trim() || "";
if (!trimmed) {
return "";
}
const commaIndex = trimmed.indexOf(",");
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
const provider = trimmed.slice(0, commaIndex).trim();
const model = trimmed.slice(commaIndex + 1).trim();
return provider && model ? `${provider}/${model}` : "";
}
return trimmed;
}
function isGeneratedProfileScope(value: ProfileConfig["scope"]): boolean {
return value === "ccr" || value === "custom";
}

View file

@ -60,6 +60,7 @@ export async function applyProfileConfig(config: AppConfig): Promise<ProfileAppl
: applyCodexProfile(config, profile, token, appliedAt)
);
}
result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles));
return result;
}
@ -91,9 +92,14 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
delete env.ANTHROPIC_AUTH_TOKEN;
delete env.ANTHROPIC_API_KEY;
if (profile.model.trim()) {
env.ANTHROPIC_MODEL = normalizeClientModel(profile.model);
const model = normalizeClientModel(profile.model);
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
} else {
delete env.ANTHROPIC_MODEL;
delete env.CCR_CLAUDE_CODE_MODEL;
delete env.CODEXL_CLAUDE_CODE_MODEL;
}
if (profile.smallFastModel?.trim()) {
env.ANTHROPIC_SMALL_FAST_MODEL = normalizeClientModel(profile.smallFastModel);
@ -533,6 +539,7 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const surface = normalizeProfileSurface(profile.surface);
const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile));
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
@ -540,6 +547,7 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
return [
"#!/bin/sh",
...envExports,
...shellEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)),
...shellEnvExports(claudeCodeUtcTimezoneEnvOverride()),
`: "\${CCR_PROFILE_SURFACE:=${surface}}"`,
"export CCR_PROFILE_SURFACE",
@ -562,6 +570,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const surface = normalizeProfileSurface(profile.surface);
const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile));
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => cmdSetLine(key, value));
@ -569,6 +578,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r
return [
"@echo off",
...envExports,
...cmdEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)),
...cmdEnvExports(claudeCodeUtcTimezoneEnvOverride()),
`if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", surface)}`,
...botEnvExports,
@ -617,6 +627,27 @@ function writeCodexCliMiddleware(
};
}
function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, settingsDir: string): Record<string, string> {
const endpoint = gatewayEndpoint(config);
const env: Record<string, string> = {
ANTHROPIC_API_BASE_URL: endpoint,
ANTHROPIC_BASE_URL: endpoint,
CLAUDE_AGENT_API_BASE_URL: endpoint,
CLAUDE_CONFIG_DIR: settingsDir
};
const model = normalizeClientModel(profile.model);
if (model) {
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
}
const smallFastModel = normalizeClientModel(profile.smallFastModel);
if (smallFastModel) {
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
}
return env;
}
function codexMiddlewareRuntimeFilename(): string {
return "ccr-codex-cli-middleware.js";
}
@ -1032,6 +1063,60 @@ function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus
);
}
export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]): ProfileClientApplyStatus[] {
const statuses: ProfileClientApplyStatus[] = [];
if (!profiles.some((profile) => profile.agent === "claude-code" && profile.enabled && isGlobalProfile(profile))) {
for (const file of uniqueResolvedPaths([
"~/.claude/settings.json",
...profiles
.filter((profile) => profile.agent === "claude-code")
.map((profile) => profile.settingsFile || "")
.filter(Boolean)
])) {
const restoreResult = restoreGlobalConfigFile(file, {
isManagedContent: isManagedClaudeCodeSettingsContent,
mode: privateFileMode
});
if (restoreResult.changed || restoreResult.missingBackup) {
statuses.push(inactiveGlobalCleanupStatus("claude-code", file, restoreResult));
}
}
}
return statuses;
}
function inactiveGlobalCleanupStatus(
client: ProfileClientKind,
file: string,
restoreResult: RestoreFileResult
): ProfileClientApplyStatus {
return {
backupFile: restoreResult.backupFile,
client,
enabled: false,
message: restoreResult.missingBackup
? `No active global ${codexCompatibleClientName(client)} profile is configured, but the global config is managed by CCR and no original backup was found.`
: `${codexCompatibleClientName(client)} global config was restored because no active global profile is configured.`,
ok: !restoreResult.missingBackup,
path: resolveUserPath(file)
};
}
function uniqueResolvedPaths(paths: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const item of paths) {
const resolved = resolveUserPath(item);
const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(resolved);
}
return result;
}
function restoreDisabledZcodeProfile(profile: ProfileConfig, configFile: string): ProfileClientApplyStatus {
const disabledMessage = "ZCode profile is disabled.";
if (!isGlobalProfile(profile)) {

View file

@ -6,6 +6,7 @@ import { loadAppConfig } from "./config";
import { APP_NAME } from "./constants";
import { getProviderAccountSnapshots } from "./provider-account-service";
import { getTodayUsageTotals, onUsageRecorded } from "./usage-store";
import windowsManager from "./windows";
import type { AppConfig, ProviderAccountMeter, TrayBalanceProgressConfig, TrayIconPreference } from "../shared/app";
const popoverMenuWidth = 420;
@ -58,6 +59,10 @@ class TrayController {
this.suppressMainWindowActivation();
this.togglePopover();
});
this.tray.on("double-click", () => {
this.suppressMainWindowActivation();
this.showMainWindow();
});
this.tray.on("right-click", () => {
this.suppressMainWindowActivation();
this.showContextMenu();
@ -275,6 +280,10 @@ class TrayController {
label: formatTokenTitle(this.trayTotalTokens)
},
{ type: "separator" },
{
click: () => this.showMainWindow(),
label: `Open ${APP_NAME}`
},
{
click: () => this.showPopover(),
label: "Show Usage"
@ -289,6 +298,11 @@ class TrayController {
this.tray?.popUpContextMenu(menu);
}
private showMainWindow(): void {
this.hidePopover();
windowsManager.showMainWindow();
}
private showDetailPopover(provider?: string): void {
if (!this.popover || this.popover.isDestroyed() || !this.popover.isVisible()) {
return;

View file

@ -57,6 +57,13 @@ class WindowsManager {
window.show();
}
});
window.on("close", (event) => {
if (!shouldHideMainWindowOnClose()) {
return;
}
event.preventDefault();
window.hide();
});
window.on("closed", () => this.windows.delete("main"));
window.webContents.on("page-title-updated", (_event, title) => {
window.setTitle(title || APP_NAME);
@ -115,10 +122,17 @@ const windowsManager = new WindowsManager();
export default windowsManager;
let appIsQuitting = false;
app.on("before-quit", () => {
appIsQuitting = true;
windowsManager.broadcast(IPC_CHANNELS.appBeforeQuit);
});
function shouldHideMainWindowOnClose(): boolean {
return process.platform === "win32" && !appIsQuitting;
}
function fitWindowSize(preferred: number, minimum: number, available: number): number {
return Math.max(minimum, Math.min(preferred, available > 0 ? available : preferred));
}

View file

@ -348,7 +348,7 @@ export function ModelsView({
<AnimatePresence initial={false}>
{visibleRows.map((row) => (
<AnimatedListItem
className="grid min-h-[76px] grid-cols-[minmax(0,1fr)_minmax(260px,1.5fr)] items-start gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
className="grid min-h-[60px] grid-cols-[minmax(0,1fr)_minmax(260px,1.5fr)] items-start gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
key={row.key}
>
<div className="min-w-0">
@ -358,11 +358,6 @@ export function ModelsView({
<div className="truncate font-mono text-[11px] text-muted-foreground" title={row.providerName ? `${row.providerName}/${row.model}` : row.model}>
{row.providerName ? `${row.providerName}/${row.model}` : row.model}
</div>
{row.providerName ? (
<Badge className="mt-1 max-w-full" variant="outline">
<span className="truncate">{row.providerName}</span>
</Badge>
) : null}
</div>
<div className="min-w-0">
{row.providerIndex !== undefined ? (

View file

@ -3,18 +3,40 @@ import {
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, ChevronRight,
cn, createMcpServerDraftFromConfig, createRouteModelOptions, defaultFusionWebSearchProvider, Dialog, DialogBody, DialogContent, DialogFooter,
DialogHeader, DialogTitle, ExtensionInstallDraft, Field, FolderOpen, formatPluginDependencies,
createFusionWebSearchEnvRows, customFusionToolName, fusionToolExecutionFlagsFromTools, fusionToolOptions,
createFusionWebSearchEnvRows, createKeyValueDraftRow, customFusionToolName, fusionToolExecutionFlagsFromTools, fusionToolOptions,
fusionWebSearchProviderOptions, GatewayMcpServerConfig, GatewayMcpToolInfo, GatewayProviderConfig, Input, isBuiltInFusionToolName, isFusionVisionToolName, isFusionWebSearchToolName, KeyValueRowsControl, LoaderCircle,
mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions,
mcpStdioMessageModeOptions, motion, normalizeFusionToolName, Pencil,
PluginMarketplaceEntry, Plus, PopoverContent, RouteTargetControl, Search, selectedFusionToolNames,
SelectControl, Toggle, Trash2, translateOptions, useAppErrorText, useAppText, useEffect, useLayoutEffect, useMemo,
useRef, useState, validateMcpServerDraft, virtualModelBaseModelSummary, VirtualModelDraft, virtualModelMatchesQuery, virtualModelMatchSummary,
type KeyValueDraftRow,
VirtualModelProfileConfig, virtualModelToolSummary, X
} from "../shared";
const virtualModelTableGridClass = "grid-cols-[minmax(180px,0.9fr)_minmax(220px,1.1fr)_minmax(220px,1.1fr)_minmax(170px,0.85fr)_112px_96px]";
const virtualModelTableMinWidthClass = "min-w-[1100px]";
const browserWebSearchEngineEnvKey = "BROWSER_SEARCH_ENGINE";
const browserWebSearchLanguageEnvKey = "BROWSER_SEARCH_LANGUAGE";
const browserWebSearchCountryEnvKey = "BROWSER_SEARCH_COUNTRY";
const browserWebSearchSafeSearchEnvKey = "BROWSER_SEARCH_SAFE_SEARCH";
const browserWebSearchKnownEnvKeys = new Set([
browserWebSearchEngineEnvKey,
browserWebSearchLanguageEnvKey,
browserWebSearchCountryEnvKey,
browserWebSearchSafeSearchEnvKey
]);
const browserWebSearchEngineOptions = [
{ label: "Bing", value: "bing" },
{ label: "Google", value: "google" },
{ label: "DuckDuckGo", value: "duckduckgo" }
];
const browserWebSearchSafeSearchOptions = [
{ label: "Default", value: "default" },
{ label: "Moderate", value: "moderate" },
{ label: "Strict", value: "strict" },
{ label: "Off", value: "off" }
];
function uniqueFusionTools(tools: string[]): string[] {
const seen = new Set<string>();
@ -452,17 +474,112 @@ function WebSearchToolConfigurationPanel({
<Field label={t("Search provider")}>
<SelectControl onChange={updateProvider} options={providerOptions} value={draft.webSearchProvider} />
</Field>
<Field label={t("Provider configuration")}>
<KeyValueRowsControl
addLabel={t("Add variable")}
{draft.webSearchProvider === "browser" ? (
<BrowserWebSearchConfigurationPanel
onChange={(webSearchEnvRows) => onChange({ webSearchEnvRows })}
rows={draft.webSearchEnvRows}
/>
) : (
<Field label={t("Provider configuration")}>
<KeyValueRowsControl
addLabel={t("Add variable")}
onChange={(webSearchEnvRows) => onChange({ webSearchEnvRows })}
rows={draft.webSearchEnvRows}
/>
</Field>
)}
</div>
);
}
function BrowserWebSearchConfigurationPanel({
onChange,
rows
}: {
onChange: (rows: KeyValueDraftRow[]) => void;
rows: KeyValueDraftRow[];
}) {
const t = useAppText();
const extraRows = browserWebSearchExtraRows(rows);
const engine = browserWebSearchEnvValue(rows, browserWebSearchEngineEnvKey) || "bing";
const language = browserWebSearchEnvValue(rows, browserWebSearchLanguageEnvKey);
const country = browserWebSearchEnvValue(rows, browserWebSearchCountryEnvKey);
const safeSearch = browserWebSearchEnvValue(rows, browserWebSearchSafeSearchEnvKey) || "default";
function updateKnownValue(key: string, value: string) {
onChange(upsertBrowserWebSearchEnvRows(rows, key, value === "default" ? "" : value));
}
function updateExtraRows(nextExtraRows: KeyValueDraftRow[]) {
onChange(mergeBrowserWebSearchEnvRows(rows, nextExtraRows));
}
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Field label={t("Search engine")}>
<SelectControl
onChange={(value) => updateKnownValue(browserWebSearchEngineEnvKey, value)}
options={browserWebSearchEngineOptions}
value={browserWebSearchEngineOptions.some((option) => option.value === engine) ? engine : "bing"}
/>
</Field>
<Field label={t("Safe search")}>
<SelectControl
onChange={(value) => updateKnownValue(browserWebSearchSafeSearchEnvKey, value)}
options={translateOptions(browserWebSearchSafeSearchOptions, t)}
value={browserWebSearchSafeSearchOptions.some((option) => option.value === safeSearch) ? safeSearch : "default"}
/>
</Field>
<Field label={t("Language")}>
<Input
onChange={(event) => updateKnownValue(browserWebSearchLanguageEnvKey, event.target.value)}
placeholder="en, zh-CN"
value={language}
/>
</Field>
<Field label={t("Country")}>
<Input
onChange={(event) => updateKnownValue(browserWebSearchCountryEnvKey, event.target.value)}
placeholder="US, CN"
value={country}
/>
</Field>
</div>
<Field label={t("Advanced variables")}>
<KeyValueRowsControl
addLabel={t("Add variable")}
onChange={updateExtraRows}
rows={extraRows}
/>
</Field>
</div>
);
}
function browserWebSearchEnvValue(rows: KeyValueDraftRow[], key: string): string {
return rows.find((row) => row.key.trim() === key)?.value.trim() ?? "";
}
function browserWebSearchExtraRows(rows: KeyValueDraftRow[]): KeyValueDraftRow[] {
return rows.filter((row) => {
const key = row.key.trim();
return key && !browserWebSearchKnownEnvKeys.has(key);
});
}
function upsertBrowserWebSearchEnvRows(rows: KeyValueDraftRow[], key: string, value: string): KeyValueDraftRow[] {
const normalizedValue = value.trim();
const nextRows = rows.filter((row) => row.key.trim() !== key);
return normalizedValue ? [createKeyValueDraftRow(key, normalizedValue), ...nextRows] : nextRows;
}
function mergeBrowserWebSearchEnvRows(currentRows: KeyValueDraftRow[], extraRows: KeyValueDraftRow[]): KeyValueDraftRow[] {
const knownRows = currentRows.filter((row) => browserWebSearchKnownEnvKeys.has(row.key.trim()) && row.value.trim());
const cleanedExtraRows = extraRows.filter((row) => row.key.trim() || row.value.trim());
return [...knownRows, ...cleanedExtraRows];
}
function CustomMcpToolDialog({
draft,
error,

View file

@ -380,7 +380,17 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Vision model is required.": "Vision model is required.",
"Web search configuration": "Web search configuration",
"Search provider": "Search provider",
"Provider configuration": "Provider configuration",
"In-app Browser": "In-app Browser",
"Provider configuration": "Provider configuration",
"Search engine": "Search engine",
"Safe search": "Safe search",
"Language": "Language",
"Country": "Country",
"Advanced variables": "Advanced variables",
"Default": "Default",
"Moderate": "Moderate",
"Strict": "Strict",
"Off": "Off",
"Add tool": "Add tool",
"Add variable": "Add variable",
"Add custom MCP": "Add custom MCP",
@ -407,6 +417,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Image recognition and Web Search": "Image recognition and Web Search",
"Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.",
"Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
"Web Search": "Web Search",
"New model": "New model",
"New model is required.": "New model is required.",
@ -1232,7 +1243,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Vision model is required.": "必须选择视觉模型。",
"Web search configuration": "Web Search 配置",
"Search provider": "搜索类型",
"Provider configuration": "类型配置",
"In-app Browser": "内置浏览器",
"Provider configuration": "类型配置",
"Search engine": "搜索引擎",
"Safe search": "安全搜索",
"Language": "语言",
"Country": "国家/地区",
"Advanced variables": "高级变量",
"Default": "默认",
"Moderate": "中等",
"Strict": "严格",
"Add tool": "添加工具",
"Add variable": "添加变量",
"Add custom MCP": "添加自定义 MCP",
@ -1250,6 +1270,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Image recognition and Web Search": "图片识别和 Web Search",
"Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "通用图片理解工具,支持 OCR、截图分析、图表解读、UI 对比、错误诊断等多图任务。",
"Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。",
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持隐藏内置浏览器搜索,以及 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。",
"Web Search": "网页搜索",
"New model": "新模型",
"New model is required.": "新模型不能为空。",

View file

@ -265,7 +265,7 @@ export const fusionToolOptions: Array<{ description: string; label: string; valu
value: BUILTIN_FUSION_VISION_TOOL_NAME
},
{
description: "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
description: "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
label: `${BUILTIN_FUSION_TOOL_SERVER_NAME} / ${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`,
value: BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME
}
@ -277,6 +277,7 @@ export const customFusionToolName = "custom_mcp_tool";
export const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "brave";
export const fusionWebSearchProviderOptions: Array<{ label: string; value: VirtualModelFusionWebSearchProvider }> = [
{ label: "In-app Browser", value: "browser" },
{ label: "Brave", value: "brave" },
{ label: "Bing", value: "bing" },
{ label: "Google CSE", value: "google_cse" },
@ -288,6 +289,7 @@ export const fusionWebSearchProviderOptions: Array<{ label: string; value: Virtu
export const fusionWebSearchEnvKeysByProvider: Record<VirtualModelFusionWebSearchProvider, string[]> = {
bing: ["BING_SEARCH_API_KEY", "BING_SEARCH_ENDPOINT"],
browser: ["BROWSER_SEARCH_ENGINE", "BROWSER_SEARCH_LANGUAGE", "BROWSER_SEARCH_COUNTRY", "BROWSER_SEARCH_SAFE_SEARCH"],
brave: ["BRAVE_SEARCH_API_KEY", "BRAVE_SEARCH_ENDPOINT"],
exa: ["EXA_API_KEY", "EXA_SEARCH_ENDPOINT"],
google_cse: ["GOOGLE_SEARCH_API_KEY", "GOOGLE_SEARCH_CX", "GOOGLE_SEARCH_ENDPOINT"],

View file

@ -625,7 +625,7 @@ export function fusionWebSearchConfigFromDraft(draft: VirtualModelDraft, key: st
export function fusionWebSearchToolName(key: string): string {
const normalized = sanitizeConfigId(key).replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
return `web_search_${normalized || "fusion"}`;
return `${normalized || "fusion"}_web_search`;
}
export function fusionCustomToolConfigFromProfile(profile: VirtualModelProfileConfig): VirtualModelFusionCustomToolConfig | undefined {
@ -996,7 +996,10 @@ export function isFusionVisionToolName(name: string): boolean {
export function isFusionWebSearchToolName(name: string): boolean {
const normalized = normalizeFusionToolName(name);
return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`);
return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME ||
normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`) ||
normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) ||
normalized.includes("search_web");
}
export function selectedFusionToolName(toolsText: string): string {

View file

@ -2,7 +2,7 @@ import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import type { AppConfig, RouterBuiltInAgentRuleId, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
import { availableGatewayModelIds, type AppConfig, type RouterBuiltInAgentRuleId, type RouterConfig, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "../../shared/app";
import { CONFIGDIR } from "../../main/constants";
type HeaderValue = string | string[] | undefined;
@ -420,8 +420,7 @@ function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; t
}
function configuredSubagentModelDescriptionRows(config: AppConfig): string[] {
const rows: string[] = [];
const seen = new Set<string>();
const candidates: Array<{ key: string; row: string; selector: string }> = [];
for (const provider of config.Providers) {
const providerName = provider.name?.trim();
if (!providerName || !Array.isArray(provider.models)) {
@ -435,18 +434,42 @@ function configuredSubagentModelDescriptionRows(config: AppConfig): string[] {
}
const selector = `${providerName}/${model}`;
const key = selector.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
const displayName = provider.modelDisplayNames?.[model]?.trim();
const label = displayName && displayName !== model ? `${selector} (${displayName})` : selector;
rows.push(`- ${label}: ${singleLineText(description, 320)}`);
candidates.push({
key,
row: `- ${label}: ${singleLineText(description, 320)}`,
selector
});
}
}
candidates.sort(compareSubagentModelDescriptionRows);
const rows: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (seen.has(candidate.key)) {
continue;
}
seen.add(candidate.key);
rows.push(candidate.row);
}
return rows;
}
function compareSubagentModelDescriptionRows(
left: { key: string; row: string; selector: string },
right: { key: string; row: string; selector: string }
): number {
return compareCodeUnitStrings(left.key, right.key) ||
compareCodeUnitStrings(left.selector, right.selector) ||
compareCodeUnitStrings(left.row, right.row);
}
function compareCodeUnitStrings(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function removeClaudeCodeBillingSystemHeader(body: Record<string, unknown>): void {
const system = body.system;
if (!Array.isArray(system) || system.length === 0) {
@ -1032,16 +1055,22 @@ export function normalizeRouteSelector(value: string | undefined): string | unde
}
function isKnownInlineRoute(model: string | undefined, config: AppConfig): boolean {
if (!model) {
const normalizedModel = normalizeRouteSelector(model);
if (!normalizedModel) {
return false;
}
const separator = model.indexOf("/");
const normalizedModelLower = normalizedModel.toLowerCase();
if (availableGatewayModelIds(config).some((id) => id.toLowerCase() === normalizedModelLower)) {
return true;
}
const separator = normalizedModel.indexOf("/");
if (separator <= 0) {
return false;
}
const providerName = model.slice(0, separator).trim().toLowerCase();
const providerName = normalizedModel.slice(0, separator).trim().toLowerCase();
return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
const targetUrl = process.env.BROWSER_WEB_SEARCH_MCP_URL?.trim();
const requestTimeoutMs = clampInteger(Number(process.env.BROWSER_WEB_SEARCH_PROXY_TIMEOUT_MS), 1_000, 600_000, 120_000);
let stdinBuffer = Buffer.alloc(0);
if (!targetUrl) {
process.stderr.write("BROWSER_WEB_SEARCH_MCP_URL is required.\n");
process.exit(1);
}
process.stdin.on("data", (chunk: Buffer | string) => {
stdinBuffer = Buffer.concat([stdinBuffer, typeof chunk === "string" ? Buffer.from(chunk) : chunk]);
void drainFrames();
});
process.stdin.on("end", () => {
process.exit(0);
});
async function drainFrames(): Promise<void> {
while (true) {
const headerEnd = stdinBuffer.indexOf("\r\n\r\n");
if (headerEnd < 0) {
return;
}
const headerText = stdinBuffer.slice(0, headerEnd).toString("utf8");
const contentLength = readContentLength(headerText);
if (contentLength === undefined || contentLength < 0) {
stdinBuffer = Buffer.alloc(0);
writeFrame(jsonRpcError(null, -32600, "Invalid MCP frame header."));
return;
}
const payloadStart = headerEnd + 4;
const payloadEnd = payloadStart + contentLength;
if (stdinBuffer.length < payloadEnd) {
return;
}
const body = stdinBuffer.slice(payloadStart, payloadEnd).toString("utf8");
stdinBuffer = stdinBuffer.slice(payloadEnd);
await forwardPayload(body);
}
}
async function forwardPayload(body: string): Promise<void> {
const requestId = readJsonRpcId(body);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await fetch(targetUrl!, {
body,
headers: {
accept: "application/json, text/event-stream",
"content-type": "application/json"
},
method: "POST",
signal: controller.signal
});
if (response.status === 204) {
return;
}
const text = await response.text();
if (!response.ok) {
writeFrame(jsonRpcError(requestId, -32603, text || `In-app browser MCP returned HTTP ${response.status}.`));
return;
}
if (text.trim()) {
writeRawFrame(text);
}
} catch (error) {
writeFrame(jsonRpcError(requestId, -32603, formatError(error)));
} finally {
clearTimeout(timeout);
}
}
function readContentLength(headerText: string): number | undefined {
for (const line of headerText.split(/\r?\n/)) {
const match = /^content-length\s*:\s*(\d+)\s*$/i.exec(line);
if (match) {
return Number(match[1]);
}
}
return undefined;
}
function readJsonRpcId(body: string): null | number | string {
try {
const payload = JSON.parse(body) as unknown;
if (isRecord(payload)) {
return isJsonRpcId(payload.id) ? payload.id : null;
}
if (Array.isArray(payload)) {
const first = payload.find((item) => isRecord(item) && isJsonRpcId(item.id));
return isRecord(first) && isJsonRpcId(first.id) ? first.id : null;
}
} catch {
// The upstream MCP endpoint will return the parse error for valid JSON-RPC framing.
}
return null;
}
function isJsonRpcId(value: unknown): value is null | number | string {
return value === null || typeof value === "number" || typeof value === "string";
}
function jsonRpcError(id: null | number | string, code: number, message: string): Record<string, unknown> {
return {
error: {
code,
message
},
id,
jsonrpc: "2.0"
};
}
function writeFrame(payload: Record<string, unknown>): void {
writeRawFrame(JSON.stringify(payload));
}
function writeRawFrame(body: string): void {
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
}
function clampInteger(value: number, min: number, max: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.trunc(value)));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.name === "AbortError" ? "In-app browser MCP proxy request timed out." : error.message;
}
return String(error);
}

View file

@ -90,6 +90,10 @@ const visionTool = {
const webSearchTool = {
description: "Search the web with this Fusion profile's configured search provider.",
inputSchema: objectSchema({
allowedDomains: { items: { type: "string" }, type: "array" },
allowed_domains: { items: { type: "string" }, type: "array" },
blockedDomains: { items: { type: "string" }, type: "array" },
blocked_domains: { items: { type: "string" }, type: "array" },
count: { maximum: 20, minimum: 1, type: "number" },
country: { type: "string" },
excludeDomains: { items: { type: "string" }, type: "array" },
@ -280,9 +284,17 @@ async function analyzeWebSearch(args: Record<string, unknown>): Promise<string>
const input = {
count,
country: readString(args.country),
excludeDomains: readStringArray(args.excludeDomains),
excludeDomains: uniqueStrings([
...readStringArray(args.excludeDomains),
...readStringArray(args.blockedDomains),
...readStringArray(args.blocked_domains)
]),
freshness: readString(args.freshness),
includeDomains: readStringArray(args.includeDomains),
includeDomains: uniqueStrings([
...readStringArray(args.includeDomains),
...readStringArray(args.allowedDomains),
...readStringArray(args.allowed_domains)
]),
includeRaw: args.includeRaw === true,
language: readString(args.language),
prompt,
@ -651,12 +663,19 @@ function isSearchResult(value: SearchResult): value is Required<Pick<SearchResul
}
function readStringArray(value: unknown): string[] {
if (typeof value === "string") {
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
if (!Array.isArray(value)) {
return [];
}
return value.map(readString).filter((item): item is string => Boolean(item));
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function textResult(text: string): ToolCallResult {
return {
content: [{ text, type: "text" }]

View file

@ -675,6 +675,7 @@ export type VirtualModelFusionVisionConfig = {
};
export type VirtualModelFusionWebSearchProvider =
| "browser"
| "brave"
| "bing"
| "google_cse"

View file

@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { codexCliMiddlewareRuntimeScript } from "../../src/main/codex-cli-middleware-runtime.ts";
test("generated Codex CLI middleware runtime is valid JavaScript", () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-check-"));
const file = path.join(dir, "ccr-codex-cli-middleware.js");
writeFileSync(file, codexCliMiddlewareRuntimeScript());
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
});
test("Claude Code wrapper injects the scoped profile model into real CLI args", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-wrapper-"));
const runtimeFile = writeRuntimeScript(dir);
const { fakeCli, outputFile } = writeFakeClaudeCli(dir);
execFileSync(process.execPath, [runtimeFile, "-p", "hi"], {
env: {
...process.env,
ANTHROPIC_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_WRAPPER: "1",
CCR_FAKE_CLAUDE_OUT: outputFile,
CCR_REAL_CLAUDE_CODE_BIN: fakeCli,
CCR_REMOTE_SYNC_ENABLED: "0"
},
stdio: "pipe"
});
const observed = JSON.parse(readFileSync(outputFile, "utf8"));
assert.deepEqual(observed.argv, ["--model", "Fusion/kimisearch", "-p", "hi"]);
assert.equal(observed.env.ANTHROPIC_MODEL, "Fusion/kimisearch");
assert.equal(observed.env.CCR_CLAUDE_CODE_MODEL, "Fusion/kimisearch");
});
test("Claude Code wrapper does not duplicate an explicit model argument", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-wrapper-"));
const runtimeFile = writeRuntimeScript(dir);
const { fakeCli, outputFile } = writeFakeClaudeCli(dir);
execFileSync(process.execPath, [runtimeFile, "--model", "Provider/manual", "-p", "hi"], {
env: {
...process.env,
ANTHROPIC_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_WRAPPER: "1",
CCR_FAKE_CLAUDE_OUT: outputFile,
CCR_REAL_CLAUDE_CODE_BIN: fakeCli,
CCR_REMOTE_SYNC_ENABLED: "0"
},
stdio: "pipe"
});
const observed = JSON.parse(readFileSync(outputFile, "utf8"));
assert.deepEqual(observed.argv, ["--model", "Provider/manual", "-p", "hi"]);
});
function writeRuntimeScript(dir) {
const file = path.join(dir, "ccr-codex-cli-middleware.js");
writeFileSync(file, codexCliMiddlewareRuntimeScript());
chmodSync(file, 0o700);
return file;
}
function writeFakeClaudeCli(dir) {
const fakeCli = path.join(dir, "fake-claude");
const outputFile = path.join(dir, "fake-claude-output.json");
writeFileSync(fakeCli, [
"#!/usr/bin/env node",
"const fs = require('node:fs');",
"fs.writeFileSync(process.env.CCR_FAKE_CLAUDE_OUT, JSON.stringify({",
" argv: process.argv.slice(2),",
" env: {",
" ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL || '',",
" CCR_CLAUDE_CODE_MODEL: process.env.CCR_CLAUDE_CODE_MODEL || ''",
" }",
"}));",
""
].join("\n"));
chmodSync(fakeCli, 0o700);
return { fakeCli, outputFile };
}

View file

@ -193,7 +193,7 @@ test("codex catalog marks prefixed Fusion virtual models with legacy web search
key: "web-prefix",
match: { exactAliases: [], prefixes: ["web-"], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
tools: [{ name: "web_search", visibility: "internal" }]
tools: [{ name: "web_prefix_web_search", visibility: "internal" }]
}
]
}, "deepseek/web-deepseek-chat");

View file

@ -1,6 +1,25 @@
import assert from "node:assert/strict";
import { PassThrough, Readable } from "node:stream";
import test from "node:test";
import { normalizeCoreGatewayVirtualModelProfiles } from "../../src/server/gateway/service.ts";
import {
fusionFallbackToolDefinitions,
fusionWebSearchToolNameForRequest,
fusionToolNamesBackedByMcpServers,
extractHostedWebSearchQueryHint,
hostedWebSearchProtocolResponseStream,
prepareAnthropicWebSearchProtocolRequestBody,
prepareClaudeCodeWebSearchContinuationRequestBody,
prepareHostedWebSearchProtocolRequestBody,
transformAnthropicWebSearchProtocolResponseValue,
transformAnthropicWebSearchProtocolSseText,
transformGeminiHostedWebSearchResponseValue,
transformGeminiHostedWebSearchSseText,
transformOpenAiChatHostedWebSearchResponseValue,
transformOpenAiChatHostedWebSearchSseText,
transformOpenAiResponsesHostedWebSearchResponseValue,
transformOpenAiResponsesHostedWebSearchSseText,
normalizeCoreGatewayVirtualModelProfiles
} from "../../src/server/gateway/service.ts";
test("gateway config rewrites Fusion fixed base and vision models to core provider selectors", () => {
const providerName = "Zhipu AI (China) - Coding Plan";
@ -59,3 +78,930 @@ test("gateway config rewrites Fusion fixed base and vision models to core provid
);
assert.equal(profiles[0].baseModel.fixedModel, `${providerName}/glm-5.2`);
});
test("gateway config normalizes Fusion web search tool names for native Anthropic search triggers", () => {
const profiles = [
{
displayName: "Kimi Search",
enabled: true,
execution: {
clientToolsPolicy: "allow",
matchWebSearch: true,
maxToolCalls: 8,
maxTurns: 6,
mode: "tool_loop",
streamMode: "optimistic"
},
id: "kimisearch",
key: "kimisearch",
match: { exactAliases: ["kimisearch"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
metadata: {
fusionWebSearch: { provider: "browser", toolName: "web_search_kimisearch" }
},
tools: [{ name: "web_search_kimisearch", visibility: "internal" }]
}
];
const [profile] = normalizeCoreGatewayVirtualModelProfiles(profiles, {
Providers: [],
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
gateway: {}
});
assert.equal(profile.metadata.fusionWebSearch.toolName, "kimisearch_web_search");
assert.deepEqual(profile.tools.map((tool) => tool.name), ["kimisearch_web_search"]);
assert.match(profile.instructions.append, /call the kimisearch_web_search function tool before answering/);
});
test("gateway resolves normalized Fusion web search tool names for Anthropic protocol bridging", () => {
const config = {
Providers: [],
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
gateway: {},
virtualModelProfiles: [
{
displayName: "Kimisearch",
enabled: true,
execution: {
clientToolsPolicy: "allow",
matchWebSearch: true,
maxToolCalls: 8,
maxTurns: 6,
mode: "tool_loop",
streamMode: "optimistic"
},
id: "fusion-2",
key: "kimisearch",
match: { exactAliases: ["kimisearch", "Fusion/kimisearch"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
metadata: {
fusionWebSearch: { provider: "browser", toolName: "web_search_fusion_2" }
},
tools: [{ name: "web_search_fusion_2", visibility: "internal" }]
}
]
};
assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search");
});
test("gateway does not route hosted web search through an unrelated Fusion search profile", () => {
const config = {
Providers: [
{
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
credentials: [{ apiKey: "test-key", id: "test-1" }],
models: ["glm-5.2", "glm-5v-turbo"],
name: "Zhipu AI (China) - Coding Plan",
type: "openai_chat_completions"
},
{
baseUrl: "https://api.moonshot.cn/anthropic",
models: ["kimi-for-coding"],
name: "Kimi Code - Coding Plan",
type: "openai_chat_completions"
}
],
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
gateway: {},
virtualModelProfiles: [
{
baseModel: { fixedModel: "Zhipu AI (China) - Coding Plan/glm-5.2", mode: "fixed" },
displayName: "GLM 5 2V",
enabled: true,
execution: {
clientToolsPolicy: "deny",
matchMultimodal: true,
matchWebSearch: false,
maxToolCalls: 8,
maxTurns: 6,
mode: "tool_loop",
streamMode: "buffered"
},
id: "glm-5.2v",
key: "glm-5.2v",
match: { exactAliases: ["GLM-5.2V"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
metadata: {
fusionVision: { modelSelector: "Zhipu AI (China) - Coding Plan/glm-5v-turbo", toolName: "vision_understand_glm_5_2v" }
},
tools: [{ name: "vision_understand_glm_5_2v", visibility: "internal" }]
},
{
baseModel: { fixedModel: "Kimi Code - Coding Plan/kimi-for-coding", mode: "fixed" },
displayName: "Kimisearch",
enabled: true,
execution: {
clientToolsPolicy: "allow",
matchWebSearch: true,
maxToolCalls: 8,
maxTurns: 6,
mode: "tool_loop",
streamMode: "optimistic"
},
id: "fusion-2",
key: "kimisearch",
match: { exactAliases: ["kimisearch"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
metadata: {
fusionWebSearch: { provider: "browser", toolName: "web_search_fusion_2" }
},
tools: [{ name: "web_search_fusion_2", visibility: "internal" }]
}
]
};
assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/GLM-5.2V"), undefined);
assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search");
});
test("gateway resolves only browser-backed Fusion web search tools for hosted protocol bridging", () => {
const config = {
Providers: [],
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
gateway: {},
virtualModelProfiles: [
{
displayName: "Research",
enabled: true,
execution: {
clientToolsPolicy: "allow",
matchWebSearch: true,
maxToolCalls: 8,
maxTurns: 6,
mode: "tool_loop",
streamMode: "optimistic"
},
id: "research",
key: "research",
match: { exactAliases: ["research", "Fusion/research"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
metadata: {
fusionWebSearch: { provider: "brave", toolName: "research_web_search" }
},
tools: [{ name: "research_web_search", visibility: "internal" }]
}
]
};
assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), undefined);
assert.equal(fusionWebSearchToolNameForRequest(config, "gpt-5"), undefined);
});
test("gateway config does not create fallback tools for MCP-backed Fusion tools", () => {
const profiles = [
{
enabled: true,
tools: [
{ description: "Browser search", name: "fusion_2_web_search", visibility: "internal" },
{ description: "Vision", name: "vision_understand_glm_5_2v", visibility: "internal" },
{ description: "Missing", name: "missing_fusion_tool", visibility: "internal" }
]
}
];
const servers = [
{ name: "fusion_2_web_search" },
{ env: { FUSION_TOOL_NAME: "vision_understand_glm_5_2v" }, name: "fusion-vision-glm-5.2v" }
];
const definitions = fusionFallbackToolDefinitions(profiles, fusionToolNamesBackedByMcpServers(servers));
assert.deepEqual(definitions.map((definition) => definition.name), ["missing_fusion_tool"]);
});
test("gateway response injects Anthropic web search protocol blocks into JSON responses", () => {
const response = {
content: [
{ thinking: "searched", type: "thinking" },
{ text: "answer", type: "text" }
],
id: "msg_1",
role: "assistant",
stop_reason: "tool_use",
type: "message",
usage: { server_tool_use: { web_search_requests: 1 } }
};
const transformed = transformAnthropicWebSearchProtocolResponseValue(response, [sampleSearchRecord()], "req-1");
assert.equal(transformed.changed, true);
assert.deepEqual(
transformed.value.content.map((block) => block.type),
["thinking", "server_tool_use", "web_search_tool_result", "text"]
);
assert.equal(transformed.value.content[1].name, "web_search");
assert.equal(transformed.value.content[2].content[0].type, "web_search_result");
assert.equal(transformed.value.content[2].content[0].snippet, "Search snippet: Spot gold traded near $3,340 per ounce.");
assert.equal(transformed.value.stop_reason, "end_turn");
});
test("gateway preserves Anthropic tool_use stop reason when client tools remain", () => {
const response = {
content: [
{ thinking: "searched", type: "thinking" },
{ id: "toolu_1", input: { command: "pwd" }, name: "Bash", type: "tool_use" }
],
id: "msg_1",
role: "assistant",
stop_reason: "tool_use",
type: "message",
usage: {}
};
const transformed = transformAnthropicWebSearchProtocolResponseValue(response, [sampleSearchRecord()], "req-1");
assert.equal(transformed.changed, true);
assert.deepEqual(
transformed.value.content.map((block) => block.type),
["thinking", "server_tool_use", "web_search_tool_result", "tool_use"]
);
assert.equal(transformed.value.stop_reason, "tool_use");
});
test("gateway injects prefetched web search evidence into Anthropic requests", () => {
const body = Buffer.from(JSON.stringify({
messages: [{ role: "user", content: [{ type: "text", text: "北京天气怎么样" }] }],
model: "Fusion/kimisearch",
output_config: { effort: "high" },
system: [{ type: "text", text: "Answer in Chinese." }],
thinking: { type: "enabled" },
tools: [{ name: "web_search", type: "web_search_20250305" }]
}));
const record = sampleSearchRecord();
record.results[0].content = "北京市当前天气晴,气温 28 摄氏度,空气质量良。";
const transformed = prepareAnthropicWebSearchProtocolRequestBody(body, [record], { queryHint: "北京天气怎么样" });
const parsed = JSON.parse(transformed.toString("utf8"));
assert.match(parsed.system[1].text, /Use the evidence below to answer the user's question directly/);
assert.equal(parsed.tools, undefined);
assert.equal(parsed.tool_choice, undefined);
assert.equal(parsed.output_config.effort, "low");
assert.equal(parsed.thinking, undefined);
assert.match(parsed.system[1].text, /北京市当前天气晴/);
});
test("gateway forces Claude Code WebSearch continuations to answer without tools", () => {
const body = Buffer.from(JSON.stringify({
messages: [
{ role: "user", content: [{ type: "text", text: "搜索 shadcn官方有哪些New Components" }] },
{
role: "assistant",
content: [
{
id: "tool_search_1",
input: { query: "shadcn UI new components 2025 2026 official" },
name: "WebSearch",
type: "tool_use"
}
]
},
{
role: "user",
content: [
{
content: "Web search results for query: \"shadcn UI new components 2025 2026 official\"\n\nLinks: [{\"title\":\"Changelog - Shadcn UI\",\"url\":\"https://ui.shadcn.com/docs/changelog\"}]\n\nThe official shadcn/ui changelog lists June 2026 chat interface components. REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.",
tool_use_id: "tool_search_1",
type: "tool_result"
}
]
}
],
model: "Fusion/kimisearch",
output_config: { effort: "high" },
system: [{ type: "text", text: "You are Claude Code." }],
thinking: { type: "enabled" },
tools: [
{ name: "WebFetch", input_schema: { type: "object" } },
{ name: "WebSearch", input_schema: { type: "object" } },
{ name: "Bash", input_schema: { type: "object" } }
]
}));
const transformed = prepareClaudeCodeWebSearchContinuationRequestBody(
body,
[sampleSearchRecord()],
{ queryHint: "shadcn UI new components 2025 2026 official" }
);
assert.ok(transformed);
const parsed = JSON.parse(transformed.toString("utf8"));
assert.equal(parsed.tools, undefined);
assert.equal(parsed.tool_choice, undefined);
assert.equal(parsed.output_config.effort, "low");
assert.equal(parsed.thinking, undefined);
assert.match(parsed.system.at(-1).text, /Do not call any tool/);
assert.match(parsed.system.at(-1).text, /In-app browser extracted evidence/);
assert.match(parsed.system.at(-1).text, /Previous WebSearch tool result/);
assert.match(parsed.system.at(-1).text, /Spot gold traded near \$3,340 per ounce/);
});
test("gateway ignores stale Claude Code WebSearch results on later turns", () => {
const body = Buffer.from(JSON.stringify({
messages: [
{ role: "user", content: [{ type: "text", text: "搜索 shadcn官方有哪些New Components" }] },
{
role: "assistant",
content: [
{
id: "tool_search_1",
input: { query: "shadcn UI new components 2025 2026 official" },
name: "WebSearch",
type: "tool_use"
}
]
},
{
role: "user",
content: [
{
content: "Web search results for query: \"shadcn UI new components 2025 2026 official\"\n\nLinks: []",
tool_use_id: "tool_search_1",
type: "tool_result"
}
]
},
{ role: "assistant", content: [{ type: "text", text: "The changelog has new chat components." }] },
{ role: "user", content: [{ type: "text", text: "Now inspect package.json with Bash." }] }
],
model: "Fusion/kimisearch",
system: [{ type: "text", text: "You are Claude Code." }],
tools: [
{ name: "WebFetch", input_schema: { type: "object" } },
{ name: "WebSearch", input_schema: { type: "object" } },
{ name: "Bash", input_schema: { type: "object" } }
]
}));
const transformed = prepareClaudeCodeWebSearchContinuationRequestBody(
body,
[sampleSearchRecord()],
{ queryHint: undefined }
);
assert.equal(transformed, undefined);
});
test("gateway synthesizes final Anthropic text when web search response has no visible answer", () => {
const response = {
content: [
{ thinking: "searched but did not answer", type: "thinking" }
],
id: "msg_1",
role: "assistant",
stop_reason: "max_tokens",
type: "message",
usage: { output_tokens: 0 }
};
const transformed = transformAnthropicWebSearchProtocolResponseValue(
response,
[sampleSearchRecord()],
"req-1",
"today gold price per ounce USD July 2026"
);
assert.equal(transformed.changed, true);
assert.deepEqual(
transformed.value.content.map((block) => block.type),
["thinking", "server_tool_use", "web_search_tool_result", "text"]
);
assert.match(transformed.value.content[3].text, /Spot gold traded near \$3,340 per ounce/);
assert.equal(transformed.value.usage.server_tool_use.web_search_requests, 1);
assert.equal(transformed.value.stop_reason, "end_turn");
});
test("gateway synthesizes useful component changelog answers from extracted pages", () => {
const response = {
content: [
{ thinking: "searched but did not answer", type: "thinking" }
],
id: "msg_1",
role: "assistant",
stop_reason: "max_tokens",
type: "message",
usage: { output_tokens: 0 }
};
const transformed = transformAnthropicWebSearchProtocolResponseValue(
response,
[sampleShadcnSearchRecord()],
"req-1",
"shadcn ui new components 2025 2026 official registry"
);
assert.equal(transformed.changed, true);
assert.match(transformed.value.content[2].content[0].snippet, /Extracted page content:/);
assert.match(transformed.value.content[3].text, /June 2026 - Components for Chat Interfaces/);
assert.match(transformed.value.content[3].text, /Message Scroller/);
assert.match(transformed.value.content[3].text, /Attachment/);
assert.doesNotMatch(transformed.value.content[3].text, /Morning, shadcn/);
});
test("gateway response injects Anthropic web search protocol blocks into SSE responses", () => {
const sse = [
sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }),
sseEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }),
sseEvent({ type: "content_block_stop", index: 0 }),
sseEvent({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }),
sseEvent({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }),
sseEvent({ type: "content_block_stop", index: 1 }),
sseEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { server_tool_use: { web_search_requests: 1 } } }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n";
const transformed = transformAnthropicWebSearchProtocolSseText(sse, [sampleSearchRecord()], "req-1");
assert.match(transformed, /"type":"server_tool_use"/);
assert.match(transformed, /"type":"web_search_tool_result"/);
assert.match(transformed, /"type":"web_search_result"/);
assert.match(transformed, /"index":3,"content_block":\{"type":"text"/);
assert.match(transformed, /"server_tool_use":\{"web_search_requests":1\}/);
assert.match(transformed, /"stop_reason":"end_turn"/);
});
test("gateway preserves Anthropic SSE tool_use stop reason when client tools remain", () => {
const sse = [
sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }),
sseEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }),
sseEvent({ type: "content_block_stop", index: 0 }),
sseEvent({ type: "content_block_start", index: 1, content_block: { id: "toolu_1", input: { command: "pwd" }, name: "Bash", type: "tool_use" } }),
sseEvent({ type: "content_block_stop", index: 1 }),
sseEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n";
const transformed = transformAnthropicWebSearchProtocolSseText(sse, [sampleSearchRecord()], "req-1");
assert.match(transformed, /"type":"server_tool_use"/);
assert.match(transformed, /"type":"tool_use"/);
assert.match(transformed, /"stop_reason":"tool_use"/);
assert.doesNotMatch(transformed, /"stop_reason":"end_turn"/);
});
test("gateway hosted web search response stream transforms Anthropic SSE responses", async () => {
const sse = [
sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }),
sseEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }),
sseEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "reasoning" } }),
sseEvent({ type: "content_block_stop", index: 0 }),
sseEvent({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }),
sseEvent({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }),
sseEvent({ type: "content_block_stop", index: 1 }),
sseEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n";
const stream = hostedWebSearchProtocolResponseStream(
Readable.from([Buffer.from(sse, "utf8")]),
new Headers({ "content-type": "text/event-stream; charset=utf-8" }),
{
protocol: "anthropic_messages",
queryHint: "today gold price per ounce USD July 2026",
records: [sampleSearchRecord()],
requestId: "req-1",
sinceMs: Date.now() - 1000,
toolName: "fusion_2_web_search"
},
{
recentBrowserWebSearchResults: () => [],
stopBrowserWebSearchMcpServers: async () => {}
}
);
const transformed = await readStreamText(stream);
assert.match(transformed, /"type":"server_tool_use"/);
assert.match(transformed, /"type":"web_search_tool_result"/);
assert.match(transformed, /"type":"web_search_result"/);
assert.match(transformed, /"server_tool_use":\{"web_search_requests":1\}/);
assert.match(transformed, /"stop_reason":"end_turn"/);
});
test("gateway hosted web search Anthropic SSE stream emits before upstream ends", async () => {
const input = new PassThrough();
const stream = hostedWebSearchProtocolResponseStream(
input,
new Headers({ "content-type": "text/event-stream; charset=utf-8" }),
{
protocol: "anthropic_messages",
queryHint: "today gold price per ounce USD July 2026",
records: [sampleSearchRecord()],
requestId: "req-1",
sinceMs: Date.now() - 1000,
toolName: "fusion_2_web_search"
},
{
recentBrowserWebSearchResults: () => [],
stopBrowserWebSearchMcpServers: async () => {}
}
);
const injectedData = waitForStreamDataMatching(stream, /"type":"server_tool_use"/, 500);
input.write([
sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }),
sseEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })
].join("\n\n") + "\n\n");
const chunk = await injectedData;
assert.ok(chunk);
assert.match(chunk.toString("utf8"), /"type":"server_tool_use"/);
input.end([
sseEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "answer" } }),
sseEvent({ type: "content_block_stop", index: 0 }),
sseEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n");
const rest = await readStreamText(stream);
assert.match(`${chunk.toString("utf8")}${rest}`, /"stop_reason":"end_turn"/);
});
test("gateway synthesizes final Anthropic SSE text when model exhausts tokens in thinking", () => {
const sse = [
sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }),
sseEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }),
sseEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "long reasoning" } }),
sseEvent({ type: "content_block_stop", index: 0 }),
sseEvent({ type: "message_delta", delta: { stop_reason: "max_tokens" }, usage: { output_tokens: 0 } }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n";
const transformed = transformAnthropicWebSearchProtocolSseText(
sse,
[sampleSearchRecord()],
"req-1",
"today gold price per ounce USD July 2026"
);
assert.match(transformed, /"type":"server_tool_use"/);
assert.match(transformed, /"type":"web_search_tool_result"/);
assert.match(transformed, /"delta":\{"text":"Based on the search results, Spot gold traded near \$3,340 per ounce/);
assert.match(transformed, /"stop_reason":"end_turn"/);
assert.match(transformed, /"server_tool_use":\{"web_search_requests":1\}/);
});
test("gateway injects prefetched web search evidence into OpenAI chat requests", () => {
const body = Buffer.from(JSON.stringify({
messages: [{ role: "user", content: "Perform a web search for the query: today gold price per ounce USD July 2026" }],
model: "Fusion/kimisearch",
tool_choice: "auto",
tools: [{ type: "web_search_preview" }],
web_search_options: { search_context_size: "low" }
}));
const transformed = prepareHostedWebSearchProtocolRequestBody(body, [sampleSearchRecord()], {
protocol: "openai_chat_completions",
queryHint: "today gold price per ounce USD July 2026"
});
const parsed = JSON.parse(transformed.toString("utf8"));
assert.equal(parsed.tools, undefined);
assert.equal(parsed.tool_choice, undefined);
assert.equal(parsed.web_search_options, undefined);
assert.equal(parsed.messages[0].role, "system");
assert.match(parsed.messages[0].content, /hidden in-app browser web search/);
});
test("gateway hosted web search rewrites preserve custom web_search-named tools", () => {
const anthropicBody = Buffer.from(JSON.stringify({
messages: [{ role: "user", content: "search docs" }],
model: "Fusion/kimisearch",
tool_choice: { name: "web_search_docs", type: "tool" },
tools: [
{ input_schema: { type: "object" }, name: "web_search_docs" },
{ name: "web_search", type: "web_search_20250305" }
]
}));
const anthropicParsed = JSON.parse(prepareAnthropicWebSearchProtocolRequestBody(
anthropicBody,
[sampleSearchRecord()],
{ queryHint: "search docs" }
).toString("utf8"));
assert.deepEqual(anthropicParsed.tools.map((tool) => tool.name), ["web_search_docs"]);
assert.equal(anthropicParsed.tool_choice.name, "web_search_docs");
const openAiBody = Buffer.from(JSON.stringify({
messages: [{ role: "user", content: "search docs" }],
model: "Fusion/kimisearch",
parallel_tool_calls: true,
tool_choice: { function: { name: "web_search_docs" }, type: "function" },
tools: [
{ function: { name: "web_search_docs", parameters: { type: "object" } }, type: "function" },
{ type: "web_search_preview" }
],
web_search_options: { search_context_size: "low" }
}));
const openAiParsed = JSON.parse(prepareHostedWebSearchProtocolRequestBody(
openAiBody,
[sampleSearchRecord()],
{ protocol: "openai_chat_completions", queryHint: "search docs" }
).toString("utf8"));
assert.deepEqual(openAiParsed.tools.map((tool) => tool.function.name), ["web_search_docs"]);
assert.equal(openAiParsed.tool_choice.function.name, "web_search_docs");
assert.equal(openAiParsed.parallel_tool_calls, true);
const geminiBody = Buffer.from(JSON.stringify({
contents: [{ role: "user", parts: [{ text: "search docs" }] }],
tools: [
{
functionDeclarations: [{ name: "web_search_docs", parameters: { type: "object" } }],
google_search: {}
}
]
}));
const geminiParsed = JSON.parse(prepareHostedWebSearchProtocolRequestBody(
geminiBody,
[sampleSearchRecord()],
{ protocol: "gemini_generate_content", queryHint: "search docs" }
).toString("utf8"));
assert.deepEqual(geminiParsed.tools[0].functionDeclarations.map((declaration) => declaration.name), ["web_search_docs"]);
assert.equal(geminiParsed.tools[0].google_search, undefined);
});
test("gateway synthesizes OpenAI chat final text for hosted web search fallback", () => {
const response = {
choices: [
{ finish_reason: "length", index: 0, message: { role: "assistant", content: "" } }
],
id: "chatcmpl_1",
object: "chat.completion"
};
const transformed = transformOpenAiChatHostedWebSearchResponseValue(
response,
[sampleSearchRecord()],
"today gold price per ounce USD July 2026"
);
assert.equal(transformed.changed, true);
assert.match(transformed.value.choices[0].message.content, /Spot gold traded near \$3,340 per ounce/);
assert.equal(transformed.value.choices[0].finish_reason, "stop");
});
test("gateway synthesizes OpenAI chat SSE final text for hosted web search fallback", () => {
const sse = [
openAiSseEvent({ id: "chatcmpl_1", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }),
openAiSseEvent({ id: "chatcmpl_1", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "length" }] }),
"data: [DONE]"
].join("\n\n") + "\n\n";
const transformed = transformOpenAiChatHostedWebSearchSseText(
sse,
[sampleSearchRecord()],
"today gold price per ounce USD July 2026"
);
assert.match(transformed, /"delta":\{"content":"Based on the search results, Spot gold traded near \$3,340 per ounce/);
assert.match(transformed, /"finish_reason":"stop"/);
});
test("gateway injects prefetched web search evidence into OpenAI responses requests", () => {
const body = Buffer.from(JSON.stringify({
input: "Perform a web search for the query: today gold price per ounce USD July 2026",
model: "Fusion/kimisearch",
tools: [{ type: "web_search_preview" }],
tool_choice: "auto"
}));
const transformed = prepareHostedWebSearchProtocolRequestBody(body, [sampleSearchRecord()], {
protocol: "openai_responses",
queryHint: "today gold price per ounce USD July 2026"
});
const parsed = JSON.parse(transformed.toString("utf8"));
assert.equal(parsed.tools, undefined);
assert.equal(parsed.tool_choice, undefined);
assert.match(parsed.instructions, /hidden in-app browser web search/);
});
test("gateway extracts OpenAI responses web search query after Codex runtime context", () => {
const body = {
input: [
{
type: "message",
role: "developer",
content: [{ type: "input_text", text: "<permissions instructions>\nNetwork access is restricted.\n</permissions instructions>" }]
},
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "<environment_context>\n <cwd>/tmp/project</cwd>\n <current_date>2026-07-02</current_date>\n <filesystem></filesystem>\n</environment_context>" }]
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "之前的回答" }]
},
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "搜索今天黄金价格" }]
}
],
model: "Fusion/kimisearch",
tools: [{ type: "web_search_preview" }]
};
assert.equal(extractHostedWebSearchQueryHint(body, "openai_responses"), "今天黄金价格");
});
test("gateway synthesizes OpenAI responses output for hosted web search fallback", () => {
const response = {
id: "resp_1",
output: [],
status: "incomplete",
incomplete_details: { reason: "max_output_tokens" }
};
const transformed = transformOpenAiResponsesHostedWebSearchResponseValue(
response,
[sampleSearchRecord()],
"req-1",
"today gold price per ounce USD July 2026"
);
assert.equal(transformed.changed, true);
assert.deepEqual(transformed.value.output.map((item) => item.type), ["web_search_call", "message"]);
assert.match(transformed.value.output[1].content[0].text, /Spot gold traded near \$3,340 per ounce/);
assert.equal(transformed.value.status, "completed");
});
test("gateway synthesizes OpenAI responses SSE output for hosted web search fallback", () => {
const sse = [
openAiSseEvent({ type: "response.created", response: { id: "resp_1", status: "in_progress" } }),
openAiSseEvent({ type: "response.completed", response: { id: "resp_1", status: "incomplete", incomplete_details: { reason: "max_output_tokens" } } }),
"data: [DONE]"
].join("\n\n") + "\n\n";
const transformed = transformOpenAiResponsesHostedWebSearchSseText(
sse,
[sampleSearchRecord()],
"req-1",
"today gold price per ounce USD July 2026"
);
assert.match(transformed, /event: response.output_text.delta/);
assert.match(transformed, /"type":"response.output_text.delta"/);
assert.match(transformed, /Spot gold traded near \$3,340 per ounce/);
assert.match(transformed, /"status":"completed"/);
});
test("gateway normalizes OpenAI responses SSE with visible text for hosted web search", () => {
const answer = "杭州今天湿润有雨气温约24℃到29.3℃。";
const sse = [
openAiSseEvent({ type: "response.created", response: { id: "resp_1", status: "in_progress", output: [] } }),
openAiSseEvent({ type: "response.output_item.added", output_index: 0, item: { id: "rs_1", type: "reasoning", status: "in_progress", content: [] } }),
openAiSseEvent({ type: "response.reasoning_text.delta", output_index: 0, content_index: 0, item_id: "rs_1", delta: "hidden reasoning" }),
openAiSseEvent({ type: "response.output_item.added", output_index: 1, item: { id: "msg_1", type: "message", role: "assistant", status: "in_progress", content: [] } }),
openAiSseEvent({ type: "response.content_part.added", output_index: 1, content_index: 0, item_id: "msg_1", part: { type: "output_text", text: "", annotations: [] } }),
openAiSseEvent({ type: "response.output_text.delta", output_index: 1, content_index: 0, item_id: "msg_1", delta: answer }),
openAiSseEvent({ type: "response.output_text.done", output_index: 1, content_index: 0, item_id: "msg_1", text: answer }),
openAiSseEvent({ type: "response.output_item.done", output_index: 0, item: { id: "rs_1", type: "reasoning", status: "completed", content: [{ type: "reasoning_text", text: "hidden reasoning" }] } }),
openAiSseEvent({ type: "response.output_item.done", output_index: 1, item: { id: "msg_1", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: answer, annotations: [] }] } }),
openAiSseEvent({
type: "response.completed",
response: {
id: "resp_1",
status: "completed",
output_text: answer,
output: [
{ id: "rs_1", type: "reasoning", status: "completed", content: [{ type: "reasoning_text", text: "hidden reasoning" }] },
{ id: "msg_1", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: answer, annotations: [] }] }
]
}
}),
"data: [DONE]"
].join("\n\n") + "\n\n";
const transformed = transformOpenAiResponsesHostedWebSearchSseText(
sse,
[sampleSearchRecord()],
"req-1",
"杭州天气怎么样"
);
assert.match(transformed, /event: response.output_text.delta/);
assert.match(transformed, /"output_index":0/);
assert.match(transformed, /"output_text":"杭州今天湿润有雨/);
assert.doesNotMatch(transformed, /"output_index":1/);
assert.doesNotMatch(transformed, /response.reasoning_text.delta/);
assert.doesNotMatch(transformed, /"type":"reasoning"/);
});
test("gateway injects prefetched web search evidence into Gemini requests", () => {
const body = Buffer.from(JSON.stringify({
contents: [{ role: "user", parts: [{ text: "Perform a web search for the query: today gold price per ounce USD July 2026" }] }],
tools: [{ google_search: {} }]
}));
const transformed = prepareHostedWebSearchProtocolRequestBody(body, [sampleSearchRecord()], {
protocol: "gemini_generate_content",
queryHint: "today gold price per ounce USD July 2026"
});
const parsed = JSON.parse(transformed.toString("utf8"));
assert.equal(parsed.tools, undefined);
assert.match(parsed.systemInstruction.parts[0].text, /hidden in-app browser web search/);
});
test("gateway synthesizes Gemini response text for hosted web search fallback", () => {
const response = { candidates: [{ content: { parts: [], role: "model" }, finishReason: "MAX_TOKENS", index: 0 }] };
const transformed = transformGeminiHostedWebSearchResponseValue(
response,
[sampleSearchRecord()],
"today gold price per ounce USD July 2026"
);
assert.equal(transformed.changed, true);
assert.match(transformed.value.candidates[0].content.parts[0].text, /Spot gold traded near \$3,340 per ounce/);
assert.equal(transformed.value.candidates[0].finishReason, "STOP");
});
test("gateway synthesizes Gemini SSE text for hosted web search fallback", () => {
const sse = [
openAiSseEvent({ candidates: [{ content: { parts: [], role: "model" }, finishReason: "MAX_TOKENS", index: 0 }] }),
"data: [DONE]"
].join("\n\n") + "\n\n";
const transformed = transformGeminiHostedWebSearchSseText(
sse,
[sampleSearchRecord()],
"today gold price per ounce USD July 2026"
);
assert.match(transformed, /"candidates":\[\{"content":\{"parts":\[\{"text":"Based on the search results, Spot gold traded near \$3,340 per ounce/);
});
function sampleSearchRecord() {
return {
completedAtMs: Date.now(),
engine: "google",
query: "today gold price per ounce USD July 2026",
results: [
{
snippet: "Spot gold traded near $3,340 per ounce.",
title: "Gold Price Today",
url: "https://example.test/gold"
}
],
searchUrl: "https://www.google.com/search?q=gold",
toolName: "fusion_2_web_search"
};
}
function sampleShadcnSearchRecord() {
return {
completedAtMs: Date.now(),
engine: "google",
query: "shadcn ui new components 2025 2026 official registry",
results: [
{
content: "Sections Introduction Components Attachment Avatar Badge Bubble Button Button Group Empty Field Input Input Group Input OTP Item Marker Message Message Scroller Native Select Changelog RSS Latest updates and announcements. June 2026 - Components for Chat Interfaces New Chat How can I help you today? Morning, shadcn! What are we working on today?",
title: "Changelog - Shadcn UI",
url: "https://ui.shadcn.com/docs/changelog"
},
{
snippet: "YouTube · Web Dev Simplified 26.3K+ views · 11 months ago",
title: "How I Built My Own Shadcn Library",
url: "https://www.youtube.com/watch?v=example"
}
],
searchUrl: "https://www.google.com/search?q=shadcn",
toolName: "fusion_2_web_search"
};
}
function sseEvent(value) {
return `event: ${value.type}\ndata: ${JSON.stringify(value)}`;
}
function openAiSseEvent(value) {
return `data: ${JSON.stringify(value)}`;
}
async function readStreamText(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
function waitForStreamDataMatching(stream, pattern, timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
cleanup();
resolve(null);
}, timeoutMs);
const onData = (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (!pattern.test(buffer.toString("utf8"))) {
return;
}
cleanup();
resolve(buffer);
};
const cleanup = () => {
clearTimeout(timer);
stream.off("data", onData);
};
stream.on("data", onData);
});
}

View file

@ -20,6 +20,7 @@ const claudeProfile = {
model: "provider,model",
name: "Claude Main",
scope: "ccr",
smallFastModel: "provider,small",
surface: "auto"
};
@ -82,6 +83,10 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => {
assert.equal(path.basename(claudePlan.command), process.platform === "win32" ? "ccr-claude-code-wrapper-claude-main.cmd" : "ccr-claude-code-wrapper-claude-main");
assert.equal(claudePlan.env.CCR_PROFILE_SURFACE, "cli");
assert.match(claudePlan.env.CLAUDE_CONFIG_DIR, /claude$/);
assert.equal(claudePlan.env.ANTHROPIC_MODEL, "provider/model");
assert.equal(claudePlan.env.CCR_CLAUDE_CODE_MODEL, "provider/model");
assert.equal(claudePlan.env.CODEXL_CLAUDE_CODE_MODEL, "provider/model");
assert.equal(claudePlan.env.ANTHROPIC_SMALL_FAST_MODEL, "provider/small");
assert.throws(() => buildProfileLaunchPlan(configDir, claudeProfile, "app"), /Claude App opening/);
});

View file

@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { restoreInactiveGlobalProfileConfigs } from "../../src/main/profile-service.ts";
test("profile service restores managed global Claude settings when only CCR-scoped Claude profiles are active", () => {
const previousHome = process.env.HOME;
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-profile-home-"));
process.env.HOME = home;
try {
const settingsFile = path.join(home, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
const originalSettings = {
env: {
USER_VALUE: "kept"
},
theme: "dark"
};
writeFileSync(`${settingsFile}.ccr-backup-2026-01-01T00-00-00-000Z`, `${JSON.stringify(originalSettings, null, 2)}\n`);
writeFileSync(settingsFile, `${JSON.stringify({
apiKeyHelper: "/tmp/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",
ANTHROPIC_MODEL: "Fusion/GLM-5.2V",
CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456"
}
}, null, 2)}\n`);
const statuses = restoreInactiveGlobalProfileConfigs([
{
agent: "claude-code",
enabled: true,
env: { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1" },
id: "claude-code-2",
model: "Fusion/kimisearch",
name: "Claude Code",
scope: "ccr",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
}
]);
const restored = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(statuses.length, 1);
assert.equal(statuses[0].client, "claude-code");
assert.equal(statuses[0].ok, true);
assert.equal(restored.env.USER_VALUE, "kept");
assert.equal(restored.env.ANTHROPIC_MODEL, undefined);
assert.equal(restored.env.CCR_CLAUDE_CODE_MODEL, undefined);
assert.equal(restored.env.CODEXL_CLAUDE_CODE_MODEL, undefined);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
rmSync(home, { force: true, recursive: true });
}
});
test("profile service keeps managed global Claude settings when a global Claude profile is active", () => {
const previousHome = process.env.HOME;
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-profile-home-"));
process.env.HOME = home;
try {
const settingsFile = path.join(home, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
writeFileSync(settingsFile, `${JSON.stringify({
apiKeyHelper: "/tmp/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",
ANTHROPIC_MODEL: "Fusion/GLM-5.2V",
CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456"
}
}, null, 2)}\n`);
const statuses = restoreInactiveGlobalProfileConfigs([
{
agent: "claude-code",
enabled: true,
env: {},
id: "claude-code",
model: "Fusion/GLM-5.2V",
name: "Claude Code",
scope: "global",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
}
]);
const current = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(statuses.length, 0);
assert.equal(current.env.ANTHROPIC_MODEL, "Fusion/GLM-5.2V");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
rmSync(home, { force: true, recursive: true });
}
});

View file

@ -6,7 +6,7 @@ function createRouterPlugin(options = {}) {
const agent = options.agent ?? "claude-code";
return new ClaudeCodeRouterPlugin({
CUSTOM_ROUTER_PATH: "",
Providers: [
Providers: options.providers ?? [
{
modelDescriptions: options.modelDescriptions,
modelDisplayNames: options.modelDisplayNames,
@ -35,7 +35,8 @@ function createRouterPlugin(options = {}) {
scope: "global"
}
]
}
},
virtualModelProfiles: options.virtualModelProfiles ?? []
});
}
@ -58,6 +59,37 @@ test("built-in Claude Code route matches user-agent case-insensitively", async (
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("built-in Claude Code route preserves explicit virtual gateway models", async () => {
const plugin = createRouterPlugin({
profileModel: "Provider/claude-sonnet",
virtualModelProfiles: [
{
displayName: "Kimisearch",
enabled: true,
id: "fusion-search",
key: "kimisearch",
match: { exactAliases: ["kimisearch"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true }
}
]
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "Fusion/kimisearch"
},
headers: {
"user-agent": "claude-code/1.0"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Fusion/kimisearch");
assert.equal(result.decision.model, "Fusion/kimisearch");
assert.equal(result.decision.reason, "inline-model");
});
test("built-in Codex route stays inactive when profile model is unset", async () => {
const plugin = createRouterPlugin({
agent: "codex"
@ -156,6 +188,82 @@ test("built-in Claude Code route injects subagent model instructions into Agent
}
});
test("built-in Claude Code route injects subagent model descriptions in stable order", async () => {
const providersA = [
{
modelDescriptions: {
"z-model": "Zeta model.",
"a-model": "Alpha model."
},
models: ["z-model", "a-model"],
name: "Zeta",
type: "anthropic_messages"
},
{
modelDescriptions: {
"z-model": "Z model.",
"a-model": "A model."
},
models: ["z-model", "a-model"],
name: "Alpha",
type: "anthropic_messages"
}
];
const providersB = [
{
modelDescriptions: {
"a-model": "A model.",
"z-model": "Z model."
},
models: ["a-model", "z-model"],
name: "Alpha",
type: "anthropic_messages"
},
{
modelDescriptions: {
"a-model": "Alpha model.",
"z-model": "Zeta model."
},
models: ["a-model", "z-model"],
name: "Zeta",
type: "anthropic_messages"
}
];
const routeRequest = (plugin) => plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Start a subagent.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Agent"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const pluginA = createRouterPlugin({ profileModel: "Alpha/a-model", providers: providersA });
const pluginB = createRouterPlugin({ profileModel: "Alpha/a-model", providers: providersB });
const [resultA, resultB] = await Promise.all([routeRequest(pluginA), routeRequest(pluginB)]);
const description = resultA.body.tools[0].description;
assert.equal(description, resultB.body.tools[0].description);
assert.ok(description.indexOf("- Alpha/a-model: A model.") < description.indexOf("- Alpha/z-model: Z model."));
assert.ok(description.indexOf("- Alpha/z-model: Z model.") < description.indexOf("- Zeta/a-model: Alpha model."));
assert.ok(description.indexOf("- Zeta/a-model: Alpha model.") < description.indexOf("- Zeta/z-model: Zeta model."));
});
test("built-in Claude Code route injects workflow subagent model instructions into the Workflow tool", async () => {
const plugin = createRouterPlugin({
modelDescriptions: {

View file

@ -27,7 +27,7 @@ test("Fusion draft saves multiple selected tools into one profile", () => {
assert.deepEqual(profile.tools.map((tool) => tool.name), [
"vision_understand_fusion_plus",
"web_search_fusion_plus",
"fusion_plus_web_search",
"lookup_customer"
]);
assert.equal(profile.execution.matchMultimodal, true);
@ -36,7 +36,7 @@ test("Fusion draft saves multiple selected tools into one profile", () => {
assert.equal(profile.execution.clientToolsPolicy, "allow");
assert.equal(profile.execution.streamMode, "optimistic");
assert.equal(metadataString(profile.metadata, "fusionVision", "toolName"), "vision_understand_fusion_plus");
assert.equal(metadataString(profile.metadata, "fusionWebSearch", "toolName"), "web_search_fusion_plus");
assert.equal(metadataString(profile.metadata, "fusionWebSearch", "toolName"), "fusion_plus_web_search");
assert.equal(metadataString(profile.metadata, "fusionTool", "mcpServerName"), "customer-tools");
});