From 0e4f0081015f53a191f99f427eb53a80d3155a5a Mon Sep 17 00:00:00 2001 From: Tony Yu <49832190+ysntony@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:59:46 +0800 Subject: [PATCH 1/5] docs: add international Kimi Code link alongside domestic subscription link kimi.com/code serves mainland China (WeChat / +86 sign-in, Alipay and WeChat Pay), so international readers cannot sign up or pay there. Add the kimi.ai/code link (Google sign-in, card payment) to the sponsor banner link row and label both plan sites in the intro paragraph of the English and Chinese READMEs, keeping the same aff value. --- README.md | 8 +++----- README_zh.md | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a80d9f47..ebd25178 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,9 @@
- Kimi Code Subscription + Kimi Code plan (中文站 | Global)  ·  - API Global -  ·  - API China + API (中文站 | Global) @@ -22,7 +20,7 @@ Thanks to Kimi for sponsoring this project! Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. Inside CCR, Kimi ships as a built-in provider preset: import the pay-as-you-go API or Kimi Code subscription in one click and route your coding agent's requests to Kimi. The subscription endpoint passes through natively without protocol conversion, API endpoints are adapted automatically, and account balance and subscription usage are visible in the CCR dashboard.

- CCR already includes Kimi provider presets. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the Kimi Code subscription. + CCR already includes Kimi provider presets. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the Kimi Code plan (中文站 | Global).

diff --git a/README_zh.md b/README_zh.md index e312bc25..d9a9958e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,11 +8,9 @@
- Kimi Code 订阅 + Kimi Code 订阅(中文站|Global)  ·  - API 中文站 -  ·  - API Global + API(中文站|Global) @@ -22,7 +20,7 @@ 感谢 Kimi 赞助本项目!Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长周期编码、知识工作和推理任务中展现前沿性能。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可将编程 Agent 的请求路由到 Kimi;订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。

- CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站|Global)体验 API,或了解 Kimi Code 订阅。 + CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站|Global)体验 API,或了解 Kimi Code 订阅(中文站|Global)。

