From ffc6318dee6d73a567173ccecff9706e4fbffb0f Mon Sep 17 00:00:00 2001 From: musi Date: Thu, 2 Jul 2026 09:22:28 +0800 Subject: [PATCH 1/6] Revise overview dashboard docs and nav links --- .../content/docs/en/configuration/overview.md | 148 +++++++++++++----- docs/src/content/docs/en/index.md | 4 +- .../content/docs/zh/configuration/overview.md | 148 +++++++++++++----- docs/src/content/docs/zh/index.md | 4 +- docs/src/i18n/content.ts | 8 +- docs/src/pages/configuration.astro | 6 +- docs/src/pages/en/configuration.astro | 6 +- 7 files changed, 240 insertions(+), 84 deletions(-) diff --git a/docs/src/content/docs/en/configuration/overview.md b/docs/src/content/docs/en/configuration/overview.md index 0399669..9a7bf49 100644 --- a/docs/src/content/docs/en/configuration/overview.md +++ b/docs/src/content/docs/en/configuration/overview.md @@ -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. | diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md index d60d2c6..a9a08ba 100644 --- a/docs/src/content/docs/en/index.md +++ b/docs/src/content/docs/en/index.md @@ -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. diff --git a/docs/src/content/docs/zh/configuration/overview.md b/docs/src/content/docs/zh/configuration/overview.md index 27a64e9..d7b4ff9 100644 --- a/docs/src/content/docs/zh/configuration/overview.md +++ b/docs/src/content/docs/zh/configuration/overview.md @@ -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 中重试,确保卡片可见且没有被缩到过小。 | diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md index 136c57e..45caad3 100644 --- a/docs/src/content/docs/zh/index.md +++ b/docs/src/content/docs/zh/index.md @@ -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 收不到消息等常见问题。 这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index 731f868..8569685 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -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"], diff --git a/docs/src/pages/configuration.astro b/docs/src/pages/configuration.astro index 1ad9fba..5484cef 100644 --- a/docs/src/pages/configuration.astro +++ b/docs/src/pages/configuration.astro @@ -1,5 +1,5 @@ --- -const target = "/configuration/provider/"; +const target = "/configuration/overview/"; --- @@ -8,10 +8,10 @@ const target = "/configuration/provider/"; - 供应商配置 + 概览仪表盘 diff --git a/docs/src/pages/en/configuration.astro b/docs/src/pages/en/configuration.astro index 01088a9..e77c3b1 100644 --- a/docs/src/pages/en/configuration.astro +++ b/docs/src/pages/en/configuration.astro @@ -1,5 +1,5 @@ --- -const target = "/en/configuration/providers/"; +const target = "/en/configuration/overview/"; --- @@ -8,10 +8,10 @@ const target = "/en/configuration/providers/"; - Provider Config + Overview Dashboard From 1adfbbf006054886e210e7999d236adbbeedb94f Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 2 Jul 2026 11:44:13 +0800 Subject: [PATCH 2/6] Sort subagent model descriptions deterministically --- .../gateway/claude-code-router-plugin.ts | 37 +++++++-- tests/main/router-builtins.test.mjs | 78 ++++++++++++++++++- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/server/gateway/claude-code-router-plugin.ts b/src/server/gateway/claude-code-router-plugin.ts index d352db5..9f276d2 100644 --- a/src/server/gateway/claude-code-router-plugin.ts +++ b/src/server/gateway/claude-code-router-plugin.ts @@ -421,8 +421,7 @@ function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; t } function configuredSubagentModelDescriptionRows(config: AppConfig): string[] { - const rows: string[] = []; - const seen = new Set(); + 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)) { @@ -436,18 +435,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(); + 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): void { const system = body.system; if (!Array.isArray(system) || system.length === 0) { diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index 747fe76..4532313 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -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, @@ -158,6 +158,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: { From 109b83bd80405cfecf440276ec723701c5bbe5dc Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 2 Jul 2026 18:23:58 +0800 Subject: [PATCH 3/6] Refactor router and add model fallback handling --- build/esbuild.config.mjs | 3 +- src/main/codex-model-catalog.ts | 11 +- src/main/electron-web-search-mcp.ts | 1265 ++++++++++ src/main/main-app.ts | 2 + .../pages/home/components/virtual-models.tsx | 125 +- src/renderer/pages/home/shared/i18n.tsx | 25 +- src/renderer/pages/home/shared/options.ts | 4 +- .../pages/home/shared/virtual-models.ts | 7 +- src/server/gateway/service.ts | 2229 ++++++++++++++++- .../mcp/browser-web-search-proxy-mcp.ts | 146 ++ src/server/mcp/fusion-vision-mcp.ts | 23 +- src/shared/app.ts | 1 + tests/main/codex-model-catalog.test.mjs | 2 +- tests/main/gateway-virtual-models.test.mjs | 576 ++++- tests/renderer/virtual-models.test.ts | 4 +- 15 files changed, 4368 insertions(+), 55 deletions(-) create mode 100644 src/main/electron-web-search-mcp.ts create mode 100644 src/server/mcp/browser-web-search-proxy-mcp.ts diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 9bdaba7..bcf3a1a 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -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") diff --git a/src/main/codex-model-catalog.ts b/src/main/codex-model-catalog.ts index 1c87b84..841f553 100644 --- a/src/main/codex-model-catalog.ts +++ b/src/main/codex-model-catalog.ts @@ -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 | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record diff --git a/src/main/electron-web-search-mcp.ts b/src/main/electron-web-search-mcp.ts new file mode 100644 index 0000000..8fa90cd --- /dev/null +++ b/src/main/electron-web-search-mcp.ts @@ -0,0 +1,1265 @@ +import { BrowserWindow, WebContentsView, app, session, type WebContents } from "electron"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { join as pathJoin } from "node:path"; +import { backendService } from "../server/backend-service"; +import type { GatewayMcpServerConfig } from "../shared/app"; +import type { + BrowserWebSearchMcpIntegration, + BrowserWebSearchMcpRegistration, + BrowserWebSearchProtocolRecord +} from "../server/gateway/service"; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcRequest = { + id?: null | number | string; + jsonrpc?: string; + method?: string; + params?: unknown; +}; + +type JsonRpcResponse = + | { + id: null | number | string; + jsonrpc: "2.0"; + result: JsonValue; + } + | { + error: { + code: number; + data?: JsonValue; + message: string; + }; + id: null | number | string; + jsonrpc: "2.0"; + }; + +type ToolCallResult = { + content: Array<{ text: string; type: "text" }>; + isError?: boolean; +}; + +type BrowserSearchEngine = "bing" | "duckduckgo" | "google"; +type BrowserSearchFreshness = "day" | "month" | "week" | "year"; + +type BrowserSearchInput = { + after?: string; + anyTerms: string[]; + before?: string; + count: number; + country?: string; + engine: BrowserSearchEngine; + exactPhrase?: string; + excludeDomains: string[]; + excludeTerms: string[]; + freshness?: BrowserSearchFreshness; + includeDomains: string[]; + includeRaw: boolean; + keywords: string[]; + language?: string; + prompt: string; + safeSearch?: "moderate" | "off" | "strict"; + timeoutMs: number; +}; + +type BrowserSearchRequest = BrowserSearchInput & { + query: string; + searchUrl: string; +}; + +type BrowserSearchResult = { + content?: string; + snippet?: string; + title: string; + url: string; +}; + +type BrowserSearchPageResult = { + blocked?: string; + results?: BrowserSearchResult[]; + title?: string; +}; + +type BrowserSearchResponse = { + engine: BrowserSearchEngine; + query: string; + results: BrowserSearchResult[]; + searchUrl: string; +}; + +type BrowserSearchWorker = { + busy: boolean; + view: WebContentsView; +}; + +type BrowserSearchQueueEntry = { + reject: (error: Error) => void; + resolve: () => void; +}; + +const ownerId = "ccr-browser-web-search-mcp"; +const protocolVersion = "2024-11-05"; +const maxMcpRequestBytes = 2 * 1024 * 1024; +const defaultResultCount = 5; +const defaultTimeoutMs = 30_000; +const maxSearchResultCount = 20; +const searchPartition = "persist:ccr-browser-web-search-mcp"; +const defaultEngine: BrowserSearchEngine = "bing"; +const desktopUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +function bundledBrowserWebSearchProxyMcpEntryPath(): string { + return pathJoin(__dirname, "browser-web-search-proxy-mcp.js"); +} + +class BrowserWebSearchMcpService implements BrowserWebSearchMcpIntegration { + private readonly recentResults: BrowserWebSearchProtocolRecord[] = []; + private readonly servers = new Map(); + private readonly searchPool = new HiddenBrowserSearchPool(); + + async registerBrowserWebSearchMcpServer(options: BrowserWebSearchMcpRegistration): Promise { + await app.whenReady(); + const existing = this.servers.get(options.toolName); + if (existing) { + return existing.mcpConfig(); + } + + const server = new BrowserWebSearchToolServer(options, this.searchPool, (record) => this.recordSearchResult(record)); + const backend = await backendService.registerHttpBackend(ownerId, { + handler: (request, response) => server.handleRequest(request, response), + id: `${ownerId}:${options.name}` + }); + server.setUrl(`${backend.url}/mcp`); + this.servers.set(options.toolName, server); + return server.mcpConfig(); + } + + recentBrowserWebSearchResults(options: { sinceMs: number; toolName?: string }): BrowserWebSearchProtocolRecord[] { + this.pruneRecentResults(); + return this.recentResults.filter((record) => { + if (record.completedAtMs < options.sinceMs) { + return false; + } + return !options.toolName || record.toolName === options.toolName; + }); + } + + async runBrowserWebSearch(options: { count?: number; prompt: string; timeoutMs?: number; toolName?: string }): Promise { + const server = options.toolName ? this.servers.get(options.toolName) : this.servers.values().next().value; + return await server?.searchForProtocolResult(options); + } + + async stopBrowserWebSearchMcpServers(): Promise { + this.servers.clear(); + this.recentResults.splice(0); + await backendService.stopOwner(ownerId); + this.searchPool.destroy(); + } + + private recordSearchResult(record: BrowserWebSearchProtocolRecord): void { + this.recentResults.push(record); + this.pruneRecentResults(); + } + + private pruneRecentResults(): void { + const cutoff = Date.now() - 5 * 60_000; + while (this.recentResults.length > 0 && (this.recentResults[0].completedAtMs < cutoff || this.recentResults.length > 50)) { + this.recentResults.shift(); + } + } +} + +class BrowserWebSearchToolServer { + private readonly defaultCount: number; + private readonly defaultEngine: BrowserSearchEngine; + private readonly defaultLanguage?: string; + private readonly defaultCountry?: string; + private readonly defaultSafeSearch?: "moderate" | "off" | "strict"; + private readonly defaultTimeoutMs: number; + private readonly toolName: string; + private url = ""; + + constructor( + options: BrowserWebSearchMcpRegistration, + private readonly searchPool: HiddenBrowserSearchPool, + private readonly recordSearchResult: (record: BrowserWebSearchProtocolRecord) => void + ) { + const env = options.env ?? {}; + this.toolName = options.toolName; + this.defaultCount = clampInteger( + options.resultCount ?? readNumber(env.BROWSER_SEARCH_RESULT_COUNT) ?? readNumber(env.SEARCH_RESULT_COUNT) ?? defaultResultCount, + 1, + maxSearchResultCount + ); + this.defaultEngine = parseSearchEngine(env.BROWSER_SEARCH_ENGINE || env.SEARCH_ENGINE) ?? defaultEngine; + this.defaultLanguage = readString(env.BROWSER_SEARCH_LANGUAGE || env.SEARCH_LANGUAGE); + this.defaultCountry = readString(env.BROWSER_SEARCH_COUNTRY || env.SEARCH_COUNTRY); + this.defaultSafeSearch = parseSafeSearch(env.BROWSER_SEARCH_SAFE_SEARCH || env.SEARCH_SAFE_SEARCH); + this.defaultTimeoutMs = clampInteger( + options.timeoutMs ?? readNumber(env.BROWSER_SEARCH_TIMEOUT_MS) ?? readNumber(env.SEARCH_TIMEOUT_MS) ?? defaultTimeoutMs, + 100, + 600_000 + ); + } + + setUrl(url: string): void { + this.url = url; + } + + mcpConfig(): GatewayMcpServerConfig { + const requestTimeoutMs = clampInteger(this.defaultTimeoutMs + 5_000, 1_000, 600_000); + return { + args: [bundledBrowserWebSearchProxyMcpEntryPath()], + command: process.execPath, + env: { + BROWSER_WEB_SEARCH_MCP_URL: this.url, + BROWSER_WEB_SEARCH_PROXY_TIMEOUT_MS: String(requestTimeoutMs), + ELECTRON_RUN_AS_NODE: "1" + }, + name: this.toolName, + protocolVersion, + requestTimeoutMs, + startupTimeoutMs: 60_000, + stdioMessageMode: "content-length", + transport: "stdio" + }; + } + + async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + response.setHeader("MCP-Protocol-Version", protocolVersion); + const path = request.url ? new URL(request.url, this.url || "http://127.0.0.1").pathname : "/"; + + if (request.method === "GET" && (path === "/mcp" || path === "/mcp/")) { + sendJson(response, 200, { + endpoint: "/mcp", + name: this.toolName, + protocol: "mcp", + transport: "streamable-http" + }); + return; + } + + if (request.method !== "POST" || (path !== "/mcp" && path !== "/mcp/")) { + sendJson(response, 404, { error: { message: "In-app browser web search MCP endpoint not found." } }); + return; + } + + let payload: unknown; + try { + payload = JSON.parse((await readRequestBody(request, maxMcpRequestBytes)).toString("utf8")) as unknown; + } catch (error) { + sendJson(response, 400, jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`)); + return; + } + + const requests = Array.isArray(payload) ? payload : [payload]; + const responses = await Promise.all(requests.map((item) => this.handleJsonRpcRequest(item))); + const filtered = responses.filter((item): item is JsonRpcResponse => Boolean(item)); + if (filtered.length === 0) { + response.writeHead(204); + response.end(); + return; + } + + sendJson(response, 200, Array.isArray(payload) ? filtered : filtered[0]); + } + + private async handleJsonRpcRequest(payload: unknown): Promise { + if (!isRecord(payload)) { + return jsonRpcError(null, -32600, "JSON-RPC request must be an object."); + } + + const request = payload as JsonRpcRequest; + const id = request.id ?? null; + if (request.id === undefined && request.method?.startsWith("notifications/")) { + return undefined; + } + if (request.jsonrpc !== "2.0" || !request.method) { + return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request."); + } + + try { + switch (request.method) { + case "initialize": + return jsonRpcResult(id, { + capabilities: { + tools: {} + }, + protocolVersion, + serverInfo: { + name: "ccr-browser-web-search", + title: "CCR In-app Browser Web Search", + version: "1.0.0" + } + }); + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: [this.tool()] as unknown as JsonValue }); + case "tools/call": + return jsonRpcResult(id, await this.callTool(request.params) as unknown as JsonValue); + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`); + } + } catch (error) { + return jsonRpcError(id, -32603, formatError(error)); + } + } + + private tool(): JsonValue { + return { + description: + "Search the web through a hidden in-app browser window. " + + "Supports Google, Bing, and DuckDuckGo plus advanced query controls such as domains, keywords, phrases, excluded terms, and date filters.", + inputSchema: objectSchema({ + after: { description: "Earliest result date as YYYY-MM-DD. Also added to the engine query as after:YYYY-MM-DD.", type: "string" }, + allowedDomains: { description: "Alias for includeDomains; compatible with Anthropic web search declarations.", items: { type: "string" }, type: "array" }, + allowed_domains: { description: "Alias for includeDomains; compatible with Anthropic web_search_20250305.", items: { type: "string" }, type: "array" }, + anyTerms: { description: "Terms where any one may match; encoded as OR terms.", items: { type: "string" }, type: "array" }, + before: { description: "Latest result date as YYYY-MM-DD. Also added to the engine query as before:YYYY-MM-DD.", type: "string" }, + blockedDomains: { description: "Alias for excludeDomains; compatible with Anthropic web search declarations.", items: { type: "string" }, type: "array" }, + blocked_domains: { description: "Alias for excludeDomains; compatible with Anthropic web_search_20250305.", items: { type: "string" }, type: "array" }, + count: { maximum: maxSearchResultCount, minimum: 1, type: "number" }, + country: { description: "Country/region hint such as US, CN, GB.", type: "string" }, + domains: { description: "Alias for includeDomains.", items: { type: "string" }, type: "array" }, + engine: { enum: ["auto", "bing", "google", "duckduckgo"], type: "string" }, + exactPhrase: { description: "Phrase to search exactly.", type: "string" }, + excludeDomain: { description: "Single domain alias for excludeDomains.", type: "string" }, + excludeDomains: { description: "Domains to exclude with -site:domain.", items: { type: "string" }, type: "array" }, + excludeTerms: { description: "Terms to exclude with a leading minus.", items: { type: "string" }, type: "array" }, + freshness: { description: "Relative time filter.", enum: ["day", "week", "month", "year"], type: "string" }, + includeDomains: { description: "Domains to restrict with site:domain.", items: { type: "string" }, type: "array" }, + includeRaw: { description: "Include the engine search URL in the text response.", type: "boolean" }, + keywords: { description: "Additional required keywords. A comma-separated string is also accepted.", items: { type: "string" }, type: "array" }, + language: { description: "Language hint such as en, zh-CN, ja.", type: "string" }, + prompt: { description: "Natural-language query. Search engine operators already present here are preserved.", type: "string" }, + query: { description: "Alias for prompt.", type: "string" }, + safeSearch: { enum: ["off", "moderate", "strict"], type: "string" }, + site: { description: "Single domain alias for includeDomains.", type: "string" }, + timeRange: { description: "Alias for freshness.", enum: ["day", "week", "month", "year"], type: "string" }, + timeoutMs: { minimum: 100, type: "number" } + }, ["prompt"]), + name: this.toolName, + title: "In-app Browser Web Search" + }; + } + + private async callTool(params: unknown): Promise { + if (!isRecord(params) || typeof params.name !== "string") { + throw new Error("tools/call params must include a tool name."); + } + if (params.name !== this.toolName) { + throw new Error(`Unknown in-app browser web search tool: ${params.name}`); + } + + const args = isRecord(params.arguments) ? params.arguments : {}; + try { + const input = this.readSearchInput(args); + const record = await this.search(input); + return textResult(formatSearchResponse(record, input.includeRaw)); + } catch (error) { + return { + ...textResult(formatError(error)), + isError: true + }; + } + } + + async searchForProtocolResult(options: { count?: number; prompt: string; timeoutMs?: number }): Promise { + return await this.search(this.readSearchInput({ + count: options.count, + prompt: options.prompt, + timeoutMs: options.timeoutMs + })); + } + + private async search(input: BrowserSearchInput): Promise { + const response = await this.searchPool.search(input); + const record = { + completedAtMs: Date.now(), + engine: response.engine, + query: response.query, + results: response.results, + searchUrl: response.searchUrl, + toolName: this.toolName + }; + this.recordSearchResult(record); + return record; + } + + private readSearchInput(args: Record): BrowserSearchInput { + const prompt = readString(args.prompt) || readString(args.query); + if (!prompt) { + throw new Error(`${this.toolName} requires prompt.`); + } + + const engine = parseSearchEngine(readString(args.engine)) ?? this.defaultEngine; + const includeDomains = uniqueStrings([ + ...readStringArray(args.includeDomains), + ...readStringArray(args.domains), + ...readStringArray(args.domain), + ...readStringArray(args.site), + ...readStringArray(args.allowedDomains), + ...readStringArray(args.allowed_domains) + ].map(normalizeDomain).filter((item): item is string => Boolean(item))); + const excludeDomains = uniqueStrings([ + ...readStringArray(args.excludeDomains), + ...readStringArray(args.excludeDomain), + ...readStringArray(args.blockedDomains), + ...readStringArray(args.blocked_domains) + ].map(normalizeDomain).filter((item): item is string => Boolean(item))); + + return { + after: normalizeSearchDate(readString(args.after) || readString(args.since) || readString(args.fromDate)), + anyTerms: readStringArray(args.anyTerms), + before: normalizeSearchDate(readString(args.before) || readString(args.until) || readString(args.toDate)), + count: clampInteger(readNumber(args.count) ?? this.defaultCount, 1, maxSearchResultCount), + country: readString(args.country) || this.defaultCountry, + engine, + exactPhrase: readString(args.exactPhrase), + excludeDomains, + excludeTerms: readStringArray(args.excludeTerms), + freshness: parseFreshness(readString(args.freshness) || readString(args.timeRange)), + includeDomains, + includeRaw: args.includeRaw === true, + keywords: readStringArray(args.keywords), + language: readString(args.language) || this.defaultLanguage, + prompt, + safeSearch: parseSafeSearch(readString(args.safeSearch)) ?? this.defaultSafeSearch, + timeoutMs: clampInteger(readNumber(args.timeoutMs) ?? this.defaultTimeoutMs, 100, 600_000) + }; + } +} + +class HiddenBrowserSearchPool { + private configuredSession = false; + private queue: BrowserSearchQueueEntry[] = []; + private window?: BrowserWindow; + private workers: BrowserSearchWorker[] = []; + + async search(input: BrowserSearchInput): Promise { + const request = buildBrowserSearchRequest(input); + const worker = await this.acquire(request.timeoutMs); + let page: BrowserSearchPageResult; + try { + page = await runSearchInWorker(worker, request); + } finally { + this.release(worker); + } + if (page.blocked) { + throw new Error(page.blocked); + } + const results = uniqueSearchResults(page.results ?? []).slice(0, request.count); + return { + engine: request.engine, + query: request.query, + results: await this.enrichResults(results, request), + searchUrl: request.searchUrl + }; + } + + destroy(): void { + this.queue.splice(0).forEach((entry) => entry.reject(new Error("Browser search service stopped."))); + this.queue = []; + for (const worker of this.workers) { + if (!worker.view.webContents.isDestroyed()) { + worker.view.webContents.close({ waitForBeforeUnload: false }); + } + } + this.workers = []; + if (this.window && !this.window.isDestroyed()) { + this.window.destroy(); + } + this.window = undefined; + this.configuredSession = false; + } + + private async acquire(timeoutMs: number): Promise { + await app.whenReady(); + this.ensureWindow(); + const maxWorkers = browserSearchConcurrency(); + for (;;) { + const idle = this.workers.find((worker) => !worker.busy); + if (idle) { + idle.busy = true; + return idle; + } + if (this.workers.length < maxWorkers) { + const worker = this.createWorker(); + worker.busy = true; + return worker; + } + await waitForQueueTurn(this.queue, timeoutMs); + } + } + + private release(worker: BrowserSearchWorker): void { + worker.busy = false; + const next = this.queue.shift(); + next?.resolve(); + } + + private ensureWindow(): BrowserWindow { + if (this.window && !this.window.isDestroyed()) { + return this.window; + } + this.configureSearchSession(); + this.window = new BrowserWindow({ + height: 900, + paintWhenInitiallyHidden: true, + show: false, + skipTaskbar: true, + title: "CCR In-app Browser Web Search", + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + images: false, + nodeIntegration: false, + sandbox: true, + webSecurity: true + }, + width: 1280 + }); + this.window.on("closed", () => { + this.workers = []; + this.window = undefined; + }); + return this.window; + } + + private createWorker(): BrowserSearchWorker { + const window = this.ensureWindow(); + const view = new WebContentsView({ + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + images: false, + nodeIntegration: false, + partition: searchPartition, + sandbox: true, + webSecurity: true + } + }); + view.webContents.setAudioMuted(true); + view.webContents.on("console-message", (event) => { + event.preventDefault(); + }); + view.setBounds({ height: 900, width: 1280, x: 0, y: 0 }); + window.contentView.addChildView(view); + const worker = { busy: false, view }; + this.workers.push(worker); + return worker; + } + + private configureSearchSession(): void { + if (this.configuredSession) { + return; + } + const browserSession = session.fromPartition(searchPartition); + browserSession.setUserAgent(desktopUserAgent); + browserSession.webRequest.onBeforeRequest((details, callback) => { + if (details.resourceType === "image" || details.resourceType === "media" || details.resourceType === "font") { + callback({ cancel: true }); + return; + } + callback({}); + }); + this.configuredSession = true; + } + + private async enrichResults(results: BrowserSearchResult[], request: BrowserSearchRequest): Promise { + const openCount = Math.min(results.length, browserSearchOpenResultCount()); + if (openCount <= 0) { + return results; + } + const enriched = await Promise.all(results.slice(0, openCount).map((result) => this.enrichResult(result, request))); + return results.map((result, index) => enriched[index] ?? result); + } + + private async enrichResult(result: BrowserSearchResult, request: BrowserSearchRequest): Promise { + if (!shouldOpenSearchResult(result.url)) { + return result; + } + const timeoutMs = browserSearchPageTimeoutMs(request.timeoutMs); + let worker: BrowserSearchWorker | undefined; + try { + worker = await this.acquire(timeoutMs); + await loadSearchUrl(worker.view.webContents, result.url, timeoutMs); + const page = await withTimeout( + worker.view.webContents.executeJavaScript(pageContentExtractionScript(), true) as Promise<{ description?: string; text?: string; title?: string }>, + timeoutMs, + "Browser result page extraction timed out." + ); + const content = compactExtractedContent(page.text); + const snippet = result.snippet || compactExtractedContent(page.description); + return { + ...result, + ...(content ? { content } : {}), + ...(snippet ? { snippet } : {}), + ...(page.title && page.title.length > result.title.length ? { title: page.title.slice(0, 240) } : {}) + }; + } catch { + return result; + } finally { + if (worker) { + this.release(worker); + } + } + } +} + +async function runSearchInWorker(worker: BrowserSearchWorker, request: BrowserSearchRequest): Promise { + const webContents = worker.view.webContents; + if (webContents.isDestroyed()) { + throw new Error("Browser search view is unavailable."); + } + + await loadSearchUrl(webContents, request.searchUrl, request.timeoutMs); + return await pollSearchResults(webContents, request); +} + +async function loadSearchUrl(webContents: WebContents, url: string, timeoutMs: number): Promise { + webContents.stop(); + await withTimeout( + webContents.loadURL(url, { + userAgent: desktopUserAgent + }), + timeoutMs, + "Browser search navigation timed out." + ); +} + +async function pollSearchResults(webContents: WebContents, request: BrowserSearchRequest): Promise { + const deadline = Date.now() + request.timeoutMs; + let last: BrowserSearchPageResult = { results: [] }; + while (Date.now() < deadline) { + last = await webContents.executeJavaScript(searchResultExtractionScript(request.engine, request.count), true) as BrowserSearchPageResult; + if (last.blocked || (last.results?.length ?? 0) >= Math.min(request.count, 3)) { + return last; + } + await sleep(150); + } + return last; +} + +function buildBrowserSearchRequest(input: BrowserSearchInput): BrowserSearchRequest { + const query = buildAdvancedSearchQuery(input); + const searchUrl = searchUrlForEngine(input, query); + return { + ...input, + query, + searchUrl + }; +} + +function searchUrlForEngine(input: BrowserSearchInput, query: string): string { + if (input.engine === "google") { + const url = new URL("https://www.google.com/search"); + url.searchParams.set("q", query); + url.searchParams.set("num", String(Math.min(input.count, 10))); + url.searchParams.set("filter", "0"); + url.searchParams.set("pws", "0"); + url.searchParams.set("udm", "14"); + if (input.language) url.searchParams.set("hl", input.language); + if (input.country) url.searchParams.set("gl", input.country); + if (input.safeSearch) url.searchParams.set("safe", input.safeSearch === "off" ? "off" : "active"); + const tbs = googleTimeFilter(input); + if (tbs) url.searchParams.set("tbs", tbs); + return url.toString(); + } + + if (input.engine === "duckduckgo") { + const url = new URL("https://duckduckgo.com/"); + url.searchParams.set("q", query); + url.searchParams.set("ia", "web"); + if (input.language) url.searchParams.set("kl", input.language); + if (input.safeSearch) url.searchParams.set("kp", input.safeSearch === "off" ? "-2" : "1"); + const df = duckDuckGoTimeFilter(input.freshness); + if (df) url.searchParams.set("df", df); + return url.toString(); + } + + const url = new URL("https://www.bing.com/search"); + url.searchParams.set("q", query); + url.searchParams.set("count", String(input.count)); + url.searchParams.set("qs", "n"); + url.searchParams.set("form", "QBRE"); + if (input.language) url.searchParams.set("setlang", input.language); + if (input.country) url.searchParams.set("cc", input.country); + if (input.safeSearch) url.searchParams.set("safeSearch", bingSafeSearch(input.safeSearch)); + const qft = bingTimeFilter(input.freshness); + if (qft) url.searchParams.set("qft", qft); + return url.toString(); +} + +function buildAdvancedSearchQuery(input: BrowserSearchInput): string { + const parts = [input.prompt.trim()]; + parts.push(...input.keywords.map(searchTerm)); + if (input.exactPhrase) { + parts.push(quoteSearchPhrase(input.exactPhrase)); + } + if (input.anyTerms.length > 0) { + parts.push(`(${input.anyTerms.map(searchTerm).join(" OR ")})`); + } + parts.push(...input.excludeTerms.map((term) => `-${searchTerm(term)}`)); + if (input.includeDomains.length === 1) { + parts.push(`site:${input.includeDomains[0]}`); + } else if (input.includeDomains.length > 1) { + parts.push(`(${input.includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`); + } + parts.push(...input.excludeDomains.map((domain) => `-site:${domain}`)); + if (input.after) { + parts.push(`after:${input.after}`); + } + if (input.before) { + parts.push(`before:${input.before}`); + } + return parts.filter(Boolean).join(" ").replace(/\s+/g, " ").trim(); +} + +function searchTerm(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + return /\s/.test(trimmed) && !/^".*"$/.test(trimmed) ? quoteSearchPhrase(trimmed) : trimmed; +} + +function quoteSearchPhrase(value: string): string { + return `"${value.trim().replace(/"/g, " ")}"`; +} + +function googleTimeFilter(input: BrowserSearchInput): string | undefined { + if (input.after || input.before) { + const parts = ["cdr:1"]; + if (input.after) parts.push(`cd_min:${dateToGoogleDate(input.after)}`); + if (input.before) parts.push(`cd_max:${dateToGoogleDate(input.before)}`); + return parts.join(","); + } + if (input.freshness === "day") return "qdr:d"; + if (input.freshness === "week") return "qdr:w"; + if (input.freshness === "month") return "qdr:m"; + if (input.freshness === "year") return "qdr:y"; + return undefined; +} + +function bingTimeFilter(value: BrowserSearchFreshness | undefined): string | undefined { + if (value === "day") return "+filterui:age-lt1440"; + if (value === "week") return "+filterui:age-lt10080"; + if (value === "month") return "+filterui:age-lt43200"; + if (value === "year") return "+filterui:age-lt525600"; + return undefined; +} + +function duckDuckGoTimeFilter(value: BrowserSearchFreshness | undefined): string | undefined { + if (value === "day") return "d"; + if (value === "week") return "w"; + if (value === "month") return "m"; + if (value === "year") return "y"; + return undefined; +} + +function bingSafeSearch(value: "moderate" | "off" | "strict"): string { + if (value === "strict") return "Strict"; + if (value === "off") return "Off"; + return "Moderate"; +} + +function dateToGoogleDate(value: string): string { + const [year, month, day] = value.split("-"); + return `${month}/${day}/${year}`; +} + +function searchResultExtractionScript(engine: BrowserSearchEngine, count: number): string { + return `(() => { + const engine = ${JSON.stringify(engine)}; + const maxResults = ${JSON.stringify(count)}; + const results = []; + const seen = new Set(); + const blockedText = detectBlocked(); + if (blockedText) return { blocked: blockedText, results, title: document.title || "" }; + + function compact(value) { + return String(value || "").replace(/\\s+/g, " ").trim(); + } + function absoluteUrl(href) { + try { return new URL(href, location.href); } catch (_) { return undefined; } + } + function decodeMaybeBase64(value) { + if (!value) return ""; + let raw = value.replace(/-/g, "+").replace(/_/g, "/"); + if (raw.startsWith("a1")) raw = raw.slice(2); + try { + const decoded = atob(raw); + return /^https?:\\/\\//i.test(decoded) ? decoded : ""; + } catch (_) { + return ""; + } + } + function normalizeResultUrl(href) { + const url = absoluteUrl(href); + if (!url || !/^https?:$/i.test(url.protocol)) return ""; + const host = url.hostname.toLowerCase(); + if (host.includes("google.") && url.pathname === "/url") { + const q = url.searchParams.get("q") || url.searchParams.get("url"); + if (q) return normalizeResultUrl(q); + } + if (host.endsWith("bing.com") && url.pathname.startsWith("/ck/")) { + const raw = url.searchParams.get("u"); + const decoded = decodeMaybeBase64(raw || ""); + if (decoded) return normalizeResultUrl(decoded); + } + if (host.endsWith("duckduckgo.com") && url.pathname.startsWith("/l/")) { + const target = url.searchParams.get("uddg"); + if (target) return normalizeResultUrl(target); + } + if (isSearchChromeUrl(url)) return ""; + url.hash = ""; + return url.toString(); + } + function isSearchChromeUrl(url) { + const host = url.hostname.toLowerCase(); + const path = url.pathname.toLowerCase(); + if (host === location.hostname.toLowerCase() && (path === "/" || path === "/search" || path.startsWith("/search/"))) return true; + if (host.includes("google.") && /\\/(preferences|advanced_search|intl|policies|support|sorry)/.test(path)) return true; + if (host.endsWith("bing.com") && /\\/(search|account|profile|images|videos|maps|news)/.test(path)) return true; + if (host.endsWith("duckduckgo.com") && path === "/") return true; + return false; + } + function resultContainer(anchor, title) { + let node = anchor; + for (let depth = 0; node && depth < 8; depth += 1, node = node.parentElement) { + const text = compact(node.textContent || ""); + if (text.length > title.length + 40) return node; + } + return anchor.closest("li.b_algo, article, div.g, div[data-sokoban-container], div[data-testid='result'], .result, li, div"); + } + function cleanSnippetCandidate(value, title, url) { + const host = (() => { try { return new URL(url).hostname.replace(/^www\\./, ""); } catch (_) { return ""; } })(); + return compact(String(value || "") + .replace(title, " ") + .replace(url, " ") + .replace(host, " ") + .replace(/https?:\\/\\/\\S+/g, " ") + .replace(/\\b(?:Cached|Translate this page|Similar)\\b/gi, " ")); + } + function breadcrumbLike(value) { + const text = compact(value); + if (!text) return true; + if (/https?:\\/\\//i.test(text)) return true; + if (text.length < 35 && /[›/]|^https?:|^www\\./i.test(text)) return true; + if (/[›]/.test(text) && !/[.!?,。!?]/.test(text)) return true; + if (!/[.!?:;,。!?:;]/.test(text) && /^[\\w\\s.:-]+(?:[›/]\\s*[\\w\\s.:-]+)+$/i.test(text)) return true; + return false; + } + function snippetFor(anchor, container, title, url) { + const preferred = container && container.querySelector(".b_caption p, .b_snippet, [data-sncf], .VwiC3b, .IsZvec, [data-result='snippet'], .result__snippet, p"); + const preferredText = cleanSnippetCandidate(preferred && preferred.textContent, title, url); + if (preferredText && !breadcrumbLike(preferredText)) return preferredText.slice(0, 700); + const candidates = container + ? Array.from(container.querySelectorAll("p, span, div")) + .map((node) => cleanSnippetCandidate(node.textContent, title, url)) + .filter((text) => text.length >= 35 && !breadcrumbLike(text)) + : []; + const text = candidates.find((candidate) => candidate.length >= 50) || candidates[0] || ""; + if (!text) return ""; + return text.slice(0, 700); + } + function titleFor(anchor, titleOverride) { + const explicit = compact(titleOverride); + if (explicit && explicit.length >= 2) return explicit; + const heading = anchor && anchor.querySelector("h1, h2, h3, [role='heading']"); + return compact((heading && heading.textContent) || (anchor && (anchor.textContent || anchor.innerText))); + } + function add(anchor, titleOverride) { + if (!anchor || results.length >= maxResults) return; + const href = anchor.getAttribute("href") || ""; + const url = normalizeResultUrl(href); + if (!url || seen.has(url)) return; + const title = titleFor(anchor, titleOverride); + if (!title || title.length < 2) return; + const container = resultContainer(anchor, title); + seen.add(url); + results.push({ + snippet: snippetFor(anchor, container, title, url), + title: title.slice(0, 240), + url + }); + } + function collect(selector) { + document.querySelectorAll(selector).forEach((node) => { + const anchor = node.matches && node.matches("a[href]") ? node : node.closest && node.closest("a[href]"); + add(anchor, node.matches && node.matches("h1, h2, h3, [role='heading']") ? node.textContent : ""); + }); + } + function collectGoogle() { + collect("#search h3"); + collect("#rso h3"); + collect("a[href] h3"); + } + function collectBing() { + collect("li.b_algo h2 a[href]"); + collect("main ol li h2 a[href]"); + } + function collectDuckDuckGo() { + collect("a[data-testid='result-title-a']"); + collect(".result__a"); + collect("article h2 a[href]"); + } + if (engine === "google") collectGoogle(); + if (engine === "bing") collectBing(); + if (engine === "duckduckgo") collectDuckDuckGo(); + collect("main a[href], #search a[href], #b_results a[href], article a[href], a[href]"); + return { results, title: document.title || "" }; + + function detectBlocked() { + const text = compact(document.body && document.body.innerText).toLowerCase(); + if (!text) return ""; + if (text.includes("unusual traffic") || text.includes("detected unusual") || text.includes("not a robot") || text.includes("captcha")) { + return "Search engine returned an anti-bot or CAPTCHA page."; + } + if (text.includes("before you continue to google") || text.includes("consent.google")) { + return "Google returned a consent page instead of search results."; + } + return ""; + } + })();`; +} + +function pageContentExtractionScript(): string { + return `(() => { + function compact(value) { + return String(value || "").replace(/[\\t\\f\\v ]+/g, " ").replace(/\\n\\s*\\n+/g, "\\n").trim(); + } + function textFrom(node) { + if (!node) return ""; + const clone = node.cloneNode(true); + clone.querySelectorAll("script, style, noscript, svg, nav, header, footer, aside, form, button, input, select, textarea, iframe, [aria-hidden='true']").forEach((item) => item.remove()); + const lines = String(clone.innerText || clone.textContent || "") + .split(/\\n+/) + .map((line) => compact(line)) + .filter((line) => line.length >= 20) + .filter((line, index, array) => array.indexOf(line) === index); + return lines.join("\\n"); + } + function scoreText(text) { + const length = text.length; + const sentenceMarks = (text.match(/[.!?。!?]/g) || []).length; + return length + sentenceMarks * 80; + } + const title = compact( + document.querySelector("meta[property='og:title']")?.getAttribute("content") || + document.querySelector("h1")?.textContent || + document.title || + "" + ); + const description = compact( + document.querySelector("meta[name='description']")?.getAttribute("content") || + document.querySelector("meta[property='og:description']")?.getAttribute("content") || + "" + ); + const candidates = [ + ...document.querySelectorAll("article, main, [role='main'], .article, .article-content, .post, .post-content, .entry-content, .content, #content, .main-content") + ].map(textFrom).filter(Boolean); + const fallback = textFrom(document.body); + const text = [...candidates, fallback] + .filter(Boolean) + .sort((left, right) => scoreText(right) - scoreText(left))[0] || ""; + return { + description, + text: text.slice(0, 5000), + title + }; + })();`; +} + +function formatSearchResponse( + response: Pick & { engine: string }, + includeRaw: boolean +): string { + if (response.results.length === 0) { + return [ + `Search engine: ${response.engine}`, + `Query: ${response.query}`, + includeRaw ? `Search URL: ${response.searchUrl}` : "", + "No results." + ].filter(Boolean).join("\n"); + } + + return [ + `Search engine: ${response.engine}`, + `Query: ${response.query}`, + includeRaw ? `Search URL: ${response.searchUrl}` : "", + ...response.results.map((result, index) => [ + `${index + 1}. ${result.title}`, + `URL: ${result.url}`, + result.snippet ? `Snippet: ${result.snippet}` : "", + result.content ? `Extracted content: ${result.content}` : "" + ].filter(Boolean).join("\n")) + ].filter(Boolean).join("\n\n"); +} + +function uniqueSearchResults(results: BrowserSearchResult[]): BrowserSearchResult[] { + const seen = new Set(); + const unique: BrowserSearchResult[] = []; + for (const result of results) { + if (!result.url || seen.has(result.url)) { + continue; + } + seen.add(result.url); + unique.push(result); + } + return unique; +} + +function normalizeDomain(value: string): string | undefined { + const trimmed = value.trim().replace(/^site:/i, ""); + if (!trimmed) { + return undefined; + } + try { + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + return url.hostname.replace(/^www\./i, "").toLowerCase(); + } catch { + return trimmed.replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase(); + } +} + +function normalizeSearchDate(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (/^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed)) { + return trimmed; + } + const time = Date.parse(trimmed); + return Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : undefined; +} + +function parseSearchEngine(value: string | undefined): BrowserSearchEngine | undefined { + const normalized = value?.trim().toLowerCase().replace(/[_\s]+/g, "-"); + if (!normalized || normalized === "auto") { + return undefined; + } + if (normalized === "google") return "google"; + if (normalized === "bing") return "bing"; + if (normalized === "duckduckgo" || normalized === "duck-duck-go" || normalized === "ddg") return "duckduckgo"; + return undefined; +} + +function parseFreshness(value: string | undefined): BrowserSearchFreshness | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "day" || normalized === "24h" || normalized === "d") return "day"; + if (normalized === "week" || normalized === "7d" || normalized === "w") return "week"; + if (normalized === "month" || normalized === "30d" || normalized === "m") return "month"; + if (normalized === "year" || normalized === "365d" || normalized === "y") return "year"; + return undefined; +} + +function parseSafeSearch(value: string | undefined): "moderate" | "off" | "strict" | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "false" || normalized === "0") return "off"; + if (normalized === "strict") return "strict"; + if (normalized === "moderate" || normalized === "medium" || normalized === "on" || normalized === "true" || normalized === "1") return "moderate"; + return undefined; +} + +function browserSearchConcurrency(): number { + return clampInteger(readNumber(process.env.CCR_BROWSER_SEARCH_CONCURRENCY) ?? 2, 1, 4); +} + +function browserSearchOpenResultCount(): number { + return clampInteger(readNumber(process.env.CCR_BROWSER_SEARCH_OPEN_RESULT_COUNT) ?? 3, 0, 8); +} + +function browserSearchPageTimeoutMs(requestTimeoutMs: number): number { + return clampInteger( + readNumber(process.env.CCR_BROWSER_SEARCH_PAGE_TIMEOUT_MS) ?? Math.min(requestTimeoutMs, 8_000), + 500, + 30_000 + ); +} + +function shouldOpenSearchResult(url: string): boolean { + try { + const parsed = new URL(url); + if (!/^https?:$/i.test(parsed.protocol)) { + return false; + } + return !/\.(?:avi|dmg|docx?|exe|gif|jpe?g|mov|mp3|mp4|pdf|png|pptx?|rar|webp|xlsx?|zip)$/i.test(parsed.pathname); + } catch { + return false; + } +} + +function compactExtractedContent(value: string | undefined): string | undefined { + const compacted = value?.replace(/\s+/g, " ").trim(); + if (!compacted || compacted.length < 40) { + return undefined; + } + return compacted.slice(0, 2_400); +} + +function waitForQueueTurn(queue: BrowserSearchQueueEntry[], timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const entry = { + reject: (error: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }, + resolve: () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(); + } + }; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const index = queue.indexOf(entry); + if (index >= 0) { + queue.splice(index, 1); + } + reject(new Error("Browser search worker queue timed out.")); + }, timeoutMs); + queue.push(entry); + }); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function textResult(text: string): ToolCallResult { + return { + content: [{ text, type: "text" }] + }; +} + +function objectSchema(properties: Record, required: string[] = []): JsonValue { + return { + additionalProperties: true, + properties, + ...(required.length ? { required } : {}), + type: "object" + }; +} + +function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse { + return { + id, + jsonrpc: "2.0", + result + }; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + request.on("data", (chunk: Buffer) => { + total += chunk.byteLength; + if (total > maxBytes) { + reject(new Error(`Request body exceeds ${maxBytes} bytes.`)); + request.destroy(); + return; + } + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void { + const body = `${JSON.stringify(payload)}\n`; + response.writeHead(statusCode, { + "cache-control": "no-store", + "content-length": Buffer.byteLength(body), + "content-type": "application/json; charset=utf-8" + }); + response.end(body); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +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 clampInteger(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, Math.round(value))); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const browserWebSearchMcpService = new BrowserWebSearchMcpService(); diff --git a/src/main/main-app.ts b/src/main/main-app.ts index 9c1d9ca..a065668 100644 --- a/src/main/main-app.ts +++ b/src/main/main-app.ts @@ -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); diff --git a/src/renderer/pages/home/components/virtual-models.tsx b/src/renderer/pages/home/components/virtual-models.tsx index 453a957..6debc9d 100644 --- a/src/renderer/pages/home/components/virtual-models.tsx +++ b/src/renderer/pages/home/components/virtual-models.tsx @@ -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(); @@ -452,17 +474,112 @@ function WebSearchToolConfigurationPanel({ - - onChange({ webSearchEnvRows })} rows={draft.webSearchEnvRows} /> + ) : ( + + onChange({ webSearchEnvRows })} + rows={draft.webSearchEnvRows} + /> + + )} + + ); +} + +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 ( +
+
+ + updateKnownValue(browserWebSearchEngineEnvKey, value)} + options={browserWebSearchEngineOptions} + value={browserWebSearchEngineOptions.some((option) => option.value === engine) ? engine : "bing"} + /> + + + updateKnownValue(browserWebSearchSafeSearchEnvKey, value)} + options={translateOptions(browserWebSearchSafeSearchOptions, t)} + value={browserWebSearchSafeSearchOptions.some((option) => option.value === safeSearch) ? safeSearch : "default"} + /> + + + updateKnownValue(browserWebSearchLanguageEnvKey, event.target.value)} + placeholder="en, zh-CN" + value={language} + /> + + + updateKnownValue(browserWebSearchCountryEnvKey, event.target.value)} + placeholder="US, CN" + value={country} + /> + +
+ +
); } +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, diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index cf70595..cb33420 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -380,7 +380,17 @@ export const appCopy: Record = { "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 = { "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 = { "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 = { "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.": "新模型不能为空。", diff --git a/src/renderer/pages/home/shared/options.ts b/src/renderer/pages/home/shared/options.ts index 25cf305..a47466f 100644 --- a/src/renderer/pages/home/shared/options.ts +++ b/src/renderer/pages/home/shared/options.ts @@ -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 = { 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"], diff --git a/src/renderer/pages/home/shared/virtual-models.ts b/src/renderer/pages/home/shared/virtual-models.ts index 18ca112..c71cdd0 100644 --- a/src/renderer/pages/home/shared/virtual-models.ts +++ b/src/renderer/pages/home/shared/virtual-models.ts @@ -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 { diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 8496526..f4ab704 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -101,6 +101,49 @@ type GatewayStopOptions = { proxyRestoreTimeoutMs?: number; }; +type HostedWebSearchProtocolContext = { + maxUses?: number; + protocol: GatewayProviderProtocol; + queryHint?: string; + records?: BrowserWebSearchProtocolRecord[]; + requestId: string; + sinceMs: number; + toolName: string; +}; + +type AnthropicWebSearchProtocolContext = HostedWebSearchProtocolContext; + +export type BrowserWebSearchMcpRegistration = { + env?: Record; + name: string; + resultCount?: number; + timeoutMs?: number; + toolName: string; +}; + +export type BrowserWebSearchProtocolResult = { + content?: string; + snippet?: string; + title: string; + url: string; +}; + +export type BrowserWebSearchProtocolRecord = { + completedAtMs: number; + engine: string; + query: string; + results: BrowserWebSearchProtocolResult[]; + searchUrl: string; + toolName: string; +}; + +export type BrowserWebSearchMcpIntegration = { + registerBrowserWebSearchMcpServer: (options: BrowserWebSearchMcpRegistration) => Promise; + recentBrowserWebSearchResults?: (options: { sinceMs: number; toolName?: string }) => BrowserWebSearchProtocolRecord[]; + runBrowserWebSearch?: (options: { count?: number; prompt: string; timeoutMs?: number; toolName?: string }) => Promise; + stopBrowserWebSearchMcpServers: () => Promise; +}; + type CoreGatewayHealth = { runtimeId?: string; status?: string; @@ -261,6 +304,7 @@ const persistedApiKeyCacheTtlMs = 1000; let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined; class GatewayService { + private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration; private child?: ChildProcess; private config?: AppConfig; private coreAuthToken = ""; @@ -276,6 +320,10 @@ class GatewayService { state: "stopped" }; + setBrowserWebSearchMcpIntegration(integration: BrowserWebSearchMcpIntegration): void { + this.browserWebSearchMcpIntegration = integration; + } + async start(config: AppConfig): Promise { const coreHostError = loopbackCoreHostError(config.gateway.coreHost); if (coreHostError) { @@ -324,7 +372,7 @@ class GatewayService { } if (shouldRunGateway) { - writeCoreGatewayConfig(config, this.rawTraceSyncToken); + await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.browserWebSearchMcpIntegration); await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint); if (await isCoreGatewayHealthy(this.status.coreEndpoint)) { throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`); @@ -379,6 +427,9 @@ class GatewayService { await proxyService.stop(options.proxyRestoreTimeoutMs); await pluginService.stop(); await backendService.stopAll(); + await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => { + console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`); + }); this.status = { ...this.status, @@ -739,6 +790,39 @@ class GatewayService { routeFallback = providerCapabilityRouting.fallback; routedModel = providerCapabilityRouting.routedModel; + const hostedWebSearchProtocolContext = createHostedWebSearchProtocolContext({ + body: bodyToForward, + config: this.config, + method, + path, + requestId, + routedModel, + sinceMs: startedAt - 1_000 + }); + + if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) { + const records = await selectHostedWebSearchProtocolRecords( + hostedWebSearchProtocolContext, + this.browserWebSearchMcpIntegration + ).catch((error) => { + console.warn(`[gateway] Failed to prefetch hosted web search results: ${formatError(error)}`); + return [] as BrowserWebSearchProtocolRecord[]; + }); + if (records.length > 0) { + hostedWebSearchProtocolContext.records = records; + const webSearchContextBody = prepareHostedWebSearchProtocolRequestBody( + bodyToForward, + records, + hostedWebSearchProtocolContext + ); + if (webSearchContextBody) { + bodyToForward = webSearchContextBody; + headers["content-type"] = "application/json"; + headers["x-ccr-hosted-web-search-context"] = hostedWebSearchProtocolContext.protocol; + } + } + } + delete headers["content-length"]; const upstreamUrl = new URL(request.url || "/", this.status.coreEndpoint).toString(); let upstreamResult: UpstreamFetchResult; @@ -795,6 +879,14 @@ class GatewayService { if (codexApplyPatchBridgeActive) { responseHeaders.delete("content-length"); } + const hostedWebSearchResponseContentType = responseHeaders.get("content-type")?.toLowerCase() ?? ""; + if ( + hostedWebSearchProtocolContext && + hostedWebSearchResponseContentType.includes("application/json") && + (this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || this.browserWebSearchMcpIntegration?.runBrowserWebSearch) + ) { + responseHeaders.delete("content-length"); + } recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders); response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders))); if (!upstreamResponse.body) { @@ -819,9 +911,17 @@ class GatewayService { } const upstreamBody = Readable.fromWeb(upstreamResponse.body as unknown as import("node:stream/web").ReadableStream); - const responseBody = codexApplyPatchBridgeActive + const patchedResponseBody = codexApplyPatchBridgeActive ? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders) : upstreamBody; + const responseBody = hostedWebSearchProtocolContext + ? hostedWebSearchProtocolResponseStream( + patchedResponseBody, + responseHeaders, + hostedWebSearchProtocolContext, + this.browserWebSearchMcpIntegration + ) + : patchedResponseBody; const sampler = createBodySampler(); const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined); let streamDetectedError: string | undefined; @@ -946,7 +1046,11 @@ class GatewayService { export const gatewayService = new GatewayService(); -function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): void { +async function writeCoreGatewayConfig( + config: AppConfig, + rawTraceSyncToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise { assertLoopbackCoreHost(config.gateway.coreHost); mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); @@ -960,7 +1064,7 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v ...pluginService.getVirtualModelProfiles() ])), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint); + const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, browserWebSearchMcpIntegration); const providers = [ ...config.Providers .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) @@ -1078,7 +1182,9 @@ function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppCo }; } - return nextProfile ?? profile; + const profileAfterVision = nextProfile ?? profile; + const profileAfterWebSearchToolName = normalizeFusionWebSearchProfileToolName(profileAfterVision) ?? profileAfterVision; + return withFusionWebSearchToolInstructions(profileAfterWebSearchToolName) ?? profileAfterWebSearchToolName; } function rewriteModelSelectorForCoreGatewayProfile( @@ -1241,18 +1347,19 @@ function hasOwn(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } -function fusionBuiltinToolArtifacts( +async function fusionBuiltinToolArtifacts( profiles: unknown[], - coreEndpoint: string -): { mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] } { + coreEndpoint: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { const providers: CoreGatewayProvider[] = []; const mcpServers: GatewayMcpServerConfig[] = []; const toolServerKeys = new Set(); const entry = bundledFusionBuiltinMcpEntryPath(); - profiles.forEach((profile, index) => { + for (const [index, profile] of profiles.entries()) { if (!isRecord(profile) || profile.enabled === false) { - return; + continue; } const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; const profileId = stringValue(profile.id) || stringValue(profile.key) || `fusion-${index + 1}`; @@ -1285,21 +1392,35 @@ function fusionBuiltinToolArtifacts( const toolServerKey = `web_search:${webSearchConfig.toolName}`; if (!toolServerKeys.has(toolServerKey)) { toolServerKeys.add(toolServerKey); - mcpServers.push(fusionBuiltinMcpServer({ - entry, - env: { - FUSION_BUILTIN_TOOL_KIND: "web_search", - FUSION_TOOL_NAME: webSearchConfig.toolName, - SEARCH_PROVIDER: webSearchConfig.provider ?? defaultFusionWebSearchProvider, - ...(webSearchConfig.resultCount ? { SEARCH_RESULT_COUNT: String(webSearchConfig.resultCount) } : {}), - ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), - ...(webSearchConfig.env ?? {}) - }, - name: `fusion-web-search-${sanitizedProfileId}` - })); + const provider = webSearchConfig.provider ?? defaultFusionWebSearchProvider; + if (provider === "browser") { + const browserMcpServer = await browserWebSearchMcpIntegration?.registerBrowserWebSearchMcpServer({ + env: webSearchConfig.env ?? {}, + name: `fusion-browser-web-search-${sanitizedProfileId}`, + resultCount: webSearchConfig.resultCount, + timeoutMs: webSearchConfig.timeoutMs, + toolName: webSearchConfig.toolName + }); + if (browserMcpServer) { + mcpServers.push(browserMcpServer); + } + } else { + mcpServers.push(fusionBuiltinMcpServer({ + entry, + env: { + FUSION_BUILTIN_TOOL_KIND: "web_search", + FUSION_TOOL_NAME: webSearchConfig.toolName, + SEARCH_PROVIDER: provider, + ...(webSearchConfig.resultCount ? { SEARCH_RESULT_COUNT: String(webSearchConfig.resultCount) } : {}), + ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), + ...(webSearchConfig.env ?? {}) + }, + name: `fusion-web-search-${sanitizedProfileId}` + })); + } } } - }); + } return { mcpServers, providers }; } @@ -1337,7 +1458,7 @@ function fusionToolFallbackMcpServer( profiles: unknown[], existingServers: unknown[] ): GatewayMcpServerConfig | undefined { - const tools = fusionFallbackToolDefinitions(profiles); + const tools = fusionFallbackToolDefinitions(profiles, fusionToolNamesBackedByMcpServers(existingServers)); if (tools.length === 0) { return undefined; } @@ -1362,8 +1483,9 @@ function bundledFusionToolFallbackMcpEntryPath(): string { return pathJoin(__dirname, "fusion-tool-fallback-mcp.js"); } -function fusionFallbackToolDefinitions( - profiles: unknown[] +export function fusionFallbackToolDefinitions( + profiles: unknown[], + backedToolNames: Set = new Set() ): Array<{ description?: string; inputSchema?: Record; name: string }> { const byName = new Map; name: string }>(); @@ -1379,6 +1501,9 @@ function fusionFallbackToolDefinitions( if (!name) { continue; } + if (backedToolNames.has(name)) { + continue; + } const existing = byName.get(name); const description = stringValue(tool.description); @@ -1408,6 +1533,26 @@ function fusionFallbackToolDefinitions( return [...byName.values()]; } +export function fusionToolNamesBackedByMcpServers(servers: unknown[]): Set { + const names = new Set(); + for (const server of servers) { + if (!isRecord(server)) { + continue; + } + const serverName = stringValue(server.name); + if (serverName) { + names.add(serverName); + } + + const env = isRecord(server.env) ? server.env : undefined; + const fusionToolName = stringValue(env?.FUSION_TOOL_NAME); + if (fusionToolName) { + names.add(fusionToolName); + } + } + return names; +} + function uniqueMcpServerName(baseName: string, servers: unknown[]): string { const used = new Set( servers @@ -1517,7 +1662,7 @@ function legacyFusionBuiltinToolName( const tools = Array.isArray(profile.tools) ? profile.tools : []; const toolName = tools .map((tool) => isRecord(tool) ? stringValue(tool.name) ?? "" : "") - .find((name) => name === baseToolName || name.startsWith(`${baseToolName}_`)); + .find((name) => fusionBuiltinToolNameMatches(name, baseToolName)); if (toolName) { return toolName; } @@ -1525,6 +1670,133 @@ function legacyFusionBuiltinToolName( return execution[executionFlag] === true ? baseToolName : undefined; } +function fusionBuiltinToolNameMatches(name: string, baseToolName: string): boolean { + if (name === baseToolName || name.startsWith(`${baseToolName}_`)) { + return true; + } + if (baseToolName !== BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME) { + return false; + } + return coreGatewayWebSearchToolNameMatches(name); +} + +function normalizeFusionWebSearchProfileToolName(profile: Record): Record | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const configuredToolName = stringValue(fusionWebSearch?.toolName); + const legacyToolName = configuredToolName ? undefined : legacyFusionWebSearchConfig(profile)?.toolName; + const toolName = configuredToolName || legacyToolName; + if (!toolName) { + return undefined; + } + + const nextToolName = coreGatewayCompatibleWebSearchToolName(toolName, stringValue(profile.key) || stringValue(profile.id)); + if (nextToolName === toolName) { + return undefined; + } + + const tools = Array.isArray(profile.tools) + ? profile.tools.map((tool) => { + if (!isRecord(tool) || stringValue(tool.name) !== toolName) { + return tool; + } + return { + ...tool, + name: nextToolName + }; + }) + : profile.tools; + + return { + ...profile, + ...(metadata && fusionWebSearch + ? { + metadata: { + ...metadata, + fusionWebSearch: { + ...fusionWebSearch, + toolName: nextToolName + } + } + } + : {}), + ...(tools ? { tools } : {}) + }; +} + +function withFusionWebSearchToolInstructions(profile: Record): Record | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const toolName = stringValue(fusionWebSearch?.toolName) || legacyFusionWebSearchConfig(profile)?.toolName; + if (!toolName) { + return undefined; + } + const execution = isRecord(profile.execution) ? profile.execution : {}; + if (execution.matchWebSearch !== true) { + return undefined; + } + + const instruction = [ + `When the client request includes a hosted web_search tool declaration, call the ${toolName} function tool before answering.`, + "Pass the user's search query in the prompt field.", + "Do not use provider-native web search or claim that web search is unavailable unless this function tool returns an error." + ].join(" "); + const instructions = isRecord(profile.instructions) ? profile.instructions : {}; + if ([instructions.prepend, instructions.append, instructions.replace].some((value) => stringValue(value)?.includes(instruction))) { + return undefined; + } + const replace = stringValue(instructions.replace); + const append = stringValue(instructions.append); + return { + ...profile, + instructions: { + ...instructions, + ...(replace + ? { replace: `${replace.trim()}\n\n${instruction}` } + : { append: [append, instruction].filter(Boolean).join("\n\n") }) + } + }; +} + +function coreGatewayCompatibleWebSearchToolName(toolName: string, fallbackName?: string): string { + if (coreGatewayWebSearchToolNameMatches(toolName)) { + return toolName; + } + + const normalized = sanitizeFusionToolName(toolName); + const prefix = `${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`; + if (normalized.startsWith(prefix) && normalized.length > prefix.length) { + return truncateFusionToolName(`${normalized.slice(prefix.length)}_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`); + } + + const fallback = sanitizeFusionToolName(fallbackName || normalized || "fusion"); + return truncateFusionToolName(`${fallback}_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`); +} + +function coreGatewayWebSearchToolNameMatches(name: string): boolean { + const normalized = name.toLowerCase().replace(/[-.]/g, "_"); + return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || + normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) || + normalized.includes("search_web"); +} + +function sanitizeFusionToolName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") || "fusion"; +} + +function truncateFusionToolName(value: string): string { + const maxToolNameLength = 64; + if (value.length <= maxToolNameLength) { + return value; + } + const suffix = `_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`; + const available = Math.max(1, maxToolNameLength - suffix.length); + return `${value.slice(0, available).replace(/_+$/g, "")}${suffix}`; +} + function readFusionVisionConfig(value: unknown): VirtualModelFusionVisionConfig | undefined { if (!isRecord(value)) { return undefined; @@ -1630,7 +1902,8 @@ function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSear normalized === "serper" || normalized === "serpapi" || normalized === "tavily" || - normalized === "exa" + normalized === "exa" || + normalized === "browser" ) { return normalized; } @@ -2312,6 +2585,1811 @@ export function transformCodexApplyPatchBridgeSseEvent(block: string): string { } } +function createHostedWebSearchProtocolContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + requestId: string; + routedModel?: string; + sinceMs: number; +}): HostedWebSearchProtocolContext | undefined { + const protocol = requestProtocolForPath(input.path); + if (input.method !== "POST" || !protocol) { + return undefined; + } + const body = parseJsonObjectSafe(input.body); + if (!body || !hasHostedWebSearchDeclaration(body, protocol)) { + return undefined; + } + const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel); + if (!toolName) { + return undefined; + } + return { + maxUses: readHostedWebSearchMaxUses(body, protocol), + protocol, + queryHint: extractHostedWebSearchQueryHint(body, protocol), + requestId: input.requestId, + sinceMs: input.sinceMs, + toolName + }; +} + +function createAnthropicWebSearchProtocolContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + requestId: string; + sinceMs: number; +}): AnthropicWebSearchProtocolContext | undefined { + const context = createHostedWebSearchProtocolContext(input); + return context?.protocol === "anthropic_messages" ? context : undefined; +} + +export function prepareHostedWebSearchProtocolRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + if (context.protocol === "anthropic_messages") { + return prepareAnthropicWebSearchProtocolRequestBody(body, records, context); + } + const parsed = parseJsonObjectSafe(body); + if (!parsed || records.length === 0) { + return undefined; + } + const evidence = hostedWebSearchEvidenceText(records, context.queryHint); + if (!evidence) { + return undefined; + } + let next: Record | undefined; + if (context.protocol === "openai_chat_completions") { + next = prepareOpenAiChatHostedWebSearchRequestBody(parsed, evidence); + } else if (context.protocol === "openai_responses") { + next = prepareOpenAiResponsesHostedWebSearchRequestBody(parsed, evidence); + } else if (context.protocol === "gemini_generate_content") { + next = prepareGeminiHostedWebSearchRequestBody(parsed, evidence); + } + return next ? Buffer.from(`${JSON.stringify(next)}\n`, "utf8") : undefined; +} + +export function prepareAnthropicWebSearchProtocolRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + const parsed = parseJsonObjectSafe(body); + if (!parsed || records.length === 0) { + return undefined; + } + const evidence = hostedWebSearchEvidenceText(records, context.queryHint); + if (!evidence) { + return undefined; + } + const next = applyAnthropicWebSearchSynthesisControls(stripAnthropicHostedWebSearchTools({ + ...parsed, + system: appendAnthropicSystemText(parsed.system, evidence) + })); + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); +} + +function applyAnthropicWebSearchSynthesisControls(body: Record): Record { + const next = { ...body }; + const outputConfig = isRecord(next.output_config) ? { ...next.output_config } : {}; + outputConfig.effort = "low"; + next.output_config = outputConfig; + delete next.thinking; + delete next.reasoning; + return next; +} + +function prepareOpenAiChatHostedWebSearchRequestBody(body: Record, evidence: string): Record { + const next = stripOpenAiHostedWebSearchTools({ + ...body, + messages: appendOpenAiChatSystemText(body.messages, evidence) + }); + return applyOpenAiHostedWebSearchSynthesisControls(next); +} + +function prepareOpenAiResponsesHostedWebSearchRequestBody(body: Record, evidence: string): Record { + const next = stripOpenAiHostedWebSearchTools({ + ...body, + instructions: appendStringInstruction(body.instructions, evidence) + }); + return applyOpenAiHostedWebSearchSynthesisControls(next); +} + +function prepareGeminiHostedWebSearchRequestBody(body: Record, evidence: string): Record { + return stripGeminiHostedWebSearchTools({ + ...body, + systemInstruction: appendGeminiSystemInstruction(body.systemInstruction, evidence) + }); +} + +function applyOpenAiHostedWebSearchSynthesisControls(body: Record): Record { + const next = { ...body }; + if (typeof next.reasoning_effort === "string") { + next.reasoning_effort = "low"; + } + if (isRecord(next.reasoning)) { + next.reasoning = { ...next.reasoning, effort: "low" }; + } + return next; +} + +function stripAnthropicHostedWebSearchTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + const tools = body.tools.filter((tool) => !isAnthropicHostedWebSearchTool(tool)); + if (tools.length === body.tools.length) { + return body; + } + const next = { ...body }; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + const toolChoice = isRecord(next.tool_choice) ? next.tool_choice : undefined; + const toolChoiceName = stringValue(toolChoice?.name); + if (tools.length === 0 || toolChoiceName === "web_search") { + delete next.tool_choice; + } + return next; +} + +function stripOpenAiHostedWebSearchTools(body: Record): Record { + const next = { ...body }; + let removedTools = false; + if (Array.isArray(body.tools)) { + const tools = body.tools.filter((tool) => !isOpenAiHostedWebSearchTool(tool)); + removedTools = tools.length !== body.tools.length; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + } + if (next.web_search_options !== undefined || next.webSearchOptions !== undefined) { + delete next.web_search_options; + delete next.webSearchOptions; + removedTools = true; + } + if (removedTools && (!Array.isArray(next.tools) || next.tools.length === 0 || openAiToolChoiceNamesWebSearch(next.tool_choice))) { + delete next.tool_choice; + delete next.parallel_tool_calls; + } + return next; +} + +function stripGeminiHostedWebSearchTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + let changed = false; + const tools = body.tools.flatMap((tool) => { + const transformed = stripGeminiHostedWebSearchTool(tool); + changed ||= transformed.changed; + return transformed.value ? [transformed.value] : []; + }); + if (!changed) { + return body; + } + const next = { ...body }; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + return next; +} + +function stripGeminiHostedWebSearchTool(tool: unknown): { changed: boolean; value?: unknown } { + if (!isRecord(tool)) { + return { changed: false, value: tool }; + } + let changed = false; + const next: Record = { ...tool }; + for (const key of ["google_search", "googleSearch", "google_search_retrieval", "googleSearchRetrieval"]) { + if (key in next) { + delete next[key]; + changed = true; + } + } + return Object.keys(next).length === 0 ? { changed, value: undefined } : { changed, value: next }; +} + +function appendAnthropicSystemText(system: unknown, text: string): unknown { + if (typeof system === "string") { + return `${system.trimEnd()}\n\n${text}`; + } + const block = { text, type: "text" }; + if (Array.isArray(system)) { + return [...system, block]; + } + return [block]; +} + +function appendOpenAiChatSystemText(messages: unknown, text: string): unknown[] { + const message = { content: text, role: "system" }; + return Array.isArray(messages) ? [message, ...messages] : [message]; +} + +function appendStringInstruction(value: unknown, text: string): string { + const existing = rawStringValue(value); + return existing ? `${existing.trimEnd()}\n\n${text}` : text; +} + +function appendGeminiSystemInstruction(value: unknown, text: string): Record { + const part = { text }; + if (typeof value === "string") { + return { parts: [{ text: value }, part] }; + } + if (isRecord(value)) { + const parts = Array.isArray(value.parts) ? value.parts : []; + return { + ...value, + parts: [...parts, part] + }; + } + return { parts: [part] }; +} + +function hostedWebSearchEvidenceText(records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined): string { + const sections = records.flatMap((record, recordIndex) => { + const resultLines = record.results.slice(0, 8).map((result, resultIndex) => { + const content = focusedWebSearchContent(result.content, queryHint); + const details = [ + result.snippet ? `Search snippet: ${result.snippet}` : "", + content ? `Extracted page content: ${content}` : "" + ].filter(Boolean).join("\n"); + return [ + `${resultIndex + 1}. ${result.title}`, + `URL: ${result.url}`, + details + ].filter(Boolean).join("\n"); + }); + if (resultLines.length === 0) { + return []; + } + return [ + [ + `Search ${recordIndex + 1}`, + `Query: ${record.query}`, + `Engine: ${record.engine}`, + `Search URL: ${record.searchUrl}`, + ...resultLines + ].join("\n\n") + ]; + }); + if (sections.length === 0) { + return ""; + } + return [ + "A hidden in-app browser web search has already been performed for this request.", + "Use the evidence below to answer the user's question directly in the visible final response, within 5 concise sentences. Do not call another web search tool, do not merely list links, do not expose hidden reasoning, and do not ask the user to open links. If the evidence is insufficient for an exact value, say that clearly and summarize the most relevant findings with source names.", + queryHint ? `Original search intent: ${queryHint}` : "", + "Web search evidence:", + ...sections + ].filter(Boolean).join("\n\n").slice(0, 10_000); +} + +function focusedWebSearchContent(content: string | undefined, queryHint: string | undefined): string | undefined { + const text = content?.replace(/\s+/g, " ").trim(); + if (!text) { + return undefined; + } + const queryTerms = normalizeSearchComparisonText(queryHint ?? "") + .split(" ") + .filter((term) => term.length >= 2); + const weatherTerms = /天气|weather/i.test(queryHint ?? "") + ? ["天气", "气温", "温度", "体感", "空气质量", "湿度", "风", "降水", "℃", "晴", "多云", "阴", "雨"] + : []; + const terms = uniqueStrings([...queryTerms, ...weatherTerms]); + const indexes = terms.flatMap((term) => { + const index = text.toLowerCase().indexOf(term.toLowerCase()); + return index >= 0 ? [index] : []; + }); + if (indexes.length === 0) { + return text.slice(0, 1_000); + } + const center = Math.min(...indexes); + const start = Math.max(0, center - 300); + return `${start > 0 ? "..." : ""}${text.slice(start, start + 1_200)}${start + 1_200 < text.length ? "..." : ""}`; +} + +function hostedWebSearchProtocolResponseStream( + input: Readable, + headers: Headers, + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration | undefined +): Readable { + if (!integration?.recentBrowserWebSearchResults && !integration?.runBrowserWebSearch) { + return input; + } + const contentType = headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("text/event-stream")) { + return input; + } + if (!contentType.includes("application/json")) { + return input; + } + + const chunks: Buffer[] = []; + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + callback(); + }, + flush(callback) { + const body = Buffer.concat(chunks).toString("utf8"); + void (async () => { + const records = await selectHostedWebSearchProtocolRecords(context, integration); + if (records.length === 0) { + this.push(body); + return; + } + + const parsed = JSON.parse(body) as unknown; + const transformed = transformHostedWebSearchProtocolResponseValue(parsed, records, context); + this.push(transformed.changed ? JSON.stringify(transformed.value) : body); + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + this.push(body); + }).finally(() => callback()); + } + })); +} + +function transformHostedWebSearchProtocolResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): { changed: boolean; value: unknown } { + if (context.protocol === "anthropic_messages") { + return transformAnthropicWebSearchProtocolResponseValue(value, records, context.requestId, context.queryHint); + } + if (context.protocol === "openai_chat_completions") { + return transformOpenAiChatHostedWebSearchResponseValue(value, records, context.queryHint); + } + if (context.protocol === "openai_responses") { + return transformOpenAiResponsesHostedWebSearchResponseValue(value, records, context.requestId, context.queryHint); + } + if (context.protocol === "gemini_generate_content") { + return transformGeminiHostedWebSearchResponseValue(value, records, context.queryHint); + } + return { changed: false, value }; +} + +function transformHostedWebSearchProtocolSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): string { + if (context.protocol === "anthropic_messages") { + return transformAnthropicWebSearchProtocolSseText(body, records, context.requestId, context.queryHint); + } + if (context.protocol === "openai_chat_completions") { + return transformOpenAiChatHostedWebSearchSseText(body, records, context.queryHint); + } + if (context.protocol === "openai_responses") { + return transformOpenAiResponsesHostedWebSearchSseText(body, records, context.requestId, context.queryHint); + } + if (context.protocol === "gemini_generate_content") { + return transformGeminiHostedWebSearchSseText(body, records, context.queryHint); + } + return body; +} + +export function transformAnthropicWebSearchProtocolResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value) || !Array.isArray(value.content)) { + return { changed: false, value }; + } + const hasWebSearchBlocks = responseValueContainsAnthropicWebSearchBlocks(value); + const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); + const answer = responseValueContainsVisibleText(value) + ? undefined + : synthesizeWebSearchAnswer(records, queryHint); + const injectedBlocks = [ + ...blocks, + ...(answer ? [{ text: answer, type: "text" }] : []) + ]; + const shouldUpdateUsage = hasWebSearchBlocks || blocks.length > 0; + if (injectedBlocks.length === 0 && !shouldUpdateUsage) { + return { changed: false, value }; + } + const insertAt = webSearchProtocolInsertIndex(value.content, hasWebSearchBlocks); + const nextValue = { + ...value, + ...(shouldUpdateUsage ? { usage: mergeAnthropicWebSearchUsage(value.usage, records.length) } : {}), + ...(answer && stringValue(value.stop_reason) === "max_tokens" ? { stop_reason: "end_turn" } : {}), + content: injectedBlocks.length > 0 + ? [ + ...value.content.slice(0, insertAt), + ...injectedBlocks, + ...value.content.slice(insertAt) + ] + : value.content + }; + return { + changed: true, + value: nextValue + }; +} + +export function transformAnthropicWebSearchProtocolSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0) { + return body; + } + const hasWebSearchBlocks = sseEventsContainAnthropicWebSearchBlocks(events); + const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); + const answer = sseEventsContainVisibleText(events) + ? undefined + : synthesizeWebSearchAnswer(records, queryHint); + const injectedBlocks = [ + ...blocks, + ...(answer ? [{ text: answer, type: "text" }] : []) + ]; + const shouldUpdateUsage = hasWebSearchBlocks || blocks.length > 0; + if (injectedBlocks.length === 0 && !shouldUpdateUsage) { + return body; + } + + let maxIndex = -1; + for (const event of events) { + if (isRecord(event.data) && Number.isFinite(event.data.index)) { + maxIndex = Math.max(maxIndex, Number(event.data.index)); + } + } + + const firstTextIndex = events.findIndex((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const contentBlock = isRecord(data?.content_block) ? data.content_block : undefined; + return data?.type === "content_block_start" && contentBlock?.type === "text"; + }); + const messageEndIndex = events.findIndex((event) => { + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return type === "message_delta" || type === "message_stop"; + }); + const insertPosition = firstTextIndex >= 0 + ? firstTextIndex + : messageEndIndex >= 0 + ? messageEndIndex + : events.length; + const insertIndex = firstTextIndex >= 0 && isRecord(events[firstTextIndex].data) && Number.isFinite(events[firstTextIndex].data.index) + ? Number(events[firstTextIndex].data.index) + : maxIndex + 1; + + const shiftedEvents = events + .map((event) => shiftSseContentBlockIndex(event, insertIndex, injectedBlocks.length)) + .map((event) => updateAnthropicWebSearchSseUsage(event, records.length, Boolean(answer))); + const injectedEvents = injectedBlocks.flatMap((block, offset) => anthropicWebSearchSseEventsForBlock(block, insertIndex + offset)); + shiftedEvents.splice(insertPosition, 0, ...injectedEvents); + return `${shiftedEvents.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +export function transformOpenAiChatHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value) || openAiChatResponseContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer || !Array.isArray(value.choices) || value.choices.length === 0) { + return { changed: false, value }; + } + const choices = value.choices.map((choice, index) => { + if (!isRecord(choice) || index !== 0) { + return choice; + } + const message = isRecord(choice.message) ? choice.message : {}; + return { + ...choice, + finish_reason: stringValue(choice.finish_reason) === "length" ? "stop" : choice.finish_reason, + message: { + ...message, + content: answer, + role: stringValue(message.role) || "assistant" + } + }; + }); + return { + changed: true, + value: { + ...value, + choices + } + }; +} + +export function transformOpenAiChatHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0 || openAiChatSseContainsVisibleText(events)) { + return body; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return body; + } + const template = firstSseDataRecord(events); + const injected = sseEventFromValue({ + ...(template?.id ? { id: template.id } : {}), + ...(template?.model ? { model: template.model } : {}), + object: stringValue(template?.object) || "chat.completion.chunk", + choices: [ + { + delta: { content: answer }, + finish_reason: null, + index: 0 + } + ] + }); + const shifted = events.map((event) => updateOpenAiChatSseFinishReason(event)); + const insertAt = doneSseEventIndex(shifted); + shifted.splice(insertAt >= 0 ? insertAt : shifted.length, 0, injected); + return `${shifted.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +export function transformOpenAiResponsesHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value)) { + return { changed: false, value }; + } + const output = Array.isArray(value.output) ? value.output : []; + const hasSearchCall = output.some((item) => isRecord(item) && stringValue(item.type) === "web_search_call"); + const answer = openAiResponsesValueContainsVisibleText(value) ? undefined : synthesizeWebSearchAnswer(records, queryHint); + const injected = [ + ...(hasSearchCall ? [] : openAiResponsesWebSearchCallItems(records, requestId)), + ...(answer ? [openAiResponsesMessageItem(answer, requestId)] : []) + ]; + if (injected.length === 0) { + return { changed: false, value }; + } + const next: Record = { + ...value, + output: [...injected, ...output] + }; + if (answer && stringValue(next.status) === "incomplete") { + next.status = "completed"; + delete next.incomplete_details; + } + return { changed: true, value: next }; +} + +export function transformOpenAiResponsesHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0) { + return body; + } + const visibleText = openAiResponsesSseVisibleText(events); + if (visibleText) { + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(events, visibleText)); + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(events)); + } + const outputIndex = nextOpenAiResponsesOutputIndex(events); + const itemId = `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`; + const contentIndex = 0; + const injected = [ + sseEventFromValue({ + item: { + id: itemId, + role: "assistant", + status: "in_progress", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.added" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: "", type: "output_text" }, + type: "response.content_part.added" + }), + sseEventFromValue({ + content_index: contentIndex, + delta: answer, + item_id: itemId, + output_index: outputIndex, + type: "response.output_text.delta" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + text: answer, + type: "response.output_text.done" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: answer, type: "output_text" }, + type: "response.content_part.done" + }), + sseEventFromValue({ + item: { + content: [{ annotations: [], text: answer, type: "output_text" }], + id: itemId, + role: "assistant", + status: "completed", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.done" + }) + ]; + const shifted = events.map(updateOpenAiResponsesCompletedStatus); + const insertAt = shifted.findIndex((event) => isRecord(event.data) && stringValue(event.data.type) === "response.completed"); + shifted.splice(insertAt >= 0 ? insertAt : doneSseEventIndex(shifted) >= 0 ? doneSseEventIndex(shifted) : shifted.length, 0, ...injected); + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(shifted, answer)); +} + +export function transformGeminiHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): { changed: boolean; value: unknown } { + if (Array.isArray(value)) { + if (geminiResponseArrayContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + return answer + ? { changed: true, value: [...value, geminiAnswerCandidateChunk(answer)] } + : { changed: false, value }; + } + if (!isRecord(value) || geminiResponseValueContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return { changed: false, value }; + } + const candidates = Array.isArray(value.candidates) ? value.candidates : []; + const nextCandidate = candidates.length > 0 && isRecord(candidates[0]) + ? { + ...candidates[0], + content: { + ...(isRecord(candidates[0].content) ? candidates[0].content : {}), + parts: [{ text: answer }], + role: "model" + }, + finishReason: stringValue(candidates[0].finishReason) === "MAX_TOKENS" ? "STOP" : candidates[0].finishReason + } + : geminiAnswerCandidate(answer); + return { + changed: true, + value: { + ...value, + candidates: [nextCandidate, ...candidates.slice(1)] + } + }; +} + +export function transformGeminiHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0 || geminiSseContainsVisibleText(events)) { + return body; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return body; + } + const injected = sseEventFromValue(geminiAnswerCandidateChunk(answer)); + const insertAt = doneSseEventIndex(events); + events.splice(insertAt >= 0 ? insertAt : events.length, 0, injected); + return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +function openAiChatResponseContainsVisibleText(value: Record): boolean { + if (!Array.isArray(value.choices)) { + return false; + } + return value.choices.some((choice) => { + const message = isRecord(choice) && isRecord(choice.message) ? choice.message : undefined; + return Boolean(stringValue(message?.content)?.trim()); + }); +} + +function openAiChatSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + const choices = isRecord(event.data) && Array.isArray(event.data.choices) ? event.data.choices : []; + return choices.some((choice) => { + const delta = isRecord(choice) && isRecord(choice.delta) ? choice.delta : undefined; + return Boolean(stringValue(delta?.content)?.trim()); + }); + }); +} + +function updateOpenAiChatSseFinishReason(event: ParsedSseEvent): ParsedSseEvent { + if (!isRecord(event.data) || !Array.isArray(event.data.choices)) { + return event; + } + let changed = false; + const choices = event.data.choices.map((choice) => { + if (!isRecord(choice) || stringValue(choice.finish_reason) !== "length") { + return choice; + } + changed = true; + return { ...choice, finish_reason: "stop" }; + }); + return changed ? { ...event, data: { ...event.data, choices } } : event; +} + +function openAiResponsesValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.output) && value.output.some(openAiResponsesItemContainsVisibleText); +} + +function openAiResponsesItemContainsVisibleText(item: unknown): boolean { + if (!isRecord(item)) { + return false; + } + if (stringValue(item.type) === "message" && Array.isArray(item.content)) { + return item.content.some((part) => isRecord(part) && Boolean(stringValue(part.text)?.trim())); + } + return Boolean(stringValue(item.text)?.trim()); +} + +function openAiResponsesSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return Boolean(openAiResponsesSseVisibleText(events)); +} + +function openAiResponsesSseVisibleText(events: ParsedSseEvent[]): string | undefined { + let deltaText = ""; + let doneText = ""; + let itemText = ""; + for (const event of events) { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + continue; + } + const type = stringValue(data.type); + if (type === "response.output_text.delta") { + deltaText += stringValue(data.delta) ?? ""; + continue; + } + if (type === "response.output_text.done") { + doneText = stringValue(data.text) ?? doneText; + continue; + } + const item = isRecord(data.item) ? data.item : undefined; + if (item && openAiResponsesItemContainsVisibleText(item)) { + itemText = openAiResponsesItemText(item) ?? itemText; + } + } + return nonEmptyText(deltaText) ?? nonEmptyText(doneText) ?? nonEmptyText(itemText); +} + +function openAiResponsesItemText(item: unknown): string | undefined { + if (!isRecord(item)) { + return undefined; + } + if (stringValue(item.type) === "message" && Array.isArray(item.content)) { + const text = item.content + .flatMap((part) => isRecord(part) ? [stringValue(part.text) ?? ""] : []) + .join(""); + return text.trim() ? text : undefined; + } + return stringValue(item.text); +} + +function nonEmptyText(value: string | undefined): string | undefined { + return value && value.trim() ? value : undefined; +} + +function openAiResponsesWebSearchCallItems(records: BrowserWebSearchProtocolRecord[], requestId: string): Record[] { + return records.map((record, index) => ({ + action: { + query: record.query, + type: "search" + }, + id: `ws_${sanitizeAnthropicToolUseId(requestId)}_${index + 1}`, + status: "completed", + type: "web_search_call" + })); +} + +function openAiResponsesMessageItem(answer: string, requestId: string): Record { + return { + content: [{ annotations: [], text: answer, type: "output_text" }], + id: `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`, + role: "assistant", + status: "completed", + type: "message" + }; +} + +function nextOpenAiResponsesOutputIndex(events: ParsedSseEvent[]): number { + const indexes = events.flatMap((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const index = numberValue(data?.output_index); + return index === undefined ? [] : [index]; + }); + return indexes.length === 0 ? 0 : Math.max(...indexes) + 1; +} + +function updateOpenAiResponsesCompletedStatus(event: ParsedSseEvent): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { + return event; + } + const response = isRecord(event.data.response) ? event.data.response : undefined; + if (!response || stringValue(response.status) !== "incomplete") { + return event; + } + const nextResponse: Record = { ...response, status: "completed" }; + delete nextResponse.incomplete_details; + return { + ...event, + data: { + ...event.data, + response: nextResponse + } + }; +} + +function normalizeOpenAiResponsesSseEvents(events: ParsedSseEvent[], visibleText?: string): ParsedSseEvent[] { + const outputIndexMap = openAiResponsesOutputIndexMap(events); + return events.flatMap((event) => { + if (isOpenAiResponsesReasoningSseEvent(event)) { + return []; + } + return [updateOpenAiResponsesCompletedOutput( + remapOpenAiResponsesOutputIndex(event, outputIndexMap), + visibleText + )]; + }); +} + +function serializeOpenAiResponsesSseEvents(events: ParsedSseEvent[]): string { + return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +function openAiResponsesOutputIndexMap(events: ParsedSseEvent[]): Map { + const indexes: number[] = []; + for (const event of events) { + if (isOpenAiResponsesReasoningSseEvent(event)) { + continue; + } + const data = isRecord(event.data) ? event.data : undefined; + const index = numberValue(data?.output_index); + if (index !== undefined && !indexes.includes(index)) { + indexes.push(index); + } + } + return new Map(indexes.sort((left, right) => left - right).map((index, nextIndex) => [index, nextIndex])); +} + +function remapOpenAiResponsesOutputIndex(event: ParsedSseEvent, outputIndexMap: Map): ParsedSseEvent { + if (!isRecord(event.data)) { + return event; + } + const index = numberValue(event.data.output_index); + if (index === undefined || !outputIndexMap.has(index)) { + return event; + } + return { + ...event, + data: { + ...event.data, + output_index: outputIndexMap.get(index) + } + }; +} + +function updateOpenAiResponsesCompletedOutput(event: ParsedSseEvent, visibleText?: string): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { + return event; + } + const response = isRecord(event.data.response) ? event.data.response : undefined; + if (!response) { + return event; + } + const nextResponse: Record = { ...response }; + if (Array.isArray(response.output)) { + nextResponse.output = response.output.filter((item) => !(isRecord(item) && stringValue(item.type) === "reasoning")); + } + if (visibleText) { + nextResponse.output_text = visibleText; + } + if (visibleText && stringValue(nextResponse.status) === "incomplete") { + nextResponse.status = "completed"; + delete nextResponse.incomplete_details; + } + return { + ...event, + data: { + ...event.data, + response: nextResponse + } + }; +} + +function isOpenAiResponsesReasoningSseEvent(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + return false; + } + const type = stringValue(data.type); + if (type?.startsWith("response.reasoning")) { + return true; + } + const item = isRecord(data.item) ? data.item : undefined; + if (stringValue(item?.type) === "reasoning") { + return true; + } + const part = isRecord(data.part) ? data.part : undefined; + return stringValue(part?.type) === "reasoning_text"; +} + +function geminiResponseValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.candidates) && value.candidates.some(geminiCandidateContainsVisibleText); +} + +function geminiResponseArrayContainsVisibleText(values: unknown[]): boolean { + return values.some((value) => isRecord(value) && geminiResponseValueContainsVisibleText(value)); +} + +function geminiCandidateContainsVisibleText(candidate: unknown): boolean { + const content = isRecord(candidate) && isRecord(candidate.content) ? candidate.content : undefined; + const parts = Array.isArray(content?.parts) ? content.parts : []; + return parts.some((part) => isRecord(part) && Boolean(stringValue(part.text)?.trim())); +} + +function geminiSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => isRecord(event.data) && geminiResponseValueContainsVisibleText(event.data)); +} + +function geminiAnswerCandidate(answer: string): Record { + return { + content: { + parts: [{ text: answer }], + role: "model" + }, + finishReason: "STOP", + index: 0 + }; +} + +function geminiAnswerCandidateChunk(answer: string): Record { + return { + candidates: [geminiAnswerCandidate(answer)] + }; +} + +function firstSseDataRecord(events: ParsedSseEvent[]): Record | undefined { + return events.map((event) => isRecord(event.data) ? event.data : undefined).find(Boolean); +} + +function doneSseEventIndex(events: ParsedSseEvent[]): number { + return events.findIndex((event) => event.raw?.includes("[DONE]")); +} + +function hasAnthropicHostedWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some(isAnthropicHostedWebSearchTool); +} + +function hasHostedWebSearchDeclaration(body: Record, protocol: GatewayProviderProtocol): boolean { + if (protocol === "anthropic_messages") { + return hasAnthropicHostedWebSearchTool(body.tools); + } + if (protocol === "openai_chat_completions" || protocol === "openai_responses") { + return hasOpenAiHostedWebSearchDeclaration(body); + } + if (protocol === "gemini_generate_content") { + return hasGeminiHostedWebSearchTool(body.tools); + } + return false; +} + +function hasOpenAiHostedWebSearchDeclaration(body: Record): boolean { + if (body.web_search_options !== undefined || body.webSearchOptions !== undefined) { + return true; + } + return Array.isArray(body.tools) && body.tools.some(isOpenAiHostedWebSearchTool); +} + +function hasGeminiHostedWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some((tool) => { + if (!isRecord(tool)) { + return false; + } + if (tool.google_search !== undefined || tool.googleSearch !== undefined || tool.google_search_retrieval !== undefined || tool.googleSearchRetrieval !== undefined) { + return true; + } + return false; + }); +} + +function isAnthropicHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return anthropicHostedWebSearchType(stringValue(tool.type)); +} + +function isOpenAiHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return openAiHostedWebSearchType(stringValue(tool.type)); +} + +function openAiToolChoiceNamesWebSearch(value: unknown): boolean { + if (typeof value === "string") { + return openAiHostedWebSearchType(value); + } + if (!isRecord(value)) { + return false; + } + return openAiHostedWebSearchType(stringValue(value.type)); +} + +function anthropicHostedWebSearchType(value: string | undefined): boolean { + const normalized = normalizedToolProtocolName(value); + return normalized === "web_search" || normalized === "web_search_20250305"; +} + +function openAiHostedWebSearchType(value: string | undefined): boolean { + const normalized = normalizedToolProtocolName(value); + return normalized === "web_search" || + normalized === "web_search_preview" || + normalized.startsWith("web_search_preview_"); +} + +function normalizedToolProtocolName(value: string | undefined): string { + return value?.trim().toLowerCase().replace(/[-.]/g, "_") ?? ""; +} + +function readAnthropicWebSearchMaxUses(tools: unknown): number | undefined { + if (!Array.isArray(tools)) { + return undefined; + } + const tool = tools.find((item) => isRecord(item) && stringValue(item.type)?.toLowerCase() === "web_search_20250305"); + return isRecord(tool) ? numberValue(tool.max_uses ?? tool.maxUses) : undefined; +} + +function readHostedWebSearchMaxUses(body: Record, protocol: GatewayProviderProtocol): number | undefined { + if (protocol === "anthropic_messages") { + return readAnthropicWebSearchMaxUses(body.tools); + } + if (protocol === "openai_chat_completions" || protocol === "openai_responses") { + const tool = Array.isArray(body.tools) ? body.tools.find(isOpenAiHostedWebSearchTool) : undefined; + return isRecord(tool) ? numberValue(tool.max_uses ?? tool.maxUses) : undefined; + } + return undefined; +} + +export function extractHostedWebSearchQueryHint(body: Record, protocol: GatewayProviderProtocol): string | undefined { + if (protocol === "anthropic_messages") { + return extractAnthropicWebSearchQueryHint(body); + } + if (protocol === "openai_chat_completions") { + return normalizedWebSearchQueryHintFromParts(textPartsFromOpenAiChatMessages(body.messages)); + } + if (protocol === "openai_responses") { + return normalizedWebSearchQueryHintFromParts(textPartsFromOpenAiResponsesInput(body.input)); + } + if (protocol === "gemini_generate_content") { + return normalizedWebSearchQueryHintFromParts(textPartsFromGeminiContents(body.contents)); + } + return undefined; +} + +function extractAnthropicWebSearchQueryHint(body: Record): string | undefined { + const userTexts = Array.isArray(body.messages) + ? body.messages.flatMap((message) => { + if (!isRecord(message) || stringValue(message.role) !== "user") { + return []; + } + return textPartsFromAnthropicContent(message.content); + }) + : []; + return normalizedWebSearchQueryHintFromParts(userTexts); +} + +function normalizedWebSearchQueryHintFromParts(parts: string[]): string | undefined { + const candidates = parts + .map((part) => part.trim()) + .filter(Boolean); + for (const candidate of [...candidates].reverse()) { + const explicit = extractExplicitWebSearchQuery(candidate); + if (explicit) { + return normalizedWebSearchQueryHint(explicit); + } + } + for (const candidate of [...candidates].reverse()) { + if (isRuntimeContextText(candidate)) { + continue; + } + return normalizedWebSearchQueryHint(stripSearchIntentPrefix(candidate)); + } + return normalizedWebSearchQueryHint(candidates.join("\n")); +} + +function normalizedWebSearchQueryHint(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const joined = value.trim(); + if (!joined) { + return undefined; + } + return joined.trim().slice(0, 500); +} + +function extractExplicitWebSearchQuery(value: string): string | undefined { + const explicit = /perform\s+a\s+web\s+search\s+for\s+the\s+query:\s*([\s\S]+)$/i.exec(value.trim()); + return normalizedWebSearchQueryHint(explicit?.[1]); +} + +function stripSearchIntentPrefix(value: string): string { + const trimmed = value.trim(); + const match = /^(?:请)?(?:帮我)?(?:搜索|查询|查一下|帮我查一下|搜一下)\s*[::]?\s*([\s\S]+)$/i.exec(trimmed); + return (match?.[1] || trimmed).trim(); +} + +function isRuntimeContextText(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (/^<(?:environment_context|permissions instructions|collaboration_mode|skills_instructions|plugins_instructions|apps_instructions)>/i.test(trimmed)) { + return true; + } + return ( + trimmed.includes("") || + trimmed.includes("") || + trimmed.includes("") || + trimmed.includes("") + ); +} + +function textPartsFromAnthropicContent(content: unknown): string[] { + if (typeof content === "string") { + return [content]; + } + if (!Array.isArray(content)) { + return []; + } + return content.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); +} + +function textPartsFromOpenAiChatMessages(messages: unknown): string[] { + if (!Array.isArray(messages)) { + return []; + } + return messages.flatMap((message) => { + if (!isRecord(message) || stringValue(message.role)?.toLowerCase() !== "user") { + return []; + } + return textPartsFromOpenAiContent(message.content); + }); +} + +function textPartsFromOpenAiContent(content: unknown): string[] { + if (typeof content === "string") { + return [content]; + } + if (!Array.isArray(content)) { + return []; + } + return content.flatMap((part) => { + if (!isRecord(part)) { + return []; + } + const type = stringValue(part.type); + if (type === "text" || type === "input_text" || type === "output_text") { + return stringValue(part.text) ? [stringValue(part.text) as string] : []; + } + return []; + }); +} + +function textPartsFromOpenAiResponsesInput(input: unknown): string[] { + if (typeof input === "string") { + return [input]; + } + if (!Array.isArray(input)) { + return []; + } + return input.flatMap((item) => { + if (!isRecord(item)) { + return []; + } + const role = stringValue(item.role)?.toLowerCase(); + if (role && role !== "user") { + return []; + } + return textPartsFromOpenAiContent(item.content); + }); +} + +function textPartsFromGeminiContents(contents: unknown): string[] { + if (!Array.isArray(contents)) { + return []; + } + return contents.flatMap((content) => { + if (!isRecord(content)) { + return []; + } + const role = stringValue(content.role)?.toLowerCase(); + if (role && role !== "user") { + return []; + } + const parts = Array.isArray(content.parts) ? content.parts : []; + return parts.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); + }); +} + +export function fusionWebSearchToolNameForRequest(config: AppConfig, model: string | undefined): string | undefined { + const normalizedModel = model ? fusionModelNameFromSelector(model) : ""; + const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; + const profiles = normalizeCoreGatewayVirtualModelProfiles( + withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), + config + ); + for (const profile of profiles) { + if (!isRecord(profile) || profile.enabled === false) { + continue; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (!webSearchConfig?.toolName || webSearchConfig.provider !== "browser") { + continue; + } + const match = isRecord(profile.match) ? profile.match : undefined; + const aliases = uniqueStrings([ + stringValue(profile.id), + stringValue(profile.key), + stringValue(profile.displayName), + ...stringListValue(match?.exactAliases) + ].filter((item): item is string => Boolean(item))); + if (!normalizedModel || aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { + return webSearchConfig.toolName; + } + } + return undefined; +} + +async function selectHostedWebSearchProtocolRecords( + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Promise { + const records = [ + ...(context.records ?? []), + ...(integration.recentBrowserWebSearchResults?.({ sinceMs: context.sinceMs, toolName: context.toolName }) ?? []) + ] + .filter((record) => record.results.length > 0) + .filter(uniqueSearchRecordFilter()) + .sort((left, right) => { + const queryScoreDelta = queryMatchScore(context.queryHint, right.query) - queryMatchScore(context.queryHint, left.query); + return queryScoreDelta || left.completedAtMs - right.completedAtMs; + }); + if (records.length > 0) { + return records.slice(0, 8); + } + if (!context.queryHint || !integration.runBrowserWebSearch) { + return []; + } + const record = await integration.runBrowserWebSearch({ + count: Math.trunc(clampNumber(context.maxUses ?? 5, 1, 10)), + prompt: context.queryHint, + timeoutMs: 30_000, + toolName: context.toolName + }); + return record?.results.length ? [record] : []; +} + +async function selectAnthropicWebSearchProtocolRecords( + context: AnthropicWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Promise { + return selectHostedWebSearchProtocolRecords(context, integration); +} + +function uniqueSearchRecordFilter(): (record: BrowserWebSearchProtocolRecord) => boolean { + const seen = new Set(); + return (record) => { + const key = `${record.toolName}\n${record.query}\n${record.searchUrl}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }; +} + +function queryMatchScore(queryHint: string | undefined, query: string): number { + if (!queryHint) { + return 0; + } + const left = normalizeSearchComparisonText(queryHint); + const right = normalizeSearchComparisonText(query); + if (!left || !right) { + return 0; + } + if (left === right) { + return 4; + } + if (left.includes(right) || right.includes(left)) { + return 3; + } + const leftTerms = new Set(left.split(" ").filter((item) => item.length > 2)); + const rightTerms = right.split(" ").filter((item) => item.length > 2); + return rightTerms.reduce((score, term) => score + (leftTerms.has(term) ? 1 : 0), 0); +} + +function normalizeSearchComparisonText(value: string): string { + return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim(); +} + +function responseValueContainsAnthropicWebSearchBlocks(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + const type = isRecord(block) ? stringValue(block.type) : undefined; + return type === "server_tool_use" || type === "web_search_tool_result"; + }); +} + +function responseValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + if (!isRecord(block) || stringValue(block.type) !== "text") { + return false; + } + return Boolean(stringValue(block.text)?.trim()); + }); +} + +function leadingThinkingBlockCount(content: unknown[]): number { + let index = 0; + while (index < content.length) { + const block = content[index]; + if (!isRecord(block) || stringValue(block.type) !== "thinking") { + break; + } + index += 1; + } + return index; +} + +function webSearchProtocolInsertIndex(content: unknown[], hasWebSearchBlocks: boolean): number { + let index = leadingThinkingBlockCount(content); + if (!hasWebSearchBlocks) { + return index; + } + while (index < content.length) { + const block = content[index]; + const type = isRecord(block) ? stringValue(block.type) : undefined; + if (type !== "server_tool_use" && type !== "web_search_tool_result") { + break; + } + index += 1; + } + return index; +} + +function mergeAnthropicWebSearchUsage(usage: unknown, searchCount: number): Record { + const nextUsage = isRecord(usage) ? { ...usage } : {}; + const serverToolUse = isRecord(nextUsage.server_tool_use) ? { ...nextUsage.server_tool_use } : {}; + const webSearchRequests = Math.max(1, Math.trunc(searchCount)); + serverToolUse.web_search_requests = Math.max(numberValue(serverToolUse.web_search_requests) ?? 0, webSearchRequests); + nextUsage.server_tool_use = serverToolUse; + return nextUsage; +} + +function sseEventsContainAnthropicWebSearchBlocks(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + const type = stringValue(block?.type) || stringValue(data?.type); + return type === "server_tool_use" || type === "web_search_tool_result"; + }); +} + +function sseEventsContainVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + return false; + } + const block = isRecord(data.content_block) ? data.content_block : undefined; + if (stringValue(data.type) === "content_block_start" && stringValue(block?.type) === "text") { + return Boolean(stringValue(block?.text)?.trim()); + } + const delta = isRecord(data.delta) ? data.delta : undefined; + return stringValue(data.type) === "content_block_delta" && + stringValue(delta?.type) === "text_delta" && + Boolean(stringValue(delta?.text)?.trim()); + }); +} + +function anthropicWebSearchSseEventsForBlock(block: Record, index: number): ParsedSseEvent[] { + if (stringValue(block.type) === "text") { + const text = stringValue(block.text) ?? ""; + return [ + sseEventFromValue({ + content_block: { text: "", type: "text" }, + index, + type: "content_block_start" + }), + sseEventFromValue({ + delta: { text, type: "text_delta" }, + index, + type: "content_block_delta" + }), + sseEventFromValue({ + index, + type: "content_block_stop" + }) + ]; + } + return [ + sseEventFromValue({ + content_block: block, + index, + type: "content_block_start" + }), + sseEventFromValue({ + index, + type: "content_block_stop" + }) + ]; +} + +function updateAnthropicWebSearchSseUsage(event: ParsedSseEvent, searchCount: number, didSynthesizeAnswer: boolean): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "message_delta") { + return event; + } + const delta = isRecord(event.data.delta) ? { ...event.data.delta } : event.data.delta; + const nextData: Record = { + ...event.data, + usage: mergeAnthropicWebSearchUsage(event.data.usage, searchCount) + }; + if (didSynthesizeAnswer && isRecord(delta) && stringValue(delta.stop_reason) === "max_tokens") { + nextData.delta = { ...delta, stop_reason: "end_turn" }; + } + return { + ...event, + data: nextData + }; +} + +function synthesizeWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined): string | undefined { + const query = queryHint || records.map((record) => record.query).find(Boolean) || ""; + const weatherAnswer = synthesizeWeatherWebSearchAnswer(records, query); + if (weatherAnswer) { + return weatherAnswer; + } + const evidence = topWebSearchEvidenceSentences(records, query, 3); + if (evidence.length === 0) { + const sources = webSearchSourceNames(records, 3); + if (!sources) { + return undefined; + } + return containsCjkText(query) + ? `搜索已完成,但页面可提取正文不足。较相关的来源包括:${sources}。` + : `The search completed, but the pages did not expose enough extractable text. The most relevant sources are: ${sources}.`; + } + const sources = webSearchSourceNames(records, 3); + return containsCjkText(query) + ? `根据搜索结果,${evidence.join(";")}。${sources ? `来源:${sources}。` : ""}` + : `Based on the search results, ${evidence.join("; ")}.${sources ? ` Sources: ${sources}.` : ""}`; +} + +function synthesizeWeatherWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], query: string): string | undefined { + if (!/天气|气温|温度|weather|forecast|temperature/i.test(query)) { + return undefined; + } + const items = webSearchEvidenceItems(records); + const text = items.map((item) => item.text).join(" "); + if (!text) { + return undefined; + } + const cjk = containsCjkText(query); + const location = extractWeatherLocation(query); + const temperatureRange = weatherTemperatureRange(text); + const currentTemperature = firstRegexGroup(text, [ + /(?:当前|现在|实时|实况|气温|温度)[^。;,,\d-]{0,12}(-?\d{1,2}(?:\.\d+)?)\s*℃/i, + location ? new RegExp(`${escapeRegExp(location)}\\s+(-?\\d{1,2}(?:\\.\\d+)?)\\s*℃`) : undefined + ]); + const feelsLike = firstRegexGroup(text, [/体感温度[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const high = firstRegexGroup(text, [/最高气温[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const low = firstRegexGroup(text, [/最低气温[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const humidity = firstRegexGroup(text, [/(?:最大相对湿度|相对湿度)[::\s]*(-?\d{1,3}(?:\.\d+)?%)/]); + const aqi = firstRegexGroup(text, [/AQI最高值[::\s]*(\d{1,3})/i]); + const airQuality = firstRegexGroup(text, [/空气质量[::\s]*([^\s,。;,;]{1,12})/]); + const rain = firstRegexGroup(text, [/(?:过去24小时总降水量|总降水量|降水量)[::\s]*(-?\d+(?:\.\d+)?mm)/i]); + const wind = firstRegexGroup(text, [/最大风力[::\s]*([<>]?\d+级|微风)/, /(东风|东南风|南风|西南风|西风|西北风|北风|东北风)\s*([<>]?\d+级|微风)/]); + + const facts = [ + currentTemperature ? (cjk ? `当前约 ${currentTemperature}℃` : `currently about ${currentTemperature}°C`) : undefined, + !currentTemperature && temperatureRange ? (cjk ? `气温约 ${temperatureRange}` : `temperatures are around ${temperatureRange}`) : undefined, + feelsLike ? (cjk ? `体感约 ${feelsLike}℃` : `feels like about ${feelsLike}°C`) : undefined, + high || low ? (cjk + ? `过去24小时${high ? `最高 ${high}℃` : ""}${high && low ? "、" : ""}${low ? `最低 ${low}℃` : ""}` + : `over the past 24 hours ${high ? `the high was ${high}°C` : ""}${high && low ? " and " : ""}${low ? `the low was ${low}°C` : ""}`) : undefined, + humidity ? (cjk ? `相对湿度最高 ${humidity}` : `relative humidity reached ${humidity}`) : undefined, + aqi ? (cjk ? `AQI 最高 ${aqi}` : `AQI reached ${aqi}`) : undefined, + airQuality && !aqi ? (cjk ? `空气质量 ${airQuality}` : `air quality is ${airQuality}`) : undefined, + rain ? (cjk ? `过去24小时降水量 ${rain}` : `24-hour rainfall is ${rain}`) : undefined, + wind ? (cjk ? `风力 ${wind}` : `wind ${wind}`) : undefined + ].filter((item): item is string => Boolean(item)); + + if (facts.length === 0) { + return undefined; + } + const sources = webSearchSourceNames(records, 2); + if (cjk) { + return `${location ? `${location}天气` : "天气"}:${facts.slice(0, 6).join(",")}。${sources ? `来源:${sources}。` : ""}`; + } + return `${location ? `${location} weather` : "Weather"}: ${facts.slice(0, 6).join(", ")}.${sources ? ` Sources: ${sources}.` : ""}`; +} + +function webSearchEvidenceItems(records: BrowserWebSearchProtocolRecord[]): Array<{ source: string; text: string; url: string }> { + return records.flatMap((record) => record.results.map((result) => ({ + source: result.title || hostnameFromUrl(result.url) || record.engine, + text: sanitizeWebSearchEvidenceText(result.content || result.snippet || ""), + url: result.url + }))).filter((item) => item.text); +} + +function topWebSearchEvidenceSentences(records: BrowserWebSearchProtocolRecord[], query: string, limit: number): string[] { + const terms = relevantSearchTerms(query); + const scored = webSearchEvidenceItems(records).flatMap((item, itemIndex) => { + const sentences = splitEvidenceSentences(item.text).slice(0, 12); + return sentences.map((sentence, sentenceIndex) => { + const normalizedSentence = normalizeSearchComparisonText(sentence); + const termScore = terms.reduce((score, term) => score + (normalizedSentence.includes(term) ? 2 : 0), 0); + const sourceBonus = itemIndex === 0 ? 2 : itemIndex === 1 ? 1 : 0; + const positionBonus = Math.max(0, 4 - sentenceIndex) / 4; + return { + score: termScore + sourceBonus + positionBonus, + sentence + }; + }); + }).filter((item) => item.sentence.length >= 12 && item.sentence.length <= 260); + scored.sort((left, right) => right.score - left.score || left.sentence.length - right.sentence.length); + const seen = new Set(); + return scored.flatMap((item) => { + const key = normalizeSearchComparisonText(item.sentence).slice(0, 120); + if (!key || seen.has(key)) { + return []; + } + seen.add(key); + return [item.sentence]; + }).slice(0, limit); +} + +function splitEvidenceSentences(text: string): string[] { + return text + .replace(/\s+/g, " ") + .split(/[。!?!?]\s*|\n+/g) + .map((sentence) => sentence.trim().replace(/[,,;;::]\s*$/, "")) + .filter((sentence) => sentence && !looksLikeNavigationText(sentence)); +} + +function looksLikeNavigationText(text: string): boolean { + const punctuationCount = (text.match(/[,,。;;::]/g) ?? []).length; + const digitCount = (text.match(/\d/g) ?? []).length; + return text.length > 160 && punctuationCount < 2 && digitCount < 2; +} + +function relevantSearchTerms(query: string): string[] { + const normalizedTerms = normalizeSearchComparisonText(query) + .split(" ") + .filter((term) => term.length >= 2); + const cjkTerms = query.match(/[\p{Script=Han}]{2,}/gu) ?? []; + return uniqueStrings([...normalizedTerms, ...cjkTerms].map((term) => term.toLowerCase())); +} + +function weatherTemperatureRange(text: string): string | undefined { + const values = Array.from(text.matchAll(/(-?\d{1,2}(?:\.\d+)?)\s*℃/g)) + .map((match) => Number(match[1])) + .filter((value) => Number.isFinite(value) && value > -80 && value < 60) + .slice(0, 8); + if (values.length === 0) { + return undefined; + } + const min = Math.min(...values); + const max = Math.max(...values); + const format = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(1); + return min === max ? `${format(min)}℃` : `${format(min)}-${format(max)}℃`; +} + +function extractWeatherLocation(query: string): string | undefined { + const cleaned = query + .replace(/perform\s+a\s+web\s+search\s+for\s+the\s+query:\s*/i, "") + .replace(/天气预报|天气|气温|温度|怎么样|如何|查询|搜索|今天|今日|现在|当前|请问|weather|forecast|temperature/gi, " ") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned || cleaned.length > 24) { + return undefined; + } + return cleaned; +} + +function firstRegexGroup(text: string, patterns: Array): string | undefined { + for (const pattern of patterns) { + if (!pattern) { + continue; + } + const match = pattern.exec(text); + const value = match?.[1]; + if (value) { + return value.trim(); + } + } + return undefined; +} + +function sanitizeWebSearchEvidenceText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function containsCjkText(text: string): boolean { + return /\p{Script=Han}/u.test(text); +} + +function webSearchSourceNames(records: BrowserWebSearchProtocolRecord[], limit: number): string { + return uniqueStrings(records.flatMap((record) => record.results.map((result) => { + const title = result.title?.trim(); + return title || hostnameFromUrl(result.url) || record.engine; + }))).slice(0, limit).join("、"); +} + +function hostnameFromUrl(value: string): string | undefined { + try { + return new URL(value).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function anthropicWebSearchProtocolBlocks(records: BrowserWebSearchProtocolRecord[], requestId: string): Record[] { + const blocks: Record[] = []; + records.forEach((record, index) => { + const toolUseId = `srvtoolu_${sanitizeAnthropicToolUseId(requestId)}_${index + 1}`; + blocks.push({ + id: toolUseId, + input: { query: record.query }, + name: "web_search", + type: "server_tool_use" + }); + blocks.push({ + content: record.results.map(anthropicWebSearchResultBlock), + tool_use_id: toolUseId, + type: "web_search_tool_result" + }); + }); + return blocks; +} + +function anthropicWebSearchResultBlock(result: BrowserWebSearchProtocolResult): Record { + const snippet = result.snippet || result.content; + return { + encrypted_content: "", + ...(snippet ? { snippet: snippet.slice(0, 1_200) } : {}), + title: result.title, + type: "web_search_result", + url: result.url + }; +} + +function sanitizeAnthropicToolUseId(value: string): string { + return value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24) || randomBytes(8).toString("hex"); +} + +type ParsedSseEvent = { + data?: unknown; + event?: string; + raw?: string; +}; + +function parseSseEvents(body: string): ParsedSseEvent[] { + return body + .split(/\r?\n\r?\n/g) + .filter((block) => block.trim()) + .map((raw) => { + const lines = raw.split(/\r?\n/g); + const event = lines + .filter((line) => line.startsWith("event:")) + .map((line) => line.slice(6).trim()) + .find(Boolean); + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (!data || data === "[DONE]") { + return { event, raw }; + } + try { + return { data: JSON.parse(data) as unknown, event, raw }; + } catch { + return { event, raw }; + } + }); +} + +function shiftSseContentBlockIndex(event: ParsedSseEvent, startIndex: number, delta: number): ParsedSseEvent { + if (!isRecord(event.data) || !Number.isFinite(event.data.index) || Number(event.data.index) < startIndex) { + return event; + } + return { + ...event, + data: { + ...event.data, + index: Number(event.data.index) + delta + } + }; +} + +function sseEventFromValue(data: Record): ParsedSseEvent { + return { + data, + event: stringValue(data.type) + }; +} + +function serializeSseEvent(event: ParsedSseEvent): string { + if (event.data === undefined) { + return event.raw ?? ""; + } + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return [ + event.event || type ? `event: ${event.event || type}` : undefined, + `data: ${JSON.stringify(event.data)}` + ].filter(Boolean).join("\n"); +} + function requestProtocolForPath(path: string): GatewayProviderProtocol | undefined { const normalized = path.toLowerCase(); if (normalized === "/v1/messages" || normalized === "/messages" || normalized.endsWith("/v1/messages")) { @@ -3159,6 +5237,16 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u PORT: String(config.gateway.corePort) }; + const noProxy = mergeNoProxy(env.NO_PROXY || env.no_proxy, [ + "127.0.0.1", + "localhost", + "::1", + config.gateway.host, + config.gateway.coreHost + ]); + env.NO_PROXY = noProxy; + env.no_proxy = noProxy; + if (!upstreamProxyUrl) { return env; } @@ -3166,13 +5254,9 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u env.HTTP_PROXY = upstreamProxyUrl; env.HTTPS_PROXY = upstreamProxyUrl; env.ALL_PROXY = upstreamProxyUrl; - env.NO_PROXY = mergeNoProxy(env.NO_PROXY, [ - "127.0.0.1", - "localhost", - "::1", - config.gateway.host, - config.gateway.coreHost - ]); + env.http_proxy = upstreamProxyUrl; + env.https_proxy = upstreamProxyUrl; + env.all_proxy = upstreamProxyUrl; env.CCR_UPSTREAM_PROXY_URL = upstreamProxyUrl; env.CCR_UNDICI_MODULE = requireFromHere.resolve("undici"); return env; @@ -3187,8 +5271,79 @@ function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: strin "const proxyUrl = process.env.CCR_UPSTREAM_PROXY_URL;", "if (proxyUrl) {", " const undiciModule = process.env.CCR_UNDICI_MODULE || \"undici\";", - " const { ProxyAgent, setGlobalDispatcher } = require(undiciModule);", - " setGlobalDispatcher(new ProxyAgent(proxyUrl));", + " const { Agent, ProxyAgent, setGlobalDispatcher } = require(undiciModule);", + " const directAgent = new Agent();", + " const proxyAgent = new ProxyAgent(proxyUrl);", + " const noProxyEntries = String(process.env.NO_PROXY || process.env.no_proxy || \"\")", + " .split(\",\")", + " .map((entry) => entry.trim())", + " .filter(Boolean);", + " function normalizeHost(host) {", + " return String(host || \"\").replace(/^\\[/, \"\").replace(/\\]$/, \"\").replace(/\\.$/, \"\").toLowerCase();", + " }", + " function isLoopbackHost(host) {", + " return host === \"localhost\" || host === \"::1\" || host === \"0:0:0:0:0:0:0:1\" || host === \"127.0.0.1\" || host.startsWith(\"127.\");", + " }", + " function splitNoProxyEntry(entry) {", + " let value = String(entry || \"\").trim().toLowerCase();", + " if (!value || value === \"*\") {", + " return { host: value, port: \"\" };", + " }", + " if (value.startsWith(\"[\")) {", + " const end = value.indexOf(\"]\");", + " if (end >= 0) {", + " const remainder = value.slice(end + 1);", + " return { host: normalizeHost(value.slice(1, end)), port: remainder.startsWith(\":\") ? remainder.slice(1) : \"\" };", + " }", + " }", + " const colon = value.lastIndexOf(\":\");", + " if (colon > 0 && value.indexOf(\":\") === colon) {", + " return { host: normalizeHost(value.slice(0, colon)), port: value.slice(colon + 1) };", + " }", + " return { host: normalizeHost(value), port: \"\" };", + " }", + " function noProxyMatches(host, port, entry) {", + " const parsed = splitNoProxyEntry(entry);", + " if (parsed.host === \"*\") {", + " return true;", + " }", + " if (!parsed.host || (parsed.port && parsed.port !== port)) {", + " return false;", + " }", + " if (parsed.host.startsWith(\"*.\")) {", + " const suffix = parsed.host.slice(1);", + " return host.endsWith(suffix);", + " }", + " if (parsed.host.startsWith(\".\")) {", + " return host === parsed.host.slice(1) || host.endsWith(parsed.host);", + " }", + " return host === parsed.host || host.endsWith(`.${parsed.host}`);", + " }", + " function shouldBypassProxy(origin) {", + " try {", + " const url = new URL(String(origin));", + " const host = normalizeHost(url.hostname);", + " const port = url.port || (url.protocol === \"https:\" ? \"443\" : url.protocol === \"http:\" ? \"80\" : \"\");", + " return isLoopbackHost(host) || noProxyEntries.some((entry) => noProxyMatches(host, port, entry));", + " } catch {", + " return false;", + " }", + " }", + " setGlobalDispatcher({", + " dispatch(options, handler) {", + " return shouldBypassProxy(options && options.origin)", + " ? directAgent.dispatch(options, handler)", + " : proxyAgent.dispatch(options, handler);", + " },", + " close(callback) {", + " Promise.all([directAgent.close(), proxyAgent.close()]).then(() => callback && callback(), callback);", + " },", + " destroy(error, callback) {", + " directAgent.destroy(error);", + " proxyAgent.destroy(error);", + " if (callback) callback();", + " }", + " });", "}" ].join("\n"), "utf8" diff --git a/src/server/mcp/browser-web-search-proxy-mcp.ts b/src/server/mcp/browser-web-search-proxy-mcp.ts new file mode 100644 index 0000000..d034adf --- /dev/null +++ b/src/server/mcp/browser-web-search-proxy-mcp.ts @@ -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 { + 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 { + 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 { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function writeFrame(payload: Record): 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 { + 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); +} diff --git a/src/server/mcp/fusion-vision-mcp.ts b/src/server/mcp/fusion-vision-mcp.ts index c6a285e..956866c 100644 --- a/src/server/mcp/fusion-vision-mcp.ts +++ b/src/server/mcp/fusion-vision-mcp.ts @@ -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): Promise 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 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" }] diff --git a/src/shared/app.ts b/src/shared/app.ts index bc91702..56bd581 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -675,6 +675,7 @@ export type VirtualModelFusionVisionConfig = { }; export type VirtualModelFusionWebSearchProvider = + | "browser" | "brave" | "bing" | "google_cse" diff --git a/tests/main/codex-model-catalog.test.mjs b/tests/main/codex-model-catalog.test.mjs index 263c2b5..b08f94f 100644 --- a/tests/main/codex-model-catalog.test.mjs +++ b/tests/main/codex-model-catalog.test.mjs @@ -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"); diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index 4931a1f..2f0898c 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -1,6 +1,22 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizeCoreGatewayVirtualModelProfiles } from "../../src/server/gateway/service.ts"; +import { + fusionFallbackToolDefinitions, + fusionWebSearchToolNameForRequest, + fusionToolNamesBackedByMcpServers, + extractHostedWebSearchQueryHint, + prepareAnthropicWebSearchProtocolRequestBody, + 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 +75,561 @@ 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 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", + 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, "Spot gold traded near $3,340 per ounce."); +}); + +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 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 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: "end_turn" }, 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\}/); +}); + +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: "\nNetwork access is restricted.\n" }] + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "\n /tmp/project\n 2026-07-02\n \n" }] + }, + { + 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 sseEvent(value) { + return `event: ${value.type}\ndata: ${JSON.stringify(value)}`; +} + +function openAiSseEvent(value) { + return `data: ${JSON.stringify(value)}`; +} diff --git a/tests/renderer/virtual-models.test.ts b/tests/renderer/virtual-models.test.ts index 5377160..1617e19 100644 --- a/tests/renderer/virtual-models.test.ts +++ b/tests/renderer/virtual-models.test.ts @@ -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"); }); From ccd2694d43c2fdc52ab10c15b22778be3488ebf3 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 2 Jul 2026 18:30:24 +0800 Subject: [PATCH 4/6] Open main window from tray and hide it on close --- src/main/tray-controller.ts | 14 ++++++++++++++ src/main/windows.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/main/tray-controller.ts b/src/main/tray-controller.ts index 11d5b1e..7379dd0 100644 --- a/src/main/tray-controller.ts +++ b/src/main/tray-controller.ts @@ -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; diff --git a/src/main/windows.ts b/src/main/windows.ts index e010725..634ec51 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -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)); } From 01b3c3bc3baba87d2e8a89afe2c4ec2594681630 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 2 Jul 2026 20:28:25 +0800 Subject: [PATCH 5/6] Add router support for multi-provider model handling --- src/main/claude-app-launch.ts | 30 + src/main/codex-cli-middleware-runtime.ts | 144 ++- src/main/profile-launch-core.ts | 30 + src/main/profile-service.ts | 87 +- .../gateway/claude-code-router-plugin.ts | 14 +- src/server/gateway/service.ts | 994 ++++++++++++++++-- .../codex-cli-middleware-runtime.test.mjs | 86 ++ tests/main/gateway-virtual-models.test.mjs | 376 ++++++- tests/main/profile-launch-core.test.mjs | 5 + tests/main/profile-service.test.mjs | 108 ++ tests/main/router-builtins.test.mjs | 34 +- 11 files changed, 1789 insertions(+), 119 deletions(-) create mode 100644 tests/main/codex-cli-middleware-runtime.test.mjs create mode 100644 tests/main/profile-service.test.mjs diff --git a/src/main/claude-app-launch.ts b/src/main/claude-app-launch.ts index ec7adce..dc67f1b 100644 --- a/src/main/claude-app-launch.ts +++ b/src/main/claude-app-launch.ts @@ -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 { }, {}); } +function claudeCodeModelEnv(profile: ProfileConfig): Record { + const env: Record = {}; + 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); } diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index 5b61cb9..2f7b30b 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -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; diff --git a/src/main/profile-launch-core.ts b/src/main/profile-launch-core.ts index 0220187..ea5bea2 100644 --- a/src/main/profile-launch-core.ts +++ b/src/main/profile-launch-core.ts @@ -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 { + const env: Record = {}; + 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"; } diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index 87be49b..5c814c7 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -60,6 +60,7 @@ export async function applyProfileConfig(config: AppConfig): Promise 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 { + const endpoint = gatewayEndpoint(config); + const env: Record = { + 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(); + 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)) { diff --git a/src/server/gateway/claude-code-router-plugin.ts b/src/server/gateway/claude-code-router-plugin.ts index a824ced..89726b3 100644 --- a/src/server/gateway/claude-code-router-plugin.ts +++ b/src/server/gateway/claude-code-router-plugin.ts @@ -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; @@ -1055,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); } diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index f4ab704..56b9e1b 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -113,6 +113,12 @@ type HostedWebSearchProtocolContext = { type AnthropicWebSearchProtocolContext = HostedWebSearchProtocolContext; +type ClaudeCodeWebSearchContinuationContext = { + queryHint?: string; + sinceMs: number; + toolName: string; +}; + export type BrowserWebSearchMcpRegistration = { env?: Record; name: string; @@ -823,6 +829,33 @@ class GatewayService { } } + const claudeCodeWebSearchContinuationContext = !hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration + ? createClaudeCodeWebSearchContinuationContext({ + body: bodyToForward, + config: this.config, + method, + path, + routedModel, + sinceMs: startedAt - 5 * 60_000 + }) + : undefined; + if (claudeCodeWebSearchContinuationContext && this.browserWebSearchMcpIntegration) { + const records = selectClaudeCodeWebSearchContinuationRecords( + claudeCodeWebSearchContinuationContext, + this.browserWebSearchMcpIntegration + ); + const webSearchContinuationBody = prepareClaudeCodeWebSearchContinuationRequestBody( + bodyToForward, + records, + claudeCodeWebSearchContinuationContext + ); + if (webSearchContinuationBody) { + bodyToForward = webSearchContinuationBody; + headers["content-type"] = "application/json"; + headers["x-ccr-claude-code-web-search-continuation"] = records.length > 0 ? "in-app-browser-evidence" : "tool-result-evidence"; + } + } + delete headers["content-length"]; const upstreamUrl = new URL(request.url || "/", this.status.coreEndpoint).toString(); let upstreamResult: UpstreamFetchResult; @@ -882,7 +915,8 @@ class GatewayService { const hostedWebSearchResponseContentType = responseHeaders.get("content-type")?.toLowerCase() ?? ""; if ( hostedWebSearchProtocolContext && - hostedWebSearchResponseContentType.includes("application/json") && + (hostedWebSearchResponseContentType.includes("application/json") || + hostedWebSearchResponseContentType.includes("text/event-stream")) && (this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || this.browserWebSearchMcpIntegration?.runBrowserWebSearch) ) { responseHeaders.delete("content-length"); @@ -2628,6 +2662,32 @@ function createAnthropicWebSearchProtocolContext(input: { return context?.protocol === "anthropic_messages" ? context : undefined; } +function createClaudeCodeWebSearchContinuationContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + routedModel?: string; + sinceMs: number; +}): ClaudeCodeWebSearchContinuationContext | undefined { + if (input.method !== "POST" || requestProtocolForPath(input.path) !== "anthropic_messages") { + return undefined; + } + const body = parseJsonObjectSafe(input.body); + if (!body || claudeCodeWebSearchToolResultTexts(body).length === 0) { + return undefined; + } + const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel); + if (!toolName) { + return undefined; + } + return { + queryHint: extractClaudeCodeWebSearchToolResultQuery(body) || extractAnthropicWebSearchQueryHint(body), + sinceMs: input.sinceMs, + toolName + }; +} + export function prepareHostedWebSearchProtocolRequestBody( body: Buffer | undefined, records: BrowserWebSearchProtocolRecord[], @@ -2675,6 +2735,31 @@ export function prepareAnthropicWebSearchProtocolRequestBody( return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); } +export function prepareClaudeCodeWebSearchContinuationRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + const parsed = parseJsonObjectSafe(body); + if (!parsed) { + return undefined; + } + const toolResultTexts = claudeCodeWebSearchToolResultTexts(parsed); + if (toolResultTexts.length === 0) { + return undefined; + } + const queryHint = context.queryHint || extractClaudeCodeWebSearchToolResultQuery(parsed) || extractAnthropicWebSearchQueryHint(parsed); + const evidence = claudeCodeWebSearchContinuationEvidenceText(records, queryHint, toolResultTexts); + if (!evidence) { + return undefined; + } + const next = applyAnthropicWebSearchSynthesisControls(stripClaudeCodeWebSearchContinuationTools({ + ...parsed, + system: appendAnthropicSystemText(parsed.system, evidence) + })); + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); +} + function applyAnthropicWebSearchSynthesisControls(body: Record): Record { const next = { ...body }; const outputConfig = isRecord(next.output_config) ? { ...next.output_config } : {}; @@ -2741,6 +2826,16 @@ function stripAnthropicHostedWebSearchTools(body: Record): Reco return next; } +function stripClaudeCodeWebSearchContinuationTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + const next = { ...body }; + delete next.tools; + delete next.tool_choice; + return next; +} + function stripOpenAiHostedWebSearchTools(body: Record): Record { const next = { ...body }; let removedTools = false; @@ -2877,6 +2972,29 @@ function hostedWebSearchEvidenceText(records: BrowserWebSearchProtocolRecord[], ].filter(Boolean).join("\n\n").slice(0, 10_000); } +function claudeCodeWebSearchContinuationEvidenceText( + records: BrowserWebSearchProtocolRecord[], + queryHint: string | undefined, + toolResultTexts: string[] +): string { + const browserEvidence = records.length > 0 ? hostedWebSearchEvidenceText(records, queryHint) : ""; + const toolResultEvidence = toolResultTexts + .map((text) => text.trim()) + .filter(Boolean) + .join("\n\n---\n\n") + .slice(0, 12_000); + if (!browserEvidence && !toolResultEvidence) { + return ""; + } + return [ + "A Claude Code WebSearch tool result has already been returned for this turn.", + "Answer the user's search question directly in the visible final response. Do not call any tool. Do not merely list links or ask the user to open links. Include the sources you used as markdown links.", + queryHint ? `Original search intent: ${queryHint}` : "", + browserEvidence ? `In-app browser extracted evidence:\n\n${browserEvidence}` : "", + toolResultEvidence ? `Previous WebSearch tool result:\n\n${toolResultEvidence}` : "" + ].filter(Boolean).join("\n\n"); +} + function focusedWebSearchContent(content: string | undefined, queryHint: string | undefined): string | undefined { const text = content?.replace(/\s+/g, " ").trim(); if (!text) { @@ -2901,7 +3019,7 @@ function focusedWebSearchContent(content: string | undefined, queryHint: string return `${start > 0 ? "..." : ""}${text.slice(start, start + 1_200)}${start + 1_200 < text.length ? "..." : ""}`; } -function hostedWebSearchProtocolResponseStream( +export function hostedWebSearchProtocolResponseStream( input: Readable, headers: Headers, context: HostedWebSearchProtocolContext, @@ -2912,7 +3030,10 @@ function hostedWebSearchProtocolResponseStream( } const contentType = headers.get("content-type")?.toLowerCase() ?? ""; if (contentType.includes("text/event-stream")) { - return input; + if (context.protocol === "anthropic_messages") { + return anthropicHostedWebSearchProtocolSseStream(input, context, integration); + } + return hostedWebSearchProtocolSseStream(input, context, integration); } if (!contentType.includes("application/json")) { return input; @@ -2944,6 +3065,416 @@ function hostedWebSearchProtocolResponseStream( })); } +function hostedWebSearchProtocolSseStream( + input: Readable, + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Readable { + const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + let records: BrowserWebSearchProtocolRecord[] | undefined; + let pending = ""; + let passThrough = false; + const state: HostedWebSearchSseState = { + done: false, + maxOutputIndex: -1, + visibleText: false + }; + + async function ensureRecords() { + if (records || passThrough) { + return; + } + records = await recordsPromise; + passThrough = records.length === 0; + } + + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + const text = chunk.toString(); + const rawText = pending + text; + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + this.push(text); + return; + } + pending += text; + drainHostedWebSearchSseBlocks(this, pending, state, records, context, false); + pending = sseTrailingPartialBlock(pending); + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + this.push(rawText); + pending = ""; + passThrough = true; + }).finally(() => callback()); + }, + flush(callback) { + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + if (pending) { + this.push(pending); + } + return; + } + drainHostedWebSearchSseBlocks(this, pending, state, records, context, true); + pending = ""; + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + if (pending) { + this.push(pending); + } + }).finally(() => callback()); + } + })); +} + +type HostedWebSearchSseState = { + done: boolean; + maxOutputIndex: number; + visibleText: boolean; +}; + +function drainHostedWebSearchSseBlocks( + stream: Transform, + text: string, + state: HostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick, + flush: boolean +): void { + let cursor = 0; + for (const match of text.matchAll(/\r?\n\r?\n/g)) { + const index = match.index ?? 0; + const delimiter = match[0]; + const block = text.slice(cursor, index); + cursor = index + delimiter.length; + if (!block.trim()) { + stream.push(delimiter); + continue; + } + writeHostedWebSearchSseEvent(stream, parseSseEventBlock(block), delimiter, state, records, context); + } + if (flush) { + const block = text.slice(cursor); + if (block.trim()) { + writeHostedWebSearchSseEvent(stream, parseSseEventBlock(block), "", state, records, context); + } else if (block) { + stream.push(block); + } + writeHostedWebSearchSseFallback(stream, state, records, context); + } +} + +function writeHostedWebSearchSseEvent( + stream: Transform, + event: ParsedSseEvent, + delimiter: string, + state: HostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): void { + updateHostedWebSearchSseState(event, state, context.protocol); + const isDone = sseEventIsDone(event); + const isOpenAiResponsesCompleted = context.protocol === "openai_responses" && + isRecord(event.data) && + stringValue(event.data.type) === "response.completed"; + if ((isDone || isOpenAiResponsesCompleted) && !state.done) { + writeHostedWebSearchSseFallback(stream, state, records, context); + } + const nextEvent = context.protocol === "openai_chat_completions" + ? updateOpenAiChatSseFinishReason(event) + : context.protocol === "openai_responses" + ? updateOpenAiResponsesCompletedStatus(event) + : event; + stream.push(`${serializeSseEvent(nextEvent)}${delimiter}`); +} + +function updateHostedWebSearchSseState( + event: ParsedSseEvent, + state: HostedWebSearchSseState, + protocol: GatewayProviderProtocol +): void { + if (protocol === "openai_chat_completions") { + state.visibleText ||= openAiChatSseContainsVisibleText([event]); + return; + } + if (protocol === "openai_responses") { + state.visibleText ||= openAiResponsesSseContainsVisibleText([event]); + if (isRecord(event.data)) { + const outputIndex = numberValue(event.data.output_index); + if (outputIndex !== undefined) { + state.maxOutputIndex = Math.max(state.maxOutputIndex, outputIndex); + } + } + return; + } + if (protocol === "gemini_generate_content") { + state.visibleText ||= geminiSseContainsVisibleText([event]); + } +} + +function writeHostedWebSearchSseFallback( + stream: Transform, + state: HostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): void { + if (state.done || state.visibleText) { + return; + } + const answer = synthesizeWebSearchAnswer(records, context.queryHint); + if (!answer) { + state.done = true; + return; + } + for (const event of hostedWebSearchSseFallbackEvents(answer, state, context)) { + stream.push(`${serializeSseEvent(event)}\n\n`); + } + state.visibleText = true; + state.done = true; +} + +function hostedWebSearchSseFallbackEvents( + answer: string, + state: HostedWebSearchSseState, + context: Pick +): ParsedSseEvent[] { + if (context.protocol === "openai_chat_completions") { + return [sseEventFromValue({ + object: "chat.completion.chunk", + choices: [ + { + delta: { content: answer }, + finish_reason: null, + index: 0 + } + ] + })]; + } + if (context.protocol === "openai_responses") { + return openAiResponsesSseAnswerEvents(answer, context.requestId, state.maxOutputIndex + 1); + } + if (context.protocol === "gemini_generate_content") { + return [sseEventFromValue(geminiAnswerCandidateChunk(answer))]; + } + return []; +} + +function anthropicHostedWebSearchProtocolSseStream( + input: Readable, + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Readable { + const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + let records: BrowserWebSearchProtocolRecord[] | undefined; + let pending = ""; + let passThrough = false; + const state: AnthropicHostedWebSearchSseState = { + answerInjected: false, + hasClientToolUse: false, + hasWebSearchBlocks: false, + injectedBlockCount: 0, + insertedSearchBlocks: false, + maxIndex: -1, + visibleText: false + }; + + async function ensureRecords() { + if (records || passThrough) { + return; + } + records = await recordsPromise; + passThrough = records.length === 0; + } + + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + const text = chunk.toString(); + const rawText = pending + text; + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + this.push(text); + return; + } + pending += text; + drainAnthropicHostedWebSearchSseBlocks(this, pending, state, records, context, false); + pending = sseTrailingPartialBlock(pending); + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + this.push(rawText); + pending = ""; + passThrough = true; + }).finally(() => callback()); + }, + flush(callback) { + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + if (pending) { + this.push(pending); + } + return; + } + drainAnthropicHostedWebSearchSseBlocks(this, pending, state, records, context, true); + pending = ""; + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + if (pending) { + this.push(pending); + } + }).finally(() => callback()); + } + })); +} + +type AnthropicHostedWebSearchSseState = { + answerInjected: boolean; + hasClientToolUse: boolean; + hasWebSearchBlocks: boolean; + injectedBlockCount: number; + insertedSearchBlocks: boolean; + insertIndex?: number; + maxIndex: number; + visibleText: boolean; +}; + +function drainAnthropicHostedWebSearchSseBlocks( + stream: Transform, + text: string, + state: AnthropicHostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick, + flush: boolean +): void { + let cursor = 0; + for (const match of text.matchAll(/\r?\n\r?\n/g)) { + const index = match.index ?? 0; + const delimiter = match[0]; + const block = text.slice(cursor, index); + cursor = index + delimiter.length; + if (!block.trim()) { + stream.push(delimiter); + continue; + } + writeAnthropicHostedWebSearchSseEvent( + stream, + parseSseEventBlock(block), + delimiter, + state, + records, + context + ); + } + if (flush) { + const block = text.slice(cursor); + if (block.trim()) { + writeAnthropicHostedWebSearchSseEvent( + stream, + parseSseEventBlock(block), + "", + state, + records, + context + ); + } else if (block) { + stream.push(block); + } + } +} + +function sseTrailingPartialBlock(text: string): string { + let cursor = 0; + for (const match of text.matchAll(/\r?\n\r?\n/g)) { + cursor = (match.index ?? 0) + match[0].length; + } + return text.slice(cursor); +} + +function writeAnthropicHostedWebSearchSseEvent( + stream: Transform, + event: ParsedSseEvent, + delimiter: string, + state: AnthropicHostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): void { + updateAnthropicHostedWebSearchSseState(event, state); + const textStartIndex = anthropicSseTextBlockStartIndex(event); + const isMessageEnd = sseEventIsAnthropicMessageEnd(event); + const answer = !state.hasClientToolUse && !state.visibleText && !state.answerInjected && isMessageEnd + ? synthesizeWebSearchAnswer(records, context.queryHint) + : undefined; + + if (!state.insertedSearchBlocks && (textStartIndex !== undefined || isMessageEnd)) { + const insertIndex = textStartIndex ?? state.maxIndex + 1; + insertAnthropicHostedWebSearchSseBlocks(stream, state, records, context.requestId, insertIndex); + } + if (answer && isMessageEnd) { + const answerIndex = state.maxIndex + state.injectedBlockCount + 1; + insertAnthropicHostedWebSearchSseAnswer(stream, state, answer, answerIndex); + } + + const nextEvent = updateAnthropicWebSearchSseUsage( + shiftAnthropicHostedWebSearchSseEvent(event, state), + records.length, + Boolean(answer), + state.hasClientToolUse + ); + stream.push(`${serializeSseEvent(nextEvent)}${delimiter}`); +} + +function updateAnthropicHostedWebSearchSseState(event: ParsedSseEvent, state: AnthropicHostedWebSearchSseState): void { + if (isRecord(event.data) && Number.isFinite(event.data.index)) { + state.maxIndex = Math.max(state.maxIndex, Number(event.data.index)); + } + state.hasWebSearchBlocks ||= sseEventContainsAnthropicWebSearchBlock(event); + state.hasClientToolUse ||= sseEventContainsAnthropicClientToolUse(event); + state.visibleText ||= sseEventContainsVisibleText(event); +} + +function insertAnthropicHostedWebSearchSseBlocks( + stream: Transform, + state: AnthropicHostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + insertIndex: number +): void { + state.insertedSearchBlocks = true; + state.insertIndex = insertIndex; + if (state.hasWebSearchBlocks) { + return; + } + const blocks = anthropicWebSearchProtocolBlocks(records, requestId); + for (const event of blocks.flatMap((block, offset) => anthropicWebSearchSseEventsForBlock(block, insertIndex + offset))) { + stream.push(`${serializeSseEvent(event)}\n\n`); + } + state.injectedBlockCount += blocks.length; +} + +function insertAnthropicHostedWebSearchSseAnswer( + stream: Transform, + state: AnthropicHostedWebSearchSseState, + answer: string, + answerIndex: number +): void { + state.answerInjected = true; + for (const event of anthropicWebSearchSseEventsForBlock({ text: answer, type: "text" }, answerIndex)) { + stream.push(`${serializeSseEvent(event)}\n\n`); + } +} + +function shiftAnthropicHostedWebSearchSseEvent( + event: ParsedSseEvent, + state: AnthropicHostedWebSearchSseState +): ParsedSseEvent { + if (!state.insertedSearchBlocks || state.insertIndex === undefined || state.injectedBlockCount === 0) { + return event; + } + return shiftSseContentBlockIndex(event, state.insertIndex, state.injectedBlockCount); +} + function transformHostedWebSearchProtocolResponseValue( value: unknown, records: BrowserWebSearchProtocolRecord[], @@ -2995,7 +3526,8 @@ export function transformAnthropicWebSearchProtocolResponseValue( } const hasWebSearchBlocks = responseValueContainsAnthropicWebSearchBlocks(value); const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); - const answer = responseValueContainsVisibleText(value) + const hasClientToolUse = responseValueContainsAnthropicClientToolUse(value); + const answer = hasClientToolUse || responseValueContainsVisibleText(value) ? undefined : synthesizeWebSearchAnswer(records, queryHint); const injectedBlocks = [ @@ -3010,7 +3542,7 @@ export function transformAnthropicWebSearchProtocolResponseValue( const nextValue = { ...value, ...(shouldUpdateUsage ? { usage: mergeAnthropicWebSearchUsage(value.usage, records.length) } : {}), - ...(answer && stringValue(value.stop_reason) === "max_tokens" ? { stop_reason: "end_turn" } : {}), + ...(shouldEndAnthropicHostedWebSearchTurn(value.stop_reason, Boolean(answer), hasClientToolUse) ? { stop_reason: "end_turn" } : {}), content: injectedBlocks.length > 0 ? [ ...value.content.slice(0, insertAt), @@ -3037,7 +3569,8 @@ export function transformAnthropicWebSearchProtocolSseText( } const hasWebSearchBlocks = sseEventsContainAnthropicWebSearchBlocks(events); const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); - const answer = sseEventsContainVisibleText(events) + const hasClientToolUse = sseEventsContainAnthropicClientToolUse(events); + const answer = hasClientToolUse || sseEventsContainVisibleText(events) ? undefined : synthesizeWebSearchAnswer(records, queryHint); const injectedBlocks = [ @@ -3076,7 +3609,7 @@ export function transformAnthropicWebSearchProtocolSseText( const shiftedEvents = events .map((event) => shiftSseContentBlockIndex(event, insertIndex, injectedBlocks.length)) - .map((event) => updateAnthropicWebSearchSseUsage(event, records.length, Boolean(answer))); + .map((event) => updateAnthropicWebSearchSseUsage(event, records.length, Boolean(answer), hasClientToolUse)); const injectedEvents = injectedBlocks.flatMap((block, offset) => anthropicWebSearchSseEventsForBlock(block, insertIndex + offset)); shiftedEvents.splice(insertPosition, 0, ...injectedEvents); return `${shiftedEvents.map(serializeSseEvent).join("\n\n")}\n\n`; @@ -3199,59 +3732,7 @@ export function transformOpenAiResponsesHostedWebSearchSseText( return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(events)); } const outputIndex = nextOpenAiResponsesOutputIndex(events); - const itemId = `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`; - const contentIndex = 0; - const injected = [ - sseEventFromValue({ - item: { - id: itemId, - role: "assistant", - status: "in_progress", - type: "message" - }, - output_index: outputIndex, - type: "response.output_item.added" - }), - sseEventFromValue({ - content_index: contentIndex, - item_id: itemId, - output_index: outputIndex, - part: { annotations: [], text: "", type: "output_text" }, - type: "response.content_part.added" - }), - sseEventFromValue({ - content_index: contentIndex, - delta: answer, - item_id: itemId, - output_index: outputIndex, - type: "response.output_text.delta" - }), - sseEventFromValue({ - content_index: contentIndex, - item_id: itemId, - output_index: outputIndex, - text: answer, - type: "response.output_text.done" - }), - sseEventFromValue({ - content_index: contentIndex, - item_id: itemId, - output_index: outputIndex, - part: { annotations: [], text: answer, type: "output_text" }, - type: "response.content_part.done" - }), - sseEventFromValue({ - item: { - content: [{ annotations: [], text: answer, type: "output_text" }], - id: itemId, - role: "assistant", - status: "completed", - type: "message" - }, - output_index: outputIndex, - type: "response.output_item.done" - }) - ]; + const injected = openAiResponsesSseAnswerEvents(answer, requestId, outputIndex); const shifted = events.map(updateOpenAiResponsesCompletedStatus); const insertAt = shifted.findIndex((event) => isRecord(event.data) && stringValue(event.data.type) === "response.completed"); shifted.splice(insertAt >= 0 ? insertAt : doneSseEventIndex(shifted) >= 0 ? doneSseEventIndex(shifted) : shifted.length, 0, ...injected); @@ -3446,6 +3927,62 @@ function nextOpenAiResponsesOutputIndex(events: ParsedSseEvent[]): number { return indexes.length === 0 ? 0 : Math.max(...indexes) + 1; } +function openAiResponsesSseAnswerEvents(answer: string, requestId: string, outputIndex: number): ParsedSseEvent[] { + const itemId = `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`; + const contentIndex = 0; + return [ + sseEventFromValue({ + item: { + id: itemId, + role: "assistant", + status: "in_progress", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.added" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: "", type: "output_text" }, + type: "response.content_part.added" + }), + sseEventFromValue({ + content_index: contentIndex, + delta: answer, + item_id: itemId, + output_index: outputIndex, + type: "response.output_text.delta" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + text: answer, + type: "response.output_text.done" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: answer, type: "output_text" }, + type: "response.content_part.done" + }), + sseEventFromValue({ + item: { + content: [{ annotations: [], text: answer, type: "output_text" }], + id: itemId, + role: "assistant", + status: "completed", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.done" + }) + ]; +} + function updateOpenAiResponsesCompletedStatus(event: ParsedSseEvent): ParsedSseEvent { if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { return event; @@ -3602,6 +4139,10 @@ function doneSseEventIndex(events: ParsedSseEvent[]): number { return events.findIndex((event) => event.raw?.includes("[DONE]")); } +function sseEventIsDone(event: ParsedSseEvent): boolean { + return Boolean(event.raw?.includes("[DONE]")); +} + function hasAnthropicHostedWebSearchTool(tools: unknown): boolean { if (!Array.isArray(tools)) { return false; @@ -3731,6 +4272,20 @@ function extractAnthropicWebSearchQueryHint(body: Record): stri return normalizedWebSearchQueryHintFromParts(userTexts); } +function extractClaudeCodeWebSearchToolResultQuery(body: Record): string | undefined { + for (const text of claudeCodeWebSearchToolResultTexts(body)) { + const quoted = /Web search results for query:\s*"([^"]+)"/i.exec(text); + if (quoted?.[1]) { + return normalizedWebSearchQueryHint(quoted[1]); + } + const unquoted = /Web search results for query:\s*([^\n]+)/i.exec(text); + if (unquoted?.[1]) { + return normalizedWebSearchQueryHint(unquoted[1].replace(/^["']|["']$/g, "")); + } + } + return undefined; +} + function normalizedWebSearchQueryHintFromParts(parts: string[]): string | undefined { const candidates = parts .map((part) => part.trim()) @@ -3799,6 +4354,75 @@ function textPartsFromAnthropicContent(content: unknown): string[] { return content.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); } +function claudeCodeWebSearchToolResultTexts(body: Record): string[] { + if (!Array.isArray(body.messages)) { + return []; + } + const lastMessage = body.messages.at(-1); + if (!isRecord(lastMessage) || stringValue(lastMessage.role) !== "user" || !Array.isArray(lastMessage.content)) { + return []; + } + const latestToolResults = lastMessage.content.filter((part) => isRecord(part) && stringValue(part.type) === "tool_result"); + if (latestToolResults.length === 0) { + return []; + } + const webSearchToolUseIds = new Set(); + for (let index = body.messages.length - 2; index >= 0; index -= 1) { + const message = body.messages[index]; + if (!isRecord(message) || stringValue(message.role) !== "assistant" || !Array.isArray(message.content)) { + continue; + } + for (const part of message.content) { + if (!isRecord(part) || stringValue(part.type) !== "tool_use" || stringValue(part.name)?.toLowerCase() !== "websearch") { + continue; + } + const id = stringValue(part.id); + if (id) { + webSearchToolUseIds.add(id); + } + } + break; + } + if (webSearchToolUseIds.size === 0) { + return []; + } + const texts: string[] = []; + for (const part of latestToolResults) { + if (!isRecord(part)) { + continue; + } + const toolUseId = stringValue(part.tool_use_id); + if (!toolUseId || !webSearchToolUseIds.has(toolUseId)) { + continue; + } + const text = anthropicToolResultContentText(part.content); + if (text) { + texts.push(text); + } + } + return texts; +} + +function anthropicToolResultContentText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content.flatMap((part) => { + if (!isRecord(part)) { + return []; + } + const type = stringValue(part.type); + if (type === "text" || type === "input_text" || type === "output_text") { + const text = stringValue(part.text); + return text ? [text] : []; + } + return []; + }).join("\n"); +} + function textPartsFromOpenAiChatMessages(messages: unknown): string[] { if (!Array.isArray(messages)) { return []; @@ -3868,11 +4492,21 @@ function textPartsFromGeminiContents(contents: unknown): string[] { export function fusionWebSearchToolNameForRequest(config: AppConfig, model: string | undefined): string | undefined { const normalizedModel = model ? fusionModelNameFromSelector(model) : ""; + for (const candidate of fusionBrowserWebSearchToolCandidates(config)) { + if (!normalizedModel || candidate.aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { + return candidate.toolName; + } + } + return undefined; +} + +function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string }> { const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; const profiles = normalizeCoreGatewayVirtualModelProfiles( withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), config ); + const candidates: Array<{ aliases: string[]; toolName: string }> = []; for (const profile of profiles) { if (!isRecord(profile) || profile.enabled === false) { continue; @@ -3890,11 +4524,9 @@ export function fusionWebSearchToolNameForRequest(config: AppConfig, model: stri stringValue(profile.displayName), ...stringListValue(match?.exactAliases) ].filter((item): item is string => Boolean(item))); - if (!normalizedModel || aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { - return webSearchConfig.toolName; - } + candidates.push({ aliases, toolName: webSearchConfig.toolName }); } - return undefined; + return candidates; } async function selectHostedWebSearchProtocolRecords( @@ -3926,6 +4558,24 @@ async function selectHostedWebSearchProtocolRecords( return record?.results.length ? [record] : []; } +function selectClaudeCodeWebSearchContinuationRecords( + context: ClaudeCodeWebSearchContinuationContext, + integration: BrowserWebSearchMcpIntegration +): BrowserWebSearchProtocolRecord[] { + const records = integration.recentBrowserWebSearchResults?.({ + sinceMs: context.sinceMs, + toolName: context.toolName + }) ?? []; + return records + .filter((record) => record.results.length > 0) + .filter(uniqueSearchRecordFilter()) + .sort((left, right) => { + const queryScoreDelta = queryMatchScore(context.queryHint, right.query) - queryMatchScore(context.queryHint, left.query); + return queryScoreDelta || right.completedAtMs - left.completedAtMs; + }) + .slice(0, 3); +} + async function selectAnthropicWebSearchProtocolRecords( context: AnthropicWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration @@ -3985,6 +4635,12 @@ function responseValueContainsVisibleText(value: Record): boole }); } +function responseValueContainsAnthropicClientToolUse(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + return isRecord(block) && stringValue(block.type) === "tool_use"; + }); +} + function leadingThinkingBlockCount(content: unknown[]): number { let index = 0; while (index < content.length) { @@ -4024,28 +4680,59 @@ function mergeAnthropicWebSearchUsage(usage: unknown, searchCount: number): Reco function sseEventsContainAnthropicWebSearchBlocks(events: ParsedSseEvent[]): boolean { return events.some((event) => { - const data = isRecord(event.data) ? event.data : undefined; - const block = isRecord(data?.content_block) ? data.content_block : undefined; - const type = stringValue(block?.type) || stringValue(data?.type); - return type === "server_tool_use" || type === "web_search_tool_result"; + return sseEventContainsAnthropicWebSearchBlock(event); }); } function sseEventsContainVisibleText(events: ParsedSseEvent[]): boolean { - return events.some((event) => { - const data = isRecord(event.data) ? event.data : undefined; - if (!data) { - return false; - } - const block = isRecord(data.content_block) ? data.content_block : undefined; - if (stringValue(data.type) === "content_block_start" && stringValue(block?.type) === "text") { - return Boolean(stringValue(block?.text)?.trim()); - } - const delta = isRecord(data.delta) ? data.delta : undefined; - return stringValue(data.type) === "content_block_delta" && - stringValue(delta?.type) === "text_delta" && - Boolean(stringValue(delta?.text)?.trim()); - }); + return events.some(sseEventContainsVisibleText); +} + +function sseEventContainsAnthropicWebSearchBlock(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + const type = stringValue(block?.type) || stringValue(data?.type); + return type === "server_tool_use" || type === "web_search_tool_result"; +} + +function sseEventContainsVisibleText(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + return false; + } + const block = isRecord(data.content_block) ? data.content_block : undefined; + if (stringValue(data.type) === "content_block_start" && stringValue(block?.type) === "text") { + return Boolean(stringValue(block?.text)?.trim()); + } + const delta = isRecord(data.delta) ? data.delta : undefined; + return stringValue(data.type) === "content_block_delta" && + stringValue(delta?.type) === "text_delta" && + Boolean(stringValue(delta?.text)?.trim()); +} + +function sseEventsContainAnthropicClientToolUse(events: ParsedSseEvent[]): boolean { + return events.some(sseEventContainsAnthropicClientToolUse); +} + +function sseEventContainsAnthropicClientToolUse(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + return stringValue(data?.type) === "content_block_start" && stringValue(block?.type) === "tool_use"; +} + +function anthropicSseTextBlockStartIndex(event: ParsedSseEvent): number | undefined { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + if (stringValue(data?.type) !== "content_block_start" || stringValue(block?.type) !== "text") { + return undefined; + } + const index = numberValue(data?.index); + return index === undefined ? undefined : index; +} + +function sseEventIsAnthropicMessageEnd(event: ParsedSseEvent): boolean { + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return type === "message_delta" || type === "message_stop"; } function anthropicWebSearchSseEventsForBlock(block: Record, index: number): ParsedSseEvent[] { @@ -4081,7 +4768,12 @@ function anthropicWebSearchSseEventsForBlock(block: Record, ind ]; } -function updateAnthropicWebSearchSseUsage(event: ParsedSseEvent, searchCount: number, didSynthesizeAnswer: boolean): ParsedSseEvent { +function updateAnthropicWebSearchSseUsage( + event: ParsedSseEvent, + searchCount: number, + didSynthesizeAnswer: boolean, + hasClientToolUse: boolean +): ParsedSseEvent { if (!isRecord(event.data) || stringValue(event.data.type) !== "message_delta") { return event; } @@ -4090,7 +4782,7 @@ function updateAnthropicWebSearchSseUsage(event: ParsedSseEvent, searchCount: nu ...event.data, usage: mergeAnthropicWebSearchUsage(event.data.usage, searchCount) }; - if (didSynthesizeAnswer && isRecord(delta) && stringValue(delta.stop_reason) === "max_tokens") { + if (isRecord(delta) && shouldEndAnthropicHostedWebSearchTurn(delta.stop_reason, didSynthesizeAnswer, hasClientToolUse)) { nextData.delta = { ...delta, stop_reason: "end_turn" }; } return { @@ -4099,12 +4791,28 @@ function updateAnthropicWebSearchSseUsage(event: ParsedSseEvent, searchCount: nu }; } +function shouldEndAnthropicHostedWebSearchTurn( + stopReason: unknown, + didSynthesizeAnswer: boolean, + hasClientToolUse: boolean +): boolean { + if (hasClientToolUse) { + return false; + } + const normalized = stringValue(stopReason); + return normalized === "tool_use" || (didSynthesizeAnswer && normalized === "max_tokens"); +} + function synthesizeWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined): string | undefined { const query = queryHint || records.map((record) => record.query).find(Boolean) || ""; const weatherAnswer = synthesizeWeatherWebSearchAnswer(records, query); if (weatherAnswer) { return weatherAnswer; } + const componentChangelogAnswer = synthesizeComponentChangelogWebSearchAnswer(records, query); + if (componentChangelogAnswer) { + return componentChangelogAnswer; + } const evidence = topWebSearchEvidenceSentences(records, query, 3); if (evidence.length === 0) { const sources = webSearchSourceNames(records, 3); @@ -4121,6 +4829,74 @@ function synthesizeWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], qu : `Based on the search results, ${evidence.join("; ")}.${sources ? ` Sources: ${sources}.` : ""}`; } +function synthesizeComponentChangelogWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], query: string): string | undefined { + const normalizedQuery = normalizeSearchComparisonText(query); + const asksForComponents = /component|components|组件/i.test(query); + const asksForNewOrChangelog = /new|latest|recent|changelog|release|新增|新组件|最新|更新|官方/i.test(query); + if (!asksForComponents || !asksForNewOrChangelog) { + return undefined; + } + const items = webSearchEvidenceItems(records); + const preferred = items.find((item) => { + const normalized = normalizeSearchComparisonText(`${item.source} ${item.url} ${item.text.slice(0, 500)}`); + return normalized.includes("changelog") || normalized.includes("official") || normalized.includes("docs") || normalizedQuery.includes("official"); + }) ?? items[0]; + if (!preferred) { + return undefined; + } + const release = extractComponentReleaseTitle(preferred.text); + const components = extractLikelyComponentNames(preferred.text); + const sources = webSearchSourceNames(records, 2); + const cjk = containsCjkText(query); + if (!release && components.length === 0) { + return undefined; + } + if (cjk) { + return [ + release ? `官方相关条目是 ${release}` : "官方页面包含新增组件相关内容", + components.length > 0 ? `可提取到的相关组件包括 ${components.join("、")}` : "", + sources ? `来源:${sources}。` : "" + ].filter(Boolean).join(";"); + } + return [ + release ? `The relevant official entry is ${release}` : "The official page contains new component information", + components.length > 0 ? `extractable related components include ${components.join(", ")}` : "", + sources ? `Sources: ${sources}.` : "" + ].filter(Boolean).join("; "); +} + +function extractComponentReleaseTitle(text: string): string | undefined { + const patterns = [ + /((?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\s*-\s*Components?[^.。!?]{0,90})/i, + /(\d{4}[-/]\d{1,2}[^.。!?]{0,60}Components?[^.。!?]{0,60})/i + ]; + for (const pattern of patterns) { + const match = pattern.exec(text); + const title = match?.[1]?.replace(/\s+/g, " ").trim(); + if (title) { + return title; + } + } + return undefined; +} + +function extractLikelyComponentNames(text: string): string[] { + const knownNames = [ + "Message Scroller", + "Message", + "Attachment", + "Bubble", + "Marker", + "Empty", + "Item", + "Field", + "Input OTP", + "Button Group" + ]; + const lower = text.toLowerCase(); + return knownNames.filter((name) => lower.includes(name.toLowerCase())).slice(0, 10); +} + function synthesizeWeatherWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], query: string): string | undefined { if (!/天气|气温|温度|weather|forecast|temperature/i.test(query)) { return undefined; @@ -4314,7 +5090,7 @@ function anthropicWebSearchProtocolBlocks(records: BrowserWebSearchProtocolRecor } function anthropicWebSearchResultBlock(result: BrowserWebSearchProtocolResult): Record { - const snippet = result.snippet || result.content; + const snippet = anthropicWebSearchResultSnippet(result); return { encrypted_content: "", ...(snippet ? { snippet: snippet.slice(0, 1_200) } : {}), @@ -4324,6 +5100,14 @@ function anthropicWebSearchResultBlock(result: BrowserWebSearchProtocolResult): }; } +function anthropicWebSearchResultSnippet(result: BrowserWebSearchProtocolResult): string | undefined { + const parts = [ + result.snippet ? `Search snippet: ${sanitizeWebSearchEvidenceText(result.snippet)}` : "", + result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : "" + ].filter(Boolean); + return parts.length > 0 ? parts.join("\n") : undefined; +} + function sanitizeAnthropicToolUseId(value: string): string { return value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24) || randomBytes(8).toString("hex"); } @@ -4338,25 +5122,27 @@ function parseSseEvents(body: string): ParsedSseEvent[] { return body .split(/\r?\n\r?\n/g) .filter((block) => block.trim()) - .map((raw) => { - const lines = raw.split(/\r?\n/g); - const event = lines - .filter((line) => line.startsWith("event:")) - .map((line) => line.slice(6).trim()) - .find(Boolean); - const data = lines - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).replace(/^ /, "")) - .join("\n"); - if (!data || data === "[DONE]") { - return { event, raw }; - } - try { - return { data: JSON.parse(data) as unknown, event, raw }; - } catch { - return { event, raw }; - } - }); + .map(parseSseEventBlock); +} + +function parseSseEventBlock(raw: string): ParsedSseEvent { + const lines = raw.split(/\r?\n/g); + const event = lines + .filter((line) => line.startsWith("event:")) + .map((line) => line.slice(6).trim()) + .find(Boolean); + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (!data || data === "[DONE]") { + return { event, raw }; + } + try { + return { data: JSON.parse(data) as unknown, event, raw }; + } catch { + return { event, raw }; + } } function shiftSseContentBlockIndex(event: ParsedSseEvent, startIndex: number, delta: number): ParsedSseEvent { diff --git a/tests/main/codex-cli-middleware-runtime.test.mjs b/tests/main/codex-cli-middleware-runtime.test.mjs new file mode 100644 index 0000000..4a06989 --- /dev/null +++ b/tests/main/codex-cli-middleware-runtime.test.mjs @@ -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 }; +} diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index 2f0898c..b449381 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; +import { PassThrough, Readable } from "node:stream"; import test from "node:test"; import { fusionFallbackToolDefinitions, fusionWebSearchToolNameForRequest, fusionToolNamesBackedByMcpServers, extractHostedWebSearchQueryHint, + hostedWebSearchProtocolResponseStream, prepareAnthropicWebSearchProtocolRequestBody, + prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, transformAnthropicWebSearchProtocolResponseValue, transformAnthropicWebSearchProtocolSseText, @@ -143,6 +146,76 @@ test("gateway resolves normalized Fusion web search tool names for Anthropic pro 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: [], @@ -205,6 +278,7 @@ test("gateway response injects Anthropic web search protocol blocks into JSON re ], id: "msg_1", role: "assistant", + stop_reason: "tool_use", type: "message", usage: { server_tool_use: { web_search_requests: 1 } } }; @@ -217,7 +291,30 @@ test("gateway response injects Anthropic web search protocol blocks into JSON re ); 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, "Spot gold traded near $3,340 per ounce."); + 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", () => { @@ -243,6 +340,107 @@ test("gateway injects prefetched web search evidence into Anthropic requests", ( 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: [ @@ -271,6 +469,32 @@ test("gateway synthesizes final Anthropic text when web search response has no v 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" } }), @@ -279,7 +503,7 @@ test("gateway response injects Anthropic web search protocol blocks into SSE res 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: "end_turn" }, usage: { server_tool_use: { web_search_requests: 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"; @@ -290,6 +514,102 @@ test("gateway response injects Anthropic web search protocol blocks into SSE res 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", () => { @@ -626,6 +946,28 @@ function sampleSearchRecord() { }; } +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)}`; } @@ -633,3 +975,33 @@ function sseEvent(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); + }); +} diff --git a/tests/main/profile-launch-core.test.mjs b/tests/main/profile-launch-core.test.mjs index d93aeda..1401e58 100644 --- a/tests/main/profile-launch-core.test.mjs +++ b/tests/main/profile-launch-core.test.mjs @@ -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/); }); diff --git a/tests/main/profile-service.test.mjs b/tests/main/profile-service.test.mjs new file mode 100644 index 0000000..e271ccf --- /dev/null +++ b/tests/main/profile-service.test.mjs @@ -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 }); + } +}); diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index 33f7cc3..ccc40cc 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -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" From ec99720037865f94b6eeb20981da0c6bc97a1a59 Mon Sep 17 00:00:00 2001 From: musi Date: Thu, 2 Jul 2026 22:08:17 +0800 Subject: [PATCH 6/6] chore: release 3.0.6 --- package-lock.json | 4 ++-- package.json | 2 +- src/renderer/pages/home/components/providers.tsx | 7 +------ 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7dd9fd..890dd6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d8d9cc7..c817649 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/renderer/pages/home/components/providers.tsx b/src/renderer/pages/home/components/providers.tsx index bde3e90..70cf187 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/src/renderer/pages/home/components/providers.tsx @@ -348,7 +348,7 @@ export function ModelsView({ {visibleRows.map((row) => (
@@ -358,11 +358,6 @@ export function ModelsView({
{row.providerName ? `${row.providerName}/${row.model}` : row.model}
- {row.providerName ? ( - - {row.providerName} - - ) : null}
{row.providerIndex !== undefined ? (