From 99715925ee95212c8f8da9bb3e6434c10d395e7d Mon Sep 17 00:00:00 2001 From: Burlesque1 <11853736+Burlesque1@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:19:21 +0000 Subject: [PATCH 2/5] fix(ui): reserve the month label row's real height in activity grids The activity grids size their cells from the frame's measured height, but reserve a hard-coded height for the month label row (10px in the dashboard widget, 8px in the tray widget). The row actually renders ~20px, and its `mb-1` margin was never accounted for, so the reservation fell short and `overflow-hidden` on the frame clipped the bottom row. Measured on v3.1.1, dashboard widget (dimensions 2x2): frame (overflow-hidden) 121.5px available month label row 20px + 4px margin cells grid 7 x 12px + 6 x 3px gap = 102px content total 126px -> 4.5px clipped Reserve the label row's measured height plus its bottom margin instead, and drop the vertical use of `monthLabelGap` (it is a column gap). The same dimensions then compute cellSize 11 instead of 12, giving 119px of content in the 121.5px frame - no clipping. Verified in the running dashboard after a rebuild: content 119px vs frame 122px (3px slack), bottom row fully visible. Fixes #1800 --- .../src/pages/home/components/dashboard.tsx | 22 +++++++++++-------- .../ui/src/pages/tray/components/widgets.tsx | 20 +++++++++++------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index d9d7e2fd..5e82401a 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -1572,21 +1572,23 @@ function OverviewActivityGrid({ const t = useAppText(); const gridFrameRef = useRef(null); const gridFrameSize = useElementSize(gridFrameRef); + const monthLabelRef = useRef(null); + const monthLabelSize = useElementSize(monthLabelRef); const showDayLabels = dimensions.width >= 2; const showMonthLabels = dimensions.height >= 2; const dayLabels = [t("M"), "", t("W"), "", t("F"), "", ""]; const cellGap = dimensions.height <= 1 ? 2 : dimensions.width >= 3 ? 4 : 3; const labelColumnWidth = showDayLabels ? 20 : 0; - const monthLabelHeight = showMonthLabels ? 10 : 0; - const monthLabelGap = showMonthLabels ? 4 : 0; + // Reserve the month label row's measured height plus its `mb-1` margin: a hard-coded + // height under-reserves, which pushes the last activity row past the frame and clips it. + const monthLabelBlockHeight = showMonthLabels ? monthLabelSize.height + MONTH_LABEL_BOTTOM_MARGIN : 0; const cellSize = activityGridCellSize({ availableHeight: gridFrameSize.height, availableWidth: gridFrameSize.width, cellGap, fallbackCellSize: dimensions.height <= 1 ? 8 : dimensions.width >= 3 ? 10 : 9, labelColumnWidth, - monthLabelGap, - monthLabelHeight, + monthLabelBlockHeight, weekCount: activity.weekCount }); const activityColumns = `repeat(${activity.weekCount}, ${cellSize}px)`; @@ -1601,6 +1603,7 @@ function OverviewActivityGrid({ {showMonthLabels ? (
(null); const gridFrameSize = useMeasuredElementSize(gridFrameRef); + const monthLabelRef = useRef(null); + const monthLabelSize = useMeasuredElementSize(monthLabelRef); const dayLabels = [t("M"), "", t("W"), "", t("F"), "", ""]; const cellGap = 3; const labelColumnWidth = 14; + // Reserve the month label row's measured height plus its `mb-1` margin: a hard-coded + // height under-reserves, which pushes the last activity row past the frame and clips it. + const monthLabelBlockHeight = monthLabelSize.height + MONTH_LABEL_BOTTOM_MARGIN; const cellSize = activityGridCellSize({ availableHeight: gridFrameSize.height, availableWidth: gridFrameSize.width, cellGap, fallbackCellSize: 8, labelColumnWidth, - monthLabelGap: 4, - monthLabelHeight: 8, + monthLabelBlockHeight, weekCount: activity.weekCount }); const activityColumns = `repeat(${activity.weekCount}, ${cellSize}px)`; @@ -239,6 +243,7 @@ function TokenActivityGrid({
Date: Tue, 22 Sep 2026 22:01:16 -0400 Subject: [PATCH 3/5] feat(fusion): add Serply as a web search provider Adds Serply to the hosted web search providers and the built-in Fusion web_search MCP tool, alongside Brave, Bing, Google CSE, Serper, SerpAPI, Tavily and Exa. Configured with SERPLY_API_KEY and an optional SERPLY_SEARCH_ENDPOINT override, like the other providers. --- .../en/configuration/fusion-web-search.md | 4 +- .../zh/configuration/fusion-web-search.md | 4 +- packages/core/src/contracts/app.ts | 1 + .../features/hosted-web-search/discovery.ts | 24 +++++++ packages/core/src/mcp/fusion-config.ts | 3 +- packages/core/src/mcp/fusion-vision-mcp.ts | 25 +++++++- .../gateway/gateway-virtual-models.test.mjs | 62 +++++++++++++++++++ packages/ui/src/pages/home/shared/i18n.tsx | 4 +- packages/ui/src/pages/home/shared/options.ts | 4 +- 9 files changed, 121 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/en/configuration/fusion-web-search.md b/docs/src/content/docs/en/configuration/fusion-web-search.md index 9684962d..023e2607 100644 --- a/docs/src/content/docs/en/configuration/fusion-web-search.md +++ b/docs/src/content/docs/en/configuration/fusion-web-search.md @@ -11,7 +11,7 @@ Use `ccr-fusion-builtins / web_search`. ## Search Providers -Supported providers include In-app Browser, Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa. +Supported providers include In-app Browser, Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa. ## In-app Browser @@ -24,7 +24,7 @@ Configuration options include search engine, language, country or region, and sa - Country or region: for example `US` or `CN`. - Safe search: default, moderate, strict, or off. -> Note: `In-app Browser` depends on CCR Desktop's Electron built-in browser capability and is only available in the desktop app. CLI, server deployments, and pure web environments do not have the built-in browser integration; use Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, or Exa instead. +> Note: `In-app Browser` depends on CCR Desktop's Electron built-in browser capability and is only available in the desktop app. CLI, server deployments, and pure web environments do not have the built-in browser integration; use Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, or Exa instead. ## Troubleshooting diff --git a/docs/src/content/docs/zh/configuration/fusion-web-search.md b/docs/src/content/docs/zh/configuration/fusion-web-search.md index b1f21692..b8a50725 100644 --- a/docs/src/content/docs/zh/configuration/fusion-web-search.md +++ b/docs/src/content/docs/zh/configuration/fusion-web-search.md @@ -11,7 +11,7 @@ lead: 为不支持联网的模型添加实时检索能力:选择 CCR 内置 we ## 搜索服务 -支持 In-app Browser、Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa 等搜索服务。 +支持 In-app Browser、Brave、Bing、Google CSE、Serper、SerpAPI、Serply、Tavily、Exa 等搜索服务。 ## In-app Browser @@ -24,7 +24,7 @@ lead: 为不支持联网的模型添加实时检索能力:选择 CCR 内置 we - 地区:例如 `US`、`CN`。 - 安全搜索:默认、中等、严格或关闭。 -> 注意:`In-app Browser` 依赖 CCR Desktop 的 Electron 内置浏览器能力,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有内置浏览器集成,请改用 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily 或 Exa 等搜索服务。 +> 注意:`In-app Browser` 依赖 CCR Desktop 的 Electron 内置浏览器能力,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有内置浏览器集成,请改用 Brave、Bing、Google CSE、Serper、SerpAPI、Serply、Tavily 或 Exa 等搜索服务。 ## 排查要点 diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 15a65780..f8bd9fee 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1080,6 +1080,7 @@ export type VirtualModelFusionWebSearchProvider = | "google_cse" | "serper" | "serpapi" + | "serply" | "tavily" | "exa"; diff --git a/packages/core/src/gateway/features/hosted-web-search/discovery.ts b/packages/core/src/gateway/features/hosted-web-search/discovery.ts index edab6761..c4fba695 100644 --- a/packages/core/src/gateway/features/hosted-web-search/discovery.ts +++ b/packages/core/src/gateway/features/hosted-web-search/discovery.ts @@ -529,6 +529,8 @@ async function runConfiguredWebSearch(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult); } +async function searchSerply(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "SERPLY_API_KEY"); + if (!apiKey) { + console.warn("[gateway] Serply web search API key is not configured."); + return []; + } + const url = new URL(searchEnv(input, "SERPLY_SEARCH_ENDPOINT") || "https://api.serply.io/v1/search"); + url.searchParams.set("q", input.query); + url.searchParams.set("num", String(Math.min(input.count, 10))); + const raw = await fetchJson(url.toString(), { + headers: { + "user-agent": "claude-code-router", + "x-api-key": apiKey + }, + signal: AbortSignal.timeout(input.timeoutMs) + }); + const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results.slice(0, input.count) : []; + return items.map((item) => webSearchResult(item, "title", "link", "description")).filter(isWebSearchProviderResult); +} + async function searchTavily(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "TAVILY_API_KEY"); if (!apiKey) { @@ -712,6 +734,8 @@ function searchProviderUrl(provider: VirtualModelFusionWebSearchProvider, query: return "https://serper.dev"; case "serpapi": return "https://serpapi.com"; + case "serply": + return "https://serply.io"; case "tavily": return "https://tavily.com"; case "exa": diff --git a/packages/core/src/mcp/fusion-config.ts b/packages/core/src/mcp/fusion-config.ts index ed7417ab..5880753c 100644 --- a/packages/core/src/mcp/fusion-config.ts +++ b/packages/core/src/mcp/fusion-config.ts @@ -305,7 +305,7 @@ export function browserWebSearchUnavailableMessage(toolName: string): string { return [ `Fusion MCP tool "${toolName}" is unavailable because In-app Browser web search requires CCR Desktop.`, "This runtime did not register the Electron browser web search integration, so the hidden browser search tool cannot run here.", - "Run the profile in CCR Desktop or switch the Fusion web search provider to Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, or Exa." + "Run the profile in CCR Desktop or switch the Fusion web search provider to Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, or Exa." ].join(" "); } @@ -786,6 +786,7 @@ function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSear normalized === "google_cse" || normalized === "serper" || normalized === "serpapi" || + normalized === "serply" || normalized === "tavily" || normalized === "exa" || normalized === "browser" diff --git a/packages/core/src/mcp/fusion-vision-mcp.ts b/packages/core/src/mcp/fusion-vision-mcp.ts index 7c812018..03211616 100644 --- a/packages/core/src/mcp/fusion-vision-mcp.ts +++ b/packages/core/src/mcp/fusion-vision-mcp.ts @@ -34,7 +34,7 @@ type ToolCallResult = { }; type FusionBuiltinToolKind = "vision" | "web_search"; -type SearchProvider = "auto" | "bing" | "brave" | "exa" | "google_cse" | "serpapi" | "serper" | "tavily"; +type SearchProvider = "auto" | "bing" | "brave" | "exa" | "google_cse" | "serpapi" | "serper" | "serply" | "tavily"; type SearchInput = { count: number; country?: string; @@ -681,6 +681,7 @@ async function searchWithProvider( if (provider === "google_cse") return searchGoogleCse(input); if (provider === "serper") return searchSerper(input); if (provider === "serpapi") return searchSerpApi(input); + if (provider === "serply") return searchSerply(input); if (provider === "tavily") return searchTavily(input); return searchExa(input); } @@ -767,6 +768,24 @@ async function searchSerpApi(input: SearchInput): Promise { return items.map((item) => normalizeSearchResult(item, "title", "link", "snippet")).filter(isSearchResult); } +async function searchSerply(input: SearchInput): Promise { + const apiKey = requireEnv("SERPLY_API_KEY", "Serply API key"); + const url = new URL(env("SERPLY_SEARCH_ENDPOINT") || "https://api.serply.io/v1/search"); + url.searchParams.set("q", scopedSearchQuery(input)); + url.searchParams.set("num", String(Math.min(input.count, 10))); + if (input.country) url.searchParams.set("gl", input.country); + if (input.language) url.searchParams.set("hl", input.language); + const raw = await fetchJson(url.toString(), { + headers: { + "user-agent": "claude-code-router", + "x-api-key": apiKey + }, + signal: AbortSignal.timeout(input.timeoutMs) + }); + const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results.slice(0, input.count) : []; + return items.map((item) => normalizeSearchResult(item, "title", "link", "description")).filter(isSearchResult); +} + async function searchTavily(input: SearchInput): Promise { const apiKey = requireEnv("TAVILY_API_KEY", "Tavily API key"); const raw = await fetchJson(env("TAVILY_SEARCH_ENDPOINT") || "https://api.tavily.com/search", { @@ -1076,7 +1095,7 @@ function resolveSearchProvider(): Exclude { if (configured !== "auto") { return configured; } - const candidates: Array> = ["brave", "bing", "google_cse", "serper", "serpapi", "tavily", "exa"]; + const candidates: Array> = ["brave", "bing", "google_cse", "serper", "serpapi", "serply", "tavily", "exa"]; const provider = candidates.find(searchProviderIsConfigured); if (!provider) { throw new Error("No search provider configured. Set SEARCH_PROVIDER and its API key."); @@ -1092,6 +1111,7 @@ function parseSearchProvider(value: string | undefined): SearchProvider | undefi value === "google_cse" || value === "serper" || value === "serpapi" || + value === "serply" || value === "tavily" || value === "exa" ) { @@ -1106,6 +1126,7 @@ function searchProviderIsConfigured(provider: Exclude): if (provider === "google_cse") return Boolean(env("GOOGLE_SEARCH_API_KEY") && env("GOOGLE_SEARCH_CX")); if (provider === "serper") return Boolean(env("SERPER_API_KEY")); if (provider === "serpapi") return Boolean(env("SERPAPI_API_KEY")); + if (provider === "serply") return Boolean(env("SERPLY_API_KEY")); if (provider === "tavily") return Boolean(env("TAVILY_API_KEY")); return Boolean(env("EXA_API_KEY")); } diff --git a/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs b/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs index 164be977..c8d6b1da 100644 --- a/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs +++ b/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs @@ -771,6 +771,68 @@ test("gateway prefetches non-browser Fusion web search records without browser i } }); +test("gateway prefetches Serply Fusion web search records", async () => { + const requests = []; + const endpoint = "http://127.0.0.1/serply-search"; + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ headers: new Headers(init.headers), url: new URL(String(input)) }); + return new Response(JSON.stringify({ + results: [ + { description: "First body", link: "https://example.test/one", title: "First" }, + { description: "Second body", link: "https://example.test/two", title: "Second" }, + { description: "Third body", link: "https://example.test/three", title: "Third" } + ] + }), { headers: { "content-type": "application/json" }, status: 200 }); + }; + try { + const config = { + Providers: [], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {}, + virtualModelProfiles: [ + { + displayName: "Research", + enabled: true, + id: "research", + key: "research", + match: { exactAliases: ["Fusion/research"], prefixes: [], suffixes: [] }, + metadata: { + fusionWebSearch: { + env: { SERPLY_API_KEY: "serply-key", SERPLY_SEARCH_ENDPOINT: endpoint }, + provider: "serply", + resultCount: 2, + toolName: "research_web_search" + } + } + } + ] + }; + + const records = await selectHostedWebSearchProtocolRecords({ + protocol: "anthropic_messages", + queryHint: "search query", + requestId: "req-1", + sinceMs: Date.now() - 1000, + toolName: "research_web_search" + }, undefined, config); + + assert.equal(records.length, 1); + assert.equal(records[0].engine, "serply"); + assert.equal(records[0].searchUrl, "https://serply.io"); + assert.deepEqual(records[0].results, [ + { snippet: "First body", title: "First", url: "https://example.test/one" }, + { snippet: "Second body", title: "Second", url: "https://example.test/two" } + ]); + assert.equal(requests[0].url.origin + requests[0].url.pathname, endpoint); + assert.equal(requests[0].url.searchParams.get("q"), "search query"); + assert.equal(requests[0].url.searchParams.get("num"), "2"); + assert.equal(requests[0].headers.get("x-api-key"), "serply-key"); + } finally { + globalThis.fetch = previousFetch; + } +}); + test("gateway config does not create fallback tools for MCP-backed Fusion tools", () => { const profiles = [ { diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 71bf17e8..ac1b0ac2 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -708,7 +708,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.", + "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.": "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.", "Image generation": "Image generation", "Video generation": "Video generation", "Generate and edit images with a media-capable provider model.": "Generate and edit images with a media-capable provider model.", @@ -1985,7 +1985,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。", + "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.": "通用网络搜索工具,支持隐藏内置浏览器搜索,以及 Brave、Bing、Google CSE、Serper、SerpAPI、Serply、Tavily、Exa。", "Image generation": "图片生成", "Video generation": "视频生成", "Generate and edit images with a media-capable provider model.": "使用支持媒体协议的供应商模型生成和编辑图片。", diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index e8459ba3..64965df6 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -309,7 +309,7 @@ export const fusionToolOptions: Array<{ description: string; label: string; valu value: BUILTIN_FUSION_VISION_TOOL_NAME }, { - description: "Generic web search tool supporting hidden in-app browser search plus 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, Serply, Tavily, and Exa.", label: `${BUILTIN_FUSION_TOOL_SERVER_NAME} / ${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`, value: BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME }, @@ -337,6 +337,7 @@ export const fusionWebSearchProviderOptions: Array<{ label: string; value: Virtu { label: "Google CSE", value: "google_cse" }, { label: "Serper", value: "serper" }, { label: "SerpAPI", value: "serpapi" }, + { label: "Serply", value: "serply" }, { label: "Tavily", value: "tavily" }, { label: "Exa", value: "exa" } ]; @@ -349,6 +350,7 @@ export const fusionWebSearchEnvKeysByProvider: Record Date: Wed, 23 Sep 2026 14:38:20 +0800 Subject: [PATCH 4/5] fix(context-archive): keep trailing tool_result when replacing Claude Code compact prompt --- .../src/gateway/context-archive/protocol.ts | 7 +++++- .../test/unit/agents/context-archive.test.mjs | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/core/src/gateway/context-archive/protocol.ts b/packages/core/src/gateway/context-archive/protocol.ts index fd1f679f..2c4848db 100644 --- a/packages/core/src/gateway/context-archive/protocol.ts +++ b/packages/core/src/gateway/context-archive/protocol.ts @@ -236,7 +236,12 @@ function replaceClaudeCodeAutoCompactPrompt( } const nextMessage = cloneJsonObject(message); nextMessage.content = protocol === "anthropic_messages" - ? [{ text: claudeCodeCompactHandoffTask(task), type: "text" }] + ? [ + ...(Array.isArray(message.content) ? message.content : []).filter( + (block) => isRecord(block) && block.type === "tool_result" + ), + { text: claudeCodeCompactHandoffTask(task), type: "text" } + ] : claudeCodeCompactHandoffTask(task); messages[index] = nextMessage; body.messages = messages; diff --git a/packages/core/test/unit/agents/context-archive.test.mjs b/packages/core/test/unit/agents/context-archive.test.mjs index 9af71d59..9a4db550 100644 --- a/packages/core/test/unit/agents/context-archive.test.mjs +++ b/packages/core/test/unit/agents/context-archive.test.mjs @@ -1016,6 +1016,31 @@ test("only explicit or structural compact signals create archives", async () => assert.doesNotMatch(handoffText, /Your task is to create a detailed summary of the conversation so far/); }); +test("Claude Code auto compact handoff keeps the trailing tool_result paired with its tool_use", async () => { + const toolResult = { content: "file contents", tool_use_id: "toolu_1", type: "tool_result" }; + const auto = await prepareContextArchiveRequest({ + body: Buffer.from(JSON.stringify({ + messages: [ + { content: "Read the file.", role: "user" }, + { content: [{ id: "toolu_1", input: {}, name: "Read", type: "tool_use" }], role: "assistant" }, + { content: [toolResult, { text: claudeAutoCompactPrompt(), type: "text" }], role: "user" } + ], + model: "claude-test" + })), + config: testConfig(), + headers: { "x-session-id": "claude-auto-tool-result" }, + method: "POST", + path: "/v1/messages", + protocol: "anthropic_messages", + requestId: "claude-auto-tool-result" + }); + assert.ok(auto); + const content = JSON.parse(auto.body.toString("utf8")).messages.at(-1).content; + assert.deepEqual(content[0], toolResult); + assert.equal(content.length, 2); + assert.match(content[1].text, /CCR compact handoff task/); +}); + test("compact refuses unresolved tool-call boundaries instead of trimming them", async () => { const config = testConfig(); await assert.rejects(() => prepareContextArchiveRequest({ From 5488e6635e69207bef3cd67a415a890aa777b364 Mon Sep 17 00:00:00 2001 From: Diogo Macedo Date: Fri, 25 Sep 2026 22:04:14 -0300 Subject: [PATCH 5/5] fix: make public OpenCode Zen free-tier imports pass the client check --- .../local-providers/opencode-freetier.ts | 238 +++++++++++++ .../src/agents/local-providers/opencode.ts | 9 +- .../gateway/core-runtime/config-compiler.ts | 3 +- .../local-agent-auth-provider-hook.ts | 29 +- ...-agent-provider-opencode-freetier.test.mjs | 326 ++++++++++++++++++ .../local-agent-provider-opencode.test.mjs | 24 +- 6 files changed, 623 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/agents/local-providers/opencode-freetier.ts create mode 100644 packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs diff --git a/packages/core/src/agents/local-providers/opencode-freetier.ts b/packages/core/src/agents/local-providers/opencode-freetier.ts new file mode 100644 index 00000000..330aed05 --- /dev/null +++ b/packages/core/src/agents/local-providers/opencode-freetier.ts @@ -0,0 +1,238 @@ +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + isRecord, + providerNamePlaceholder, + providerNameSlugPlaceholder, + readString +} from "@ccr/core/agents/local-providers/shared"; + +/** + * Free-tier client fingerprint for login-less ("public") OpenCode Zen imports. + * + * Zen serves zero-cost models to anonymous callers only when the request looks + * like it comes from the official OpenCode client: the User-Agent must carry + * the `opencode/` prefix, the `x-opencode-session` / `x-opencode-request` + * headers must be well-formed client IDs with a fresh embedded timestamp, the + * request must stream, and the declared tools must include OpenCode's own + * `bash` and `read` (other tools may be declared alongside them). Anything + * else is rejected with `403 FreeTierError: "OpenCode's free tier can only be + * used from within OpenCode"`. + * + * The ID layout mirrors the open-source OpenCode ID generator + * (`ses_` descending, `msg_` ascending: 12 hex chars of + * `(timestampMs * 0x1000 + counter)` over 48 bits, plus 14 random base62 + * chars). There is no shared secret involved, so CCR can mint equally valid + * IDs per request. If Zen ever hardens this gate (it already did once), + * public imports stop working and the supported fallback is a logged-in Zen + * API key. + * + * These headers never touch authentication: the provider keeps `apiKey: + * "public"` and the hook below only sets client-identification headers. + */ +export const OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX = "opencode-public-freetier"; + +export const OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK = "opencode/1.18.31"; + +const OPENCODE_CLIENT = "cli"; +const OPENCODE_ID_TIME_BYTES = 6; +const OPENCODE_ID_TIME_HEX_LENGTH = OPENCODE_ID_TIME_BYTES * 2; +const OPENCODE_ID_RANDOM_LENGTH = 14; +const OPENCODE_ID_SUFFIX_LENGTH = OPENCODE_ID_TIME_HEX_LENGTH + OPENCODE_ID_RANDOM_LENGTH; +const OPENCODE_ID_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const OPENCODE_FREETIER_REQUIRED_TOOL_NAMES = ["bash", "read"]; +const OPENCODE_FREETIER_PLACEHOLDER_TOOL_DESCRIPTION = "Unavailable in this client. Never call this tool; use the other tools instead."; +const OPENCODE_FREETIER_PLACEHOLDER_TOOL_PARAMETERS = { properties: {}, type: "object" }; + +let freetierIdCounter = 0; +let freetierIdLastTimestamp = 0; +let cachedUserAgent: string | undefined; + +export function isOpenCodePublicFreeTierPlugin(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const key = readString(value.key)?.toLowerCase(); + return Boolean(key?.startsWith("ccr-local-agent-") && key.includes(OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX)); +} + +export function openCodePublicFreeTierPlugin(providerName = providerNamePlaceholder, keySuffix = ""): Record { + return { + key: `ccr-local-agent-${providerNameSlugPlaceholder}-${OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX}${keySuffix}`, + providerName, + // The gateway drops provider plugins without an auth/request/response + // section before handing the config to runtime hooks, so a bare marker + // would never reach the free-tier hook. + request: { + headers: { "x-opencode-client": OPENCODE_CLIENT } + } + }; +} + +export function mintOpenCodeSessionId(timestamp = Date.now()): string { + return formatOpenCodeId("ses", "descending", timestamp, nextFreeTierCounter(timestamp), randomOpenCodeIdPart()); +} + +export function mintOpenCodeRequestId(timestamp = Date.now()): string { + return formatOpenCodeId("msg", "ascending", timestamp, nextFreeTierCounter(timestamp), randomOpenCodeIdPart()); +} + +export function formatOpenCodeId( + prefix: string, + direction: "ascending" | "descending", + timestampMs: number, + counter: number, + randomPart: string +): string { + const mask = (1n << BigInt(OPENCODE_ID_TIME_BYTES * 8)) - 1n; + let now = BigInt(Math.trunc(timestampMs)) * 0x1000n + BigInt(Math.trunc(counter)); + now = direction === "descending" ? ~now & mask : now & mask; + return `${prefix}_${now.toString(16).padStart(OPENCODE_ID_TIME_HEX_LENGTH, "0")}${randomPart}`; +} + +/** + * Extracts the embedded millisecond timestamp from an ascending (`msg_`) or + * descending (`ses_`) client ID. The 48-bit time field wraps roughly every 2.2 + * years, so callers must compare against the same wrap window. + */ +export function openCodeIdTimestampMs(id: string): number | undefined { + const separator = id.indexOf("_"); + if (separator <= 0) { + return undefined; + } + const suffix = id.slice(separator + 1); + if (suffix.length !== OPENCODE_ID_SUFFIX_LENGTH || !/^[0-9A-Za-z]{26}$/.test(suffix)) { + return undefined; + } + const hex = suffix.slice(0, OPENCODE_ID_TIME_HEX_LENGTH); + if (!/^[0-9a-f]{12}$/.test(hex)) { + return undefined; + } + const mask = (1n << BigInt(OPENCODE_ID_TIME_BYTES * 8)) - 1n; + const encoded = BigInt(`0x${hex}`); + const timestamp = id.slice(0, separator) === "ses" ? (~encoded & mask) / 0x1000n : encoded / 0x1000n; + return Number(timestamp); +} + +export function openCodePublicFreeTierHeaders(): Record { + return { + "user-agent": openCodePublicFreeTierUserAgent(), + "x-opencode-client": OPENCODE_CLIENT, + "x-opencode-request": mintOpenCodeRequestId(), + "x-opencode-session": mintOpenCodeSessionId() + }; +} + +export function applyOpenCodePublicFreeTierHeaders(headers: Record | undefined): Record { + const next: Record = { ...(headers ?? {}) }; + for (const [name, value] of Object.entries(openCodePublicFreeTierHeaders())) { + for (const key of Object.keys(next)) { + if (key.toLowerCase() === name) { + delete next[key]; + } + } + next[name] = value; + } + return next; +} + +export function withOpenCodePublicFreeTierTools(body: unknown, upstreamUrl: string): unknown { + if (!isRecord(body)) { + return body; + } + const tools = Array.isArray(body.tools) ? body.tools : []; + const declaredNames = new Set(tools.map(declaredToolName)); + const missingNames = OPENCODE_FREETIER_REQUIRED_TOOL_NAMES.filter((name) => !declaredNames.has(name)); + if (missingNames.length === 0) { + return body; + } + const placeholderTool = isResponsesEndpoint(upstreamUrl) ? responsesPlaceholderTool : chatCompletionsPlaceholderTool; + return { ...body, tools: [...tools, ...missingNames.map(placeholderTool)] }; +} + +export function openCodePublicFreeTierUserAgent(): string { + const override = process.env.CCR_OPENCODE_USER_AGENT?.trim(); + if (override) { + return override; + } + if (cachedUserAgent) { + return cachedUserAgent; + } + cachedUserAgent = readInstalledOpenCodeUserAgent() ?? OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK; + return cachedUserAgent; +} + +export function resetOpenCodePublicFreeTierState(): void { + freetierIdCounter = 0; + freetierIdLastTimestamp = 0; + cachedUserAgent = undefined; +} + +function nextFreeTierCounter(timestamp: number): number { + if (timestamp !== freetierIdLastTimestamp) { + freetierIdLastTimestamp = timestamp; + freetierIdCounter = 0; + } + freetierIdCounter += 1; + return freetierIdCounter; +} + +function declaredToolName(tool: unknown): string | undefined { + if (!isRecord(tool)) { + return undefined; + } + return readString(tool.name) ?? (isRecord(tool.function) ? readString(tool.function.name) : undefined); +} + +function isResponsesEndpoint(upstreamUrl: string): boolean { + try { + return new URL(upstreamUrl).pathname.replace(/\/+$/, "").endsWith("/responses"); + } catch { + return false; + } +} + +function responsesPlaceholderTool(name: string): Record { + return { + description: OPENCODE_FREETIER_PLACEHOLDER_TOOL_DESCRIPTION, + name, + parameters: OPENCODE_FREETIER_PLACEHOLDER_TOOL_PARAMETERS, + type: "function" + }; +} + +function chatCompletionsPlaceholderTool(name: string): Record { + return { + function: { + description: OPENCODE_FREETIER_PLACEHOLDER_TOOL_DESCRIPTION, + name, + parameters: OPENCODE_FREETIER_PLACEHOLDER_TOOL_PARAMETERS + }, + type: "function" + }; +} + +function randomOpenCodeIdPart(): string { + const bytes = randomBytes(OPENCODE_ID_RANDOM_LENGTH); + let result = ""; + for (let index = 0; index < OPENCODE_ID_RANDOM_LENGTH; index += 1) { + result += OPENCODE_ID_BASE62[bytes[index] % OPENCODE_ID_BASE62.length]; + } + return result; +} + +function readInstalledOpenCodeUserAgent(): string | undefined { + try { + const output = execFileSync("opencode", ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000 + }).trim(); + const version = output.match(/\d+\.\d+\.\d+/)?.[0]; + return version ? `opencode/${version}` : undefined; + } catch { + // The CCR host may not have the OpenCode CLI installed; callers fall back + // to a pinned recent client version instead of failing the request. + return undefined; + } +} diff --git a/packages/core/src/agents/local-providers/opencode.ts b/packages/core/src/agents/local-providers/opencode.ts index 5cbdce9e..45453431 100644 --- a/packages/core/src/agents/local-providers/opencode.ts +++ b/packages/core/src/agents/local-providers/opencode.ts @@ -28,6 +28,7 @@ import { uniqueStrings } from "@ccr/core/agents/local-providers/shared"; import { findProviderPresetByBaseUrl } from "@ccr/core/providers/presets/index"; +import { openCodePublicFreeTierPlugin } from "@ccr/core/agents/local-providers/opencode-freetier"; type OpenCodeCredential = { apiKey?: string; @@ -191,7 +192,13 @@ export function importOpenCodeProvider( ...provider, apiKey: "public" }, - providerPlugins: [] + // Marker plugins only: the gateway runtime hook mints a fresh OpenCode + // free-tier client fingerprint per request. No auth section here on + // purpose, so a later real api_key edit is never overridden. + providerPlugins: [ + openCodePublicFreeTierPlugin(), + openCodePublicFreeTierPlugin(providerInternalNamePlaceholder, "-internal") + ] }; } const apiKey = credential?.apiKey; diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index 8815caca..5c0867d0 100644 --- a/packages/core/src/gateway/core-runtime/config-compiler.ts +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -20,6 +20,7 @@ import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin"; import { isLocalAgentOauthProviderPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook"; import { ccrRouterPluginKey } from "@ccr/core/gateway/core-runtime/router-plugin-contract"; +import { isOpenCodePublicFreeTierPlugin } from "@ccr/core/agents/local-providers/opencode-freetier"; import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution"; const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer"; @@ -219,7 +220,7 @@ function coreGatewayStaticAuthKeys( } function localAgentAuthProviderHookPluginConfig(providerPlugins: unknown[]): Record | undefined { - if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) { + if (!providerPlugins.some((plugin) => isLocalAgentOauthProviderPlugin(plugin) || isOpenCodePublicFreeTierPlugin(plugin))) { return undefined; } return { diff --git a/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts index e0c4d32d..fbf16154 100644 --- a/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts +++ b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts @@ -1,4 +1,5 @@ import { readClaudeCodeOauth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service"; +import { applyOpenCodePublicFreeTierHeaders, isOpenCodePublicFreeTierPlugin, withOpenCodePublicFreeTierTools } from "@ccr/core/agents/local-providers/opencode-freetier"; import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok"; import { kimiAccessTokenExpired, kimiIdentityHeaders } from "@ccr/core/agents/local-providers/kimi"; import { transformCodexApplyPatchBridgeRequestBody } from "@ccr/core/gateway/features/codex-patch-bridge"; @@ -69,9 +70,22 @@ function localAgentOauthProviderHook(plugin: unknown): ProviderHook | undefined if (!isRecord(plugin)) { return undefined; } - const kind = localAgentOauthKind(plugin); const key = stringValue(plugin.key); - if (!kind || !key) { + if (!key) { + return undefined; + } + + if (isOpenCodePublicFreeTierPlugin(plugin)) { + return { + key: `${configProviderPluginKeyPrefix}${key}`, + provider: stringValue(plugin.provider), + providerName: stringValue(plugin.providerName), + transformRequest: transformOpenCodePublicFreeTierRequest + }; + } + + const kind = localAgentOauthKind(plugin); + if (!kind) { return undefined; } @@ -173,6 +187,17 @@ async function resolveLiveKimiAccessToken(plugin: Record): Prom return originalBearerToken(plugin); } +function transformOpenCodePublicFreeTierRequest(input: ProviderPluginInput): ProviderHookResult { + return { + ok: true, + value: { + ...input.upstreamRequest, + body: withOpenCodePublicFreeTierTools(input.upstreamRequest.body, input.upstreamRequest.url), + headers: applyOpenCodePublicFreeTierHeaders(input.upstreamRequest.headers) + } + }; +} + function transformWithHeaders(input: ProviderPluginInput, headers: HeaderRecord): ProviderHookResult { return { ok: true, diff --git a/packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs b/packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs new file mode 100644 index 00000000..aea66926 --- /dev/null +++ b/packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs @@ -0,0 +1,326 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; +import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts"; +import { createGatewayPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook.ts"; +import { + OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX, + OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK, + applyOpenCodePublicFreeTierHeaders, + formatOpenCodeId, + isOpenCodePublicFreeTierPlugin, + mintOpenCodeRequestId, + mintOpenCodeSessionId, + openCodeIdTimestampMs, + openCodePublicFreeTierHeaders, + openCodePublicFreeTierPlugin, + openCodePublicFreeTierUserAgent, + resetOpenCodePublicFreeTierState, + withOpenCodePublicFreeTierTools +} from "@ccr/core/agents/local-providers/opencode-freetier.ts"; + +test("OpenCode free-tier session IDs embed a fresh descending timestamp", () => { + resetOpenCodePublicFreeTierState(); + const id = mintOpenCodeSessionId(); + assert.match(id, /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + assert.equal(id.length, 30); + const timestamp = openCodeIdTimestampMs(id); + assert.ok(timestamp !== undefined); + assert.ok(Math.abs(timestamp - (Date.now() % 2 ** 36)) < 60_000); +}); + +test("OpenCode free-tier request IDs embed a fresh ascending timestamp", () => { + resetOpenCodePublicFreeTierState(); + const id = mintOpenCodeRequestId(); + assert.match(id, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + assert.equal(id.length, 30); + const timestamp = openCodeIdTimestampMs(id); + assert.ok(timestamp !== undefined); + assert.ok(Math.abs(timestamp - (Date.now() % 2 ** 36)) < 60_000); +}); + +test("OpenCode free-tier IDs order by direction for identical timestamps", () => { + const first = formatOpenCodeId("ses", "descending", 1789604300000, 1, "A".repeat(14)); + const second = formatOpenCodeId("ses", "descending", 1789604300000, 2, "A".repeat(14)); + assert.ok(first > second); + + const firstRequest = formatOpenCodeId("msg", "ascending", 1789604300000, 1, "A".repeat(14)); + const secondRequest = formatOpenCodeId("msg", "ascending", 1789604300000, 2, "A".repeat(14)); + assert.ok(firstRequest < secondRequest); + + assert.equal(openCodeIdTimestampMs(first), openCodeIdTimestampMs(firstRequest)); +}); + +test("OpenCode free-tier IDs are unique across requests", () => { + resetOpenCodePublicFreeTierState(); + const ids = new Set(); + for (let index = 0; index < 200; index += 1) { + ids.add(mintOpenCodeSessionId()); + ids.add(mintOpenCodeRequestId()); + } + assert.equal(ids.size, 400); +}); + +test("OpenCode free-tier timestamp decoder rejects malformed IDs", () => { + assert.equal(openCodeIdTimestampMs("ses_short"), undefined); + assert.equal(openCodeIdTimestampMs("ses_ZZZZZZZZZZZZAAAAAAAAAAAAAA"), undefined); + assert.equal(openCodeIdTimestampMs("no-separator-at-all-0123456789abcdef"), undefined); + assert.equal(openCodeIdTimestampMs(""), undefined); +}); + +test("OpenCode free-tier user agent prefers an explicit override", () => { + const previousOverride = process.env.CCR_OPENCODE_USER_AGENT; + process.env.CCR_OPENCODE_USER_AGENT = "opencode/9.9.9-test"; + resetOpenCodePublicFreeTierState(); + try { + assert.equal(openCodePublicFreeTierUserAgent(), "opencode/9.9.9-test"); + } finally { + restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride); + resetOpenCodePublicFreeTierState(); + } +}); + +test("OpenCode free-tier user agent reads the installed CLI version", () => { + const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-opencode-freetier-bin-")); + writeFileSync(path.join(binDir, "opencode"), "#!/bin/sh\necho 'opencode version 7.8.9'\n"); + chmodSync(path.join(binDir, "opencode"), 0o755); + const previousPath = process.env.PATH; + const previousOverride = process.env.CCR_OPENCODE_USER_AGENT; + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; + delete process.env.CCR_OPENCODE_USER_AGENT; + resetOpenCodePublicFreeTierState(); + try { + assert.equal(openCodePublicFreeTierUserAgent(), "opencode/7.8.9"); + } finally { + restoreEnv("PATH", previousPath); + restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride); + resetOpenCodePublicFreeTierState(); + rmSync(binDir, { force: true, recursive: true }); + } +}); + +test("OpenCode free-tier user agent falls back without an installed CLI", () => { + const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-opencode-freetier-empty-bin-")); + const previousPath = process.env.PATH; + const previousOverride = process.env.CCR_OPENCODE_USER_AGENT; + process.env.PATH = binDir; + delete process.env.CCR_OPENCODE_USER_AGENT; + resetOpenCodePublicFreeTierState(); + try { + assert.equal(openCodePublicFreeTierUserAgent(), OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK); + } finally { + restoreEnv("PATH", previousPath); + restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride); + resetOpenCodePublicFreeTierState(); + rmSync(binDir, { force: true, recursive: true }); + } +}); + +test("OpenCode free-tier headers carry a fresh client fingerprint", () => { + const previousOverride = process.env.CCR_OPENCODE_USER_AGENT; + process.env.CCR_OPENCODE_USER_AGENT = "opencode/1.18.31"; + resetOpenCodePublicFreeTierState(); + try { + const headers = openCodePublicFreeTierHeaders(); + assert.equal(headers["user-agent"], "opencode/1.18.31"); + assert.equal(headers["x-opencode-client"], "cli"); + assert.match(headers["x-opencode-session"], /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + assert.match(headers["x-opencode-request"], /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + } finally { + restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride); + resetOpenCodePublicFreeTierState(); + } +}); + +test("OpenCode free-tier header application overwrites the client user agent but keeps auth", () => { + const previousOverride = process.env.CCR_OPENCODE_USER_AGENT; + process.env.CCR_OPENCODE_USER_AGENT = "opencode/1.18.31"; + resetOpenCodePublicFreeTierState(); + try { + const input = { + authorization: "Bearer public", + "User-Agent": "claude-code/1.0", + "x-custom": "keep" + }; + const headers = applyOpenCodePublicFreeTierHeaders(input); + assert.equal(headers["user-agent"], "opencode/1.18.31"); + assert.equal(headers["User-Agent"], undefined); + assert.equal(headers.authorization, "Bearer public"); + assert.equal(headers["x-custom"], "keep"); + assert.match(headers["x-opencode-session"], /^ses_/); + assert.match(headers["x-opencode-request"], /^msg_/); + assert.equal(input["User-Agent"], "claude-code/1.0"); + } finally { + restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride); + resetOpenCodePublicFreeTierState(); + } +}); + +test("OpenCode free-tier plugin detector only matches managed public imports", () => { + assert.equal(isOpenCodePublicFreeTierPlugin(openCodePublicFreeTierPlugin()), true); + assert.equal(isOpenCodePublicFreeTierPlugin({ + key: "ccr-local-agent-opencode-public-Responses-OPENCODE-PUBLIC-FREETIER".toLowerCase(), + providerName: "OpenCode Public" + }), true); + assert.equal(isOpenCodePublicFreeTierPlugin({ + key: "ccr-local-agent-grok-cli-api-grok-cli-oauth" + }), false); + assert.equal(isOpenCodePublicFreeTierPlugin({ key: "external-opencode-public-freetier" }), false); + assert.equal(isOpenCodePublicFreeTierPlugin({}), false); + assert.equal(isOpenCodePublicFreeTierPlugin(undefined), false); +}); + +test("OpenCode free-tier import plugin never overrides auth", () => { + const plugin = openCodePublicFreeTierPlugin(); + assert.equal( + plugin.key, + `ccr-local-agent-__CCR_PROVIDER_NAME_SLUG__-${OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX}` + ); + assert.equal(plugin.providerName, "__CCR_PROVIDER_NAME__"); + assert.equal(plugin.auth, undefined); +}); + +test("OpenCode free-tier import plugin carries a request section so the gateway keeps it for runtime hooks", () => { + assert.deepEqual(openCodePublicFreeTierPlugin().request, { headers: { "x-opencode-client": "cli" } }); +}); + +test("OpenCode free-tier internal import plugin gets a key distinct from the display one", () => { + const internal = openCodePublicFreeTierPlugin("__CCR_PROVIDER_INTERNAL_NAME__", "-internal"); + + assert.equal(internal.providerName, "__CCR_PROVIDER_INTERNAL_NAME__"); + assert.notEqual(internal.key, openCodePublicFreeTierPlugin().key); + assert.equal(isOpenCodePublicFreeTierPlugin(internal), true); +}); + +test("OpenCode free-tier hook mints headers per request without touching auth", async () => { + const previousOverride = process.env.CCR_OPENCODE_USER_AGENT; + process.env.CCR_OPENCODE_USER_AGENT = "opencode/1.18.31"; + resetOpenCodePublicFreeTierState(); + try { + const [hook] = createGatewayPlugin({ + config: { + providerPlugins: [{ + key: "ccr-local-agent-opencode-public-chat-completions-opencode-public-freetier", + providerName: "OpenCode Public (Chat Completions)" + }] + } + }).providerHooks; + assert.ok(hook); + assert.equal(hook.authenticate, undefined); + + const first = await hook.transformRequest({ + model: "big-pickle", + upstreamRequest: { + headers: { authorization: "Bearer public", "user-agent": "some-client/1.0" }, + method: "POST", + url: "https://opencode.ai/zen/v1/chat/completions" + } + }); + assert.equal(first.ok, true); + assert.equal(first.value.headers.authorization, "Bearer public"); + assert.equal(first.value.headers["user-agent"], "opencode/1.18.31"); + assert.match(first.value.headers["x-opencode-session"], /^ses_/); + + const second = await hook.transformRequest({ + model: "big-pickle", + upstreamRequest: { headers: {}, method: "POST", url: "https://opencode.ai/zen/v1/chat/completions" } + }); + assert.equal(second.ok, true); + assert.notEqual(second.value.headers["x-opencode-session"], first.value.headers["x-opencode-session"]); + assert.notEqual(second.value.headers["x-opencode-request"], first.value.headers["x-opencode-request"]); + } finally { + restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride); + resetOpenCodePublicFreeTierState(); + } +}); + +test("OpenCode free-tier tools append the required bash and read to chat completions requests", () => { + const clientTool = { function: { name: "Bash", parameters: { type: "object" } }, type: "function" }; + const body = { model: "big-pickle", stream: true, tools: [clientTool] }; + + const next = withOpenCodePublicFreeTierTools(body, "https://opencode.ai/zen/v1/chat/completions"); + + assert.deepEqual(next.tools.map((tool) => tool.function.name), ["Bash", "bash", "read"]); + assert.equal(next.tools[0], clientTool); + assert.deepEqual(body.tools, [clientTool]); +}); + +test("OpenCode free-tier tools use the flat tool shape on the responses endpoint", () => { + const next = withOpenCodePublicFreeTierTools({ input: "hi" }, "https://opencode.ai/zen/v1/responses"); + + assert.deepEqual(next.tools.map((tool) => [tool.type, tool.name]), [["function", "bash"], ["function", "read"]]); +}); + +test("OpenCode free-tier tools keep requests that already declare bash and read untouched", () => { + const body = { + tools: [ + { function: { name: "read" }, type: "function" }, + { function: { name: "bash" }, type: "function" } + ] + }; + + assert.equal(withOpenCodePublicFreeTierTools(body, "https://opencode.ai/zen/v1/chat/completions"), body); +}); + +test("OpenCode free-tier hook adds the required tools to the upstream body", async () => { + const [hook] = createGatewayPlugin({ + config: { + providerPlugins: [{ + key: "ccr-local-agent-opencode-public-chat-completions-opencode-public-freetier", + providerName: "OpenCode Public (Chat Completions)" + }] + } + }).providerHooks; + + const result = await hook.transformRequest({ + model: "big-pickle", + upstreamRequest: { + body: { model: "big-pickle", stream: true }, + headers: {}, + method: "POST", + url: "https://opencode.ai/zen/v1/chat/completions" + } + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.value.body.tools.map((tool) => tool.function.name), ["bash", "read"]); +}); + +test("core gateway config installs the runtime hook for public free-tier plugins", async () => { + const config = createDefaultAppConfig(); + config.providerPlugins = [{ + key: "ccr-local-agent-opencode-public-chat-completions-opencode-public-freetier", + providerName: "OpenCode Public (Chat Completions)" + }]; + config.Providers = [ + { + api_base_url: "https://opencode.ai/zen/v1", + api_key: "public", + id: "opencode-public-chat-completions", + models: ["big-pickle"], + name: "OpenCode Public (Chat Completions)", + type: "openai_chat_completions" + } + ]; + + const compiled = await compileCoreGatewayConfig( + config, + "raw-trace-token", + "billing-usage-token", + "core-auth-token" + ); + const plugins = Array.isArray(compiled.plugins) ? compiled.plugins : []; + const freeTierHookPlugin = plugins.find((plugin) => plugin.key === "ccr-local-agent-auth-provider-hooks"); + assert.ok(freeTierHookPlugin); +}); + +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs b/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs index fc858548..3cbb18b1 100644 --- a/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs +++ b/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs @@ -10,6 +10,7 @@ import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-providers/opencode.ts"; import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared.ts"; +import { OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX } from "@ccr/core/agents/local-providers/opencode-freetier.ts"; test("OpenCode local provider imports Zen models using each model's native protocol", async () => { await withOpenCodeHome(async (home) => { @@ -381,13 +382,13 @@ test("OpenCode local provider imports public free models without a login", async assert.ok(available.every((candidate) => candidate.detail.includes("No login is required"))); const chatResult = importOpenCodeProvider(candidateForProtocol(candidates, "openai_chat_completions"), []); - assert.deepEqual(chatResult.providerPlugins, []); + assertPublicFreeTierPlugins(chatResult.providerPlugins); assert.equal(chatResult.provider.apiKey, "public"); assert.deepEqual(chatResult.provider.models, ["chat-free"]); assert.equal(chatResult.provider.account, undefined); const anthropicResult = importOpenCodeProvider(candidateForProtocol(candidates, "anthropic_messages"), []); - assert.deepEqual(anthropicResult.providerPlugins, []); + assertPublicFreeTierPlugins(anthropicResult.providerPlugins); assert.equal(anthropicResult.provider.apiKey, "public"); }); }); @@ -537,6 +538,25 @@ function candidateForId(candidates, id) { return candidate; } +function assertPublicFreeTierPlugins(providerPlugins) { + assert.equal(providerPlugins.length, 2); + for (const plugin of providerPlugins) { + assert.ok(plugin.key.includes(OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX)); + assert.equal(plugin.auth, undefined); + } + assert.deepEqual( + providerPlugins.map((plugin) => plugin.key), + [ + "ccr-local-agent-__CCR_PROVIDER_NAME_SLUG__-opencode-public-freetier", + "ccr-local-agent-__CCR_PROVIDER_NAME_SLUG__-opencode-public-freetier-internal" + ] + ); + assert.deepEqual( + providerPlugins.map((plugin) => plugin.providerName), + ["__CCR_PROVIDER_NAME__", "__CCR_PROVIDER_INTERNAL_NAME__"] + ); +} + async function withOpenCodeHome(run) { const environmentNames = [ "CCR_INTERNAL_HOME_DIR",