From 222ccfa208bb665197b351ece961346d84419154 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 10 Jul 2026 22:31:50 +0800 Subject: [PATCH 01/38] Update provider links for code0.ai and claudeapi --- README.md | 4 ++-- README_zh.md | 4 ++-- docs/src/content/docs/en/configuration/provider-deeplink.md | 4 ++-- docs/src/content/docs/zh/configuration/provider-deeplink.md | 4 ++-- packages/core/src/providers/presets/claudeapi/index.ts | 2 +- packages/core/src/providers/presets/code0/index.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 10f4c32d..aead10cf 100644 --- a/README.md +++ b/README.md @@ -210,14 +210,14 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co - + code0.ai icon
code0.ai
- + claudeapi icon
claudeapi diff --git a/README_zh.md b/README_zh.md index 183df2d9..339de073 100644 --- a/README_zh.md +++ b/README_zh.md @@ -209,14 +209,14 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
- + code0.ai 图标
code0.ai
- + claudeapi 图标
claudeapi diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md index b2dd290c..571d7627 100644 --- a/docs/src/content/docs/en/configuration/provider-deeplink.md +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -78,11 +78,11 @@ Choose a provider below to get started. CCR shows what will be added before savi TeamoRouterAnthropic / Chat / Responses
- + code0.aiAnthropic / Chat / Responses - + claudeapiAnthropic Messages diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index ac471c7f..7b0c9303 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -78,11 +78,11 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少 TeamoRouterAnthropic / Chat / Responses - + code0.aiAnthropic / Chat / Responses - + claudeapiAnthropic Messages diff --git a/packages/core/src/providers/presets/claudeapi/index.ts b/packages/core/src/providers/presets/claudeapi/index.ts index 85a7b65e..dbd10d79 100644 --- a/packages/core/src/providers/presets/claudeapi/index.ts +++ b/packages/core/src/providers/presets/claudeapi/index.ts @@ -11,5 +11,5 @@ export const claudeApiProviderPreset: ProviderPreset = { ], id: "claudeapi", name: "claudeapi", - websiteUrl: "https://www.claudeapi.com?source=claudecoderouter" + websiteUrl: "https://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default" }; diff --git a/packages/core/src/providers/presets/code0/index.ts b/packages/core/src/providers/presets/code0/index.ts index b87be187..1caf8324 100644 --- a/packages/core/src/providers/presets/code0/index.ts +++ b/packages/core/src/providers/presets/code0/index.ts @@ -11,5 +11,5 @@ export const code0ProviderPreset: ProviderPreset = { ], id: "code0", name: "code0.ai", - websiteUrl: "https://code0.ai?source=claudecoderouter" + websiteUrl: "https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default" }; From 2bb2611447b9fbb4c04ef4bb1e440e5528328c58 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 10 Jul 2026 23:14:35 +0800 Subject: [PATCH 02/38] Add Qiniu Cloud AI and Fenno provider presets --- README.md | 16 ++++ README_zh.md | 16 ++++ docs/public/provider-icons/fenno.jpg | Bin 0 -> 98987 bytes docs/public/provider-icons/qiniu-ai.png | Bin 0 -> 20727 bytes .../en/configuration/provider-deeplink.md | 8 ++ .../zh/configuration/provider-deeplink.md | 8 ++ package-lock.json | 8 +- package.json | 2 +- packages/cli/README.md | 14 ++++ packages/cli/README_zh.md | 14 ++++ .../core/src/providers/presets/fenno/index.ts | 15 ++++ packages/core/src/providers/presets/index.ts | 4 + .../src/providers/presets/qiniu-ai/index.ts | 35 ++++++++ packages/core/src/providers/probe.ts | 16 +++- packages/core/src/providers/url.ts | 23 +++++- .../ui/src/assets/provider-icons/fenno.jpg | Bin 0 -> 98987 bytes .../ui/src/assets/provider-icons/qiniu-ai.png | Bin 0 -> 20727 bytes .../src/pages/home/components/providers.tsx | 14 +--- packages/ui/src/pages/home/shared/i18n.tsx | 1 - packages/ui/src/pages/home/shared/options.ts | 4 + .../ui/src/pages/home/shared/providers.ts | 3 +- tests/main/provider-preset-utils.test.mjs | 30 +++++++ tests/main/provider-url.test.mjs | 12 +++ tests/renderer/providers.test.ts | 76 +++++++++++++++++- 24 files changed, 294 insertions(+), 25 deletions(-) create mode 100644 docs/public/provider-icons/fenno.jpg create mode 100644 docs/public/provider-icons/qiniu-ai.png create mode 100644 packages/core/src/providers/presets/fenno/index.ts create mode 100644 packages/core/src/providers/presets/qiniu-ai/index.ts create mode 100644 packages/ui/src/assets/provider-icons/fenno.jpg create mode 100644 packages/ui/src/assets/provider-icons/qiniu-ai.png diff --git a/README.md b/README.md index aead10cf..f32ddf13 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,22 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co + + + + Qiniu Cloud AI icon +
+ Qiniu Cloud AI +
+ + + + Fenno.ai icon +
+ Fenno.ai +
+ +

Community Sponsors

diff --git a/README_zh.md b/README_zh.md index 339de073..7065c54e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -223,6 +223,22 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 + + + + 七牛云 AI 图标 +
+ 七牛云 AI +
+ + + + Fenno.ai 图标 +
+ Fenno.ai +
+ +

社区赞助者

diff --git a/docs/public/provider-icons/fenno.jpg b/docs/public/provider-icons/fenno.jpg new file mode 100644 index 0000000000000000000000000000000000000000..364f735c4e71e15847fd08da152bfed7e5c0f274 GIT binary patch literal 98987 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�*pyyGk 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 G@c#f;|1n7b literal 0 HcmV?d00001 diff --git a/docs/public/provider-icons/qiniu-ai.png b/docs/public/provider-icons/qiniu-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..2ff255dc7617f18b63fb5ef18a5c5584f27868b3 GIT binary patch literal 20727 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 claudeapiAnthropic Messages + + + Qiniu Cloud AIChat / Responses / Anthropic / Gemini Generate + + + + Fenno.aiChat / Responses / Anthropic + ## Embeddable Button Component diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index 7b0c9303..a2814711 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -86,6 +86,14 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少 claudeapiAnthropic Messages + + + 七牛云 AIChat / Responses / Anthropic / Gemini Generate + + + + Fenno.aiChat / Responses / Anthropic + ## 嵌入式按钮组件 diff --git a/package-lock.json b/package-lock.json index 6bd7bb43..0a22cc47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "packages/*" ], "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.6", + "@the-next-ai/ai-gateway": "^1.0.7", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", @@ -2310,9 +2310,9 @@ } }, "node_modules/@the-next-ai/ai-gateway": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.6.tgz", - "integrity": "sha512-EIJjwvc//hql1jwE94yyMWNGrJLuCJQlTNgn8p3KWhDYSiOWp4vmLU2Gw3/A+J49X21V7HYdX/W9YuJUai80eQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.7.tgz", + "integrity": "sha512-HJtfjpBgjFEPQ6ub7nFTtEyxzzbrz6V5vQZfJOoHi3Jhpjob3W4h0UenTW6loHanJYucFIEh01SvB//Qj3ulcw==", "license": "MIT", "dependencies": { "diff": "^8.0.3", diff --git a/package.json b/package.json index 2333cb7e..605606f8 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.6", + "@the-next-ai/ai-gateway": "^1.0.7", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", diff --git a/packages/cli/README.md b/packages/cli/README.md index 1862494b..17dc66b5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -208,6 +208,20 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co TeamoRouter + + + Qiniu Cloud AI icon +
+ Qiniu Cloud AI +
+ + + + Fenno.ai icon +
+ Fenno.ai +
+ diff --git a/packages/cli/README_zh.md b/packages/cli/README_zh.md index 959c2393..58c4fef9 100644 --- a/packages/cli/README_zh.md +++ b/packages/cli/README_zh.md @@ -207,6 +207,20 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 TeamoRouter + + + 七牛云 AI 图标 +
+ 七牛云 AI +
+ + + + Fenno.ai 图标 +
+ Fenno.ai +
+ diff --git a/packages/core/src/providers/presets/fenno/index.ts b/packages/core/src/providers/presets/fenno/index.ts new file mode 100644 index 00000000..63a645ce --- /dev/null +++ b/packages/core/src/providers/presets/fenno/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const fennoProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["fenno", "fenno.ai", "fenno ai"], + endpoints: [ + { + baseUrl: "https://api.fenno.ai", + protocols: ["openai_chat_completions", "openai_responses", "anthropic_messages"] + } + ], + id: "fenno", + name: "Fenno.ai", + websiteUrl: "https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=9HHHAB5QLAES" +}; diff --git a/packages/core/src/providers/presets/index.ts b/packages/core/src/providers/presets/index.ts index 7ea43061..2a56d91a 100644 --- a/packages/core/src/providers/presets/index.ts +++ b/packages/core/src/providers/presets/index.ts @@ -3,6 +3,7 @@ import { bailianProviderPreset } from "@ccr/core/providers/presets/bailian/index import { claudeApiProviderPreset } from "@ccr/core/providers/presets/claudeapi/index"; import { code0ProviderPreset } from "@ccr/core/providers/presets/code0/index"; import { deepSeekProviderPreset } from "@ccr/core/providers/presets/deepseek/index"; +import { fennoProviderPreset } from "@ccr/core/providers/presets/fenno/index"; import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index"; import { kimiCodingProviderPreset } from "@ccr/core/providers/presets/kimi-coding/index"; import { minimaxChinaProviderPreset, minimaxGlobalProviderPreset } from "@ccr/core/providers/presets/minimax/index"; @@ -10,6 +11,7 @@ import { mistralProviderPreset } from "@ccr/core/providers/presets/mistral/index import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "@ccr/core/providers/presets/moonshot/index"; import { openaiProviderPreset } from "@ccr/core/providers/presets/openai/index"; import { openRouterProviderPreset } from "@ccr/core/providers/presets/openrouter/index"; +import { qiniuAiProviderPreset } from "@ccr/core/providers/presets/qiniu-ai/index"; import { runApiProviderPreset } from "@ccr/core/providers/presets/runapi/index"; import { siliconFlowProviderPreset } from "@ccr/core/providers/presets/siliconflow/index"; import { teamoRouterProviderPreset } from "@ccr/core/providers/presets/teamorouter/index"; @@ -46,6 +48,8 @@ export const providerPresets: ProviderPreset[] = [ moonshotGlobalProviderPreset, bailianProviderPreset, siliconFlowProviderPreset, + qiniuAiProviderPreset, + fennoProviderPreset, runApiProviderPreset, teamoRouterProviderPreset, code0ProviderPreset, diff --git a/packages/core/src/providers/presets/qiniu-ai/index.ts b/packages/core/src/providers/presets/qiniu-ai/index.ts new file mode 100644 index 00000000..36deee3d --- /dev/null +++ b/packages/core/src/providers/presets/qiniu-ai/index.ts @@ -0,0 +1,35 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const qiniuAiProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["qiniu", "qiniu ai", "qiniu cloud ai", "qiniu yun ai", "qiniu yun", "七牛云", "七牛云ai", "七牛云 ai", "modelink"], + endpoints: [ + { + baseUrl: "https://api.qnaigc.com", + label: "China mainland OpenAI", + protocols: ["openai_chat_completions"], + websiteUrl: "https://s.qiniu.com/AVjMVf" + }, + { + baseUrl: "https://api.qnaigc.com/bypass/openai/v1", + label: "China mainland OpenAI Responses", + protocols: ["openai_responses"], + websiteUrl: "https://s.qiniu.com/AVjMVf" + }, + { + baseUrl: "https://api.qnaigc.com", + label: "China mainland Anthropic", + protocols: ["anthropic_messages"], + websiteUrl: "https://s.qiniu.com/AVjMVf" + }, + { + baseUrl: "https://api.qnaigc.com/bypass/vertex/v1", + label: "China mainland Gemini Generate", + protocols: ["gemini_generate_content"], + websiteUrl: "https://s.qiniu.com/AVjMVf" + } + ], + id: "qiniu-ai", + name: "七牛云 AI", + websiteUrl: "https://s.qiniu.com/AVjMVf" +}; diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index 94b4f5a4..3f6c7bd4 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -465,7 +465,7 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }; } - const result = await requestJson(withGeminiKey(`${parsed.geminiBaseUrl}/v1beta/models`, apiKey), { + const result = await requestJson(withGeminiKey(geminiApiEndpoint(parsed.geminiBaseUrl, "models"), apiKey), { headers: { ...geminiHeaders(apiKey) }, @@ -762,11 +762,11 @@ function endpointsForProtocol( return [ { baseUrl: parsed.geminiBaseUrl, - endpoint: `${parsed.geminiBaseUrl}/v1beta/interactions` + endpoint: geminiApiEndpoint(parsed.geminiBaseUrl, "interactions", "v1beta") }, { baseUrl: parsed.geminiBaseUrl, - endpoint: `${parsed.geminiBaseUrl}/v1/interactions` + endpoint: geminiApiEndpoint(parsed.geminiBaseUrl, "interactions", "v1") } ]; } @@ -775,11 +775,19 @@ function endpointsForProtocol( return [ { baseUrl: parsed.geminiBaseUrl, - endpoint: `${parsed.geminiBaseUrl}/v1beta/models/${encodedModel}:generateContent` + endpoint: geminiApiEndpoint(parsed.geminiBaseUrl, `models/${encodedModel}:generateContent`) } ]; } +function geminiApiEndpoint(baseUrl: string, path: string, defaultVersion: "v1" | "v1beta" = "v1beta"): string { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); + if (/\/v1(?:beta)?$/i.test(normalizedBaseUrl)) { + return `${normalizedBaseUrl}/${path}`; + } + return `${normalizedBaseUrl}/${defaultVersion}/${path}`; +} + function withGeminiKey(url: string, apiKey: string | undefined): string { if (!apiKey) { return url; diff --git a/packages/core/src/providers/url.ts b/packages/core/src/providers/url.ts index afb4c3a4..e3f66b39 100644 --- a/packages/core/src/providers/url.ts +++ b/packages/core/src/providers/url.ts @@ -39,7 +39,7 @@ export function parseProviderBaseUrl(value: string): ParsedProviderBaseUrl { return { anthropicBaseUrl, anthropicBaseUrlCandidates, - geminiBaseUrl: rootBaseUrl, + geminiBaseUrl: providerGeminiBaseUrl(normalizedInputBaseUrl, rootBaseUrl), normalizedInputBaseUrl, openaiBaseUrl, openaiBaseUrlCandidates, @@ -126,6 +126,27 @@ function stripProviderApiVersion(value: string): string { return compactProviderUrl(url); } +function providerGeminiBaseUrl(normalizedInputBaseUrl: string, rootBaseUrl: string): string { + return isVersionedVertexBypassBaseUrl(normalizedInputBaseUrl) + ? normalizedInputBaseUrl + : rootBaseUrl; +} + +function isVersionedVertexBypassBaseUrl(value: string): boolean { + try { + const url = new URL(value); + const segments = url.pathname + .split("/") + .map((segment) => segment.trim().toLowerCase()) + .filter(Boolean); + return segments.includes("bypass") && + segments.includes("vertex") && + /^(v1|v1beta)$/.test(segments[segments.length - 1] ?? ""); + } catch { + return false; + } +} + function stripNestedProviderApiVersion(pathname: string): string { return pathname.replace(/(\/v[0-9][a-z0-9-]*)\/v1$/i, "$1") || "/"; } diff --git a/packages/ui/src/assets/provider-icons/fenno.jpg b/packages/ui/src/assets/provider-icons/fenno.jpg new file mode 100644 index 0000000000000000000000000000000000000000..364f735c4e71e15847fd08da152bfed7e5c0f274 GIT binary patch literal 98987 zcmbTedpwhW_y>G%jI1OzC6%R6 zN$Z4E!fc8h%K4DwFk`cMKI{8?e$VrI{(7EAFS~EA?t8mGpX>TupX++R-`A$UP5*%A zc)EGGK`1ZQ#5ZXFA2pwHrof*hENTi+~5;fak)|>?>W8(#=`SVSd ztVEkETE2Mx{H1G_E?;GCfwfqOwp?e4S+^2nfziAPOjlPIIRiNliJXTqnQwym|NJxk z8Zw-r-L2iN1zQHe4Pjb_u<7>@8ayWg_V)++pC1@p3p}sx3_avbaDi|R1czy9!L_vz z2yJa}bu9RQNZSxG*KEDB&ODz3y2~O^8!p^_Fk|`FM{kUL>;A0Rc<^Mr9&-Kyde{bn1qX$E?>EN zjd3SACG~Dv`n`;t+&p$Zhg-mVTwEf2Qu_4S^SAHbmseDNsQM_bmq;7Fers&{+1}CF z)!ozE*FQWmIwl*JPfRK_z^x!HADM5vj2Aji~s*Mvi}{} z|23{AXqFZX7*ER(!a<5028N|245?BJUkM*H6?aZUc-G&ms-v(^R)(4+M$LysQ z*`OnQ|2yE3)Gm&qm{=3@j6#c|z^VER^H$NZZ8A^#se&N|8*fj5XUl9jiuy6Gcg)>- zRj7i1c;gCZK#U%h7s+TK;2}+tkyA#2h1N|&h2DZqS!l(3MXk;yln;JJ44NI1%^4u; zK2Z46B4sT2tuc0rd) z1&5QJQbm(GLLm%&cmf_8lnK^!?oMRj)drAiZnr!|N|`-9VG=^#1RFMPK0Xqit#zEXT_T|kfKggLd0j`V4`lX9A+Zzg6qi|>Ld_(FTtHsqhg z4#BmB%egQMeM>Xe%s(_9Il4de*53mK<{tF608Dft+VffK|C~SxsDpPIkT7J5v_FbB zPAm7_OlNO%N_v zbYEFLDShvX;e=Bm&V>OtUBNy>j@;GiuBZhCeW%H`=JEVqtauS{DgWN0Vj=X8evOU) z&^?%uL3+^xlzr6wlee?`-RyX}#XEWVKU}t};Ug_0zt7=A8Bi^2 ztZ*7yFM36pyD4sxrF~%3WLGN7aJ`)J4SX2PWyp~6DwCUg@YV?yX+iL;6Ymewt=GGT zTsy2D&f8Fx^ojzjA6=hM`f%IR!XcA*e}S?DmhI-QYZO*%IN!j=EK&DQH?Mi)nsk>E z0^&<-ubrH?s7*OIDITH?mG!vF?wgpkQ3RmWsqw+DEwMSMzsG9dQt z0e9W9e}GvKR(HX`q1!jya&?%K(uE7(-_Tl&3{Rg>R?w}Ta*f=ERG~}jcifB^C8Zk$ zZg{tfZlP64-m7d#(%g61THVWucl?22Hwrr?5NZ{O!lQ%Js zkG=?>d{qL|?QpIXP@;XNp%Xq`?y5Iisnyv|ROY9k-;AQu%CZ+u zL2QUzZo~w``#zfc`LA*3hy547bU3A|j;Sdr(-QX_qvepWD?vq7QRToN@2{aQKZt-l z80!OnbUeUit?kyj68?q0_Sxi&a-W$uI%^->otQZLh!!0UDTf@vZGUIEvW2BP zYyWBX)PTc_o*@&5O7$>HA0EMYVEg$-+?YJZ&KV_M59+K7p0y0A_`SY+w!; zhy{`b#)%tjd9M!9V!2w|7&mFM3UEC2{=cRd2 zC}go=C0T@S_VhF9xn$(&zW;I#_%$39Pxb6UuHnIzs*Ap8pTRaQr*-2IeE$ za~u|8IoEiW2Cw7TSlxZvM|!B|_I4P*z+&~5$Z05AWWO0Fzdy>FxjAb-&*agunPIJw z{%~_S1%AcH_dES@aN*#NKP6QaA4VV)4ENJ*HSQd~SiCE9v<&QtSK4EbmLMe4P*8cm3$WRU z$PW+GhJ;?aLn|gH+v#RYX~`aylQs|j*AaA1|K}Ab*w$Nh8G0<8ae*yx*dlSUuBSNZ zx0RT_7{l_xqktEGMYA^worY8q3SzV|OIvMH8lvW^sM&5!&yIi-3jB4>1y_hvrZy4y zhtn?e4-??y1;@Zrjx(C=hCPatF)u;;K;8zoqe(Z5i_0lq>Vj9P+4>L^vFrMfpR+M% zn_E9rx5R?71jB;P6n3Mx_+K3g9Fl+Ur@&H7QwT8x!~{+%@Mz5iOI4EIO`p#No6tkF zXBjaZ_Z4Jet{!f#Alil1{1)!E`KKnA1acFuCVxm0?843z$qo(RQROHk2JS_Z#TO~0 zi{cI&ook@aYq*b76n9%Nrho$L85=yN#cjaVltFxuPD%*+z>-H_)vqtUA2zp|Qf|^KFTg>}kle z#?c!*Ap8b$1Ev}Zx~}rAuZcMv)PFboTrCvW%UggO$S7hy$6l&Yj89?U4nB+xm=CO( zcXQ>32K;Uc5r0#gN;0Gm%z`YT3Wxp>5M=U>x(q10UKR3#`m>js#ZlC}mcFa4h3t`s zr=k9)Oar)PK3GVUY05peIRhdoKgk4!4aF%_e~)zJe~FEQ%=&VCG{|WeH8i-0f3^f@~a5CcInINQjmNO7sa6~gcWm~jsoxzX}o zxCy3uA#3LRvORtNN|M(u!5AMTUi^l8r`22u!;bq~B*}pC?2*2^EMUf{x$E%;S5yaM zsnbwV1J_yo$Vo{1I)I1r!k>qRqJM7|(>EshxvrvALt7e(IL+i5nLt~49X}g5x|S17 zRg7*2{<6hj-R|GC9-Op&pzs>c1jpvt~?P6}=$roj32GQ)*a2i)t8e zI4M?$!LQ`3Y07?Z)BVUEol(2*zLOEtP}GCRYI`BL#1Nd{!x*mW zMT=wVXGe;Yb;b0R8>?PB_P@U733rPm()-BklmCst&l)*2mRogWydVa}n`*!1k zOQ<%RX-KpUWc(&MEWPuMIMEc3ayxi$)h8s^-6*VH70`h<7?~$dGIPZ45YNLKb*nz< zX9nkvwkajbwwI}}0la~78zb(|6mPf3y;s3TdmKjnEhC=O%|}2^zAE`c(o{y;Xbyd8 z#(I-5?^IrC)@J#YF8|*mDaiy%t4>zF8{w>Yco_P(Qk?UeS$FhYBI`9Jx^tE}IL)tPi&R7VC^c&gB&Sw3wvX`| zz@%XZp^YDs94#KZ7$;se+7#n=c=;!gh4)mchQvuuK|^t==pMJY64=svRqsq5o9#G! zQ|P`pAzPMjpw)*roRK@0`!Pw(p2C0-bN%zrZ?jXPM-JX!>rjzD{Dh9~d1WuX+7Yl1 z_!%c)KM0}Bo-$}ll_qaUQTK9&-BB=qe@YwmL`mBw?;*b;C;3lOr6-i~$x6=Kjf<5} zmis$m%hQGl+O6B-QlbqNejs^V;XJp?Uh>a5MbgLo=ioh^g#Ppu8=dUcDUY^~*aj$t zdVRiNF;d`PZ)K-MgFk#D@P_GxS?&CVhjlOWAIH869D}AI`=BA8?oX=zBPiJEgIigt zJ*Zb;5oO-{_Hvxv(?DO?>8@%LtS$+EWb%-tNBe36S!Hb&m}P{M!+&m%OMy{W2G(1B zLY`@?SC-s8G-Ky()(oc_w47=uij>>(gL(hR$j}Y%RZf3$Ym$|3?R<&O37Cr(h_Xs24X06nm$k8raDX2o1; z-dzBa?3lb~H9twus;UEvE9&ucqkR-00q^teBy49P#qCzl%3Xnn^kFomCYopObGCV39}l*VR(hJGiEm$63KzupRVstb5mS$YnAw3nDs zw!?hXM_n@X)Xe+0c+x`EboPsVU5x-*_cpzcbmL)W7(c(5gD$g%hMYi1ahSxtw0J3A z+)lTwGvMkIW~*98>VbfL)va{wp@Hr0OZs~nJ7m_e**HJ^ zY#}JOvE+QQ@%@9RatEz?G4L+G7s~de5)q5zcA0js7cMl4{Pjp#H@;id3*WN#42f5? z%2M4k?C1KP`KuQ3>Kiv~;<}MxR`Is5;4M3$fT_M2#{E=zqn|_^n+j@ue^4lllC^GR z6z<-;nS>pSl_LZ~`Kfh0(r1VlCoy6*R`b-c__hwT+)A|;+O<4~WSE{8DX~5soNw(cm z;@$3R8#`j&f?8|2oa;3P>c zVjFs0cM0))4ohi|`C!ht(YzrO36(Ntv=^w@Dg-)|=8T{7G1APT{+yg2@%qLyyY8&< z!E@={s0_?*$7G?m;0otLap<=Y6#dP*ADL$ zLd2<^=xcl$n47wBifSih36h{M>}pcT`c3cqJjSnp1s8dPwCA>1c)`0c1f4x$r-OYPFj#F=wyI{Sg%lP3AZOQSCZe0slteGWoB(K2@F-UKX)uFYtubag= z{O#8_gohc|jUNFUFEJro7+eqA724hsA*o++XeIT`^BI{!nwQL{c2m^;GT+BXe7w|J|0#lWM#EMLYYUAo6U zo;Qnlxh1k1B5E~@FVy(?tG{IDCzjM9?8I1M-?Lki%Y9W# zFEDOxeW1*{K~Nk+v0A4mm$bYNam;JG1q2o}72t|62U)l$lp*JRxB#Zy&Q& zD?|U29M#(qm>hYUg`6}kIZP5gt{iZ0%$2cp8c$J+NqOb}PU0B3%59oZ)+{|&BMVA& z9lwTaX~$7VKSI48*zZx=*7)uxCrGe6$BP^z{YrFHG0wk4&yz-2t^)LS}tv{)uVBF|rjmk$+t33t^Dt}Pc z6jG~){*X@ULrV()i1(GayPXYk=zaJFp%$SSxWzOiDf1oU7f(Y4F>DDE1govI!Ur+6 z%mEC5hjVV3T14V?sb9ihwUO&;j4}ao_^JfDmuJoZ;4NGwQrre;`jWqg6o|w2eF@;& z3DP@pK`XiyWambpl$qB!MiY>SNXHbo2EiE?L?aiSQy0ckD<`Dq+B8L{5r$oE%+j+I zePuj9CtY+%IUJA_1L-<@n1k}hx9Gnk0f7V+&m0+=T<54OE>7~13Fg(tVqg?&q&^h# zN&V-bRU&cVgm7_#u3x$N&Ri$cR=xSUa^+I33PmnH_KvTZH-FN6%z{N~qk=Y%90U;3JNiVV0wb;b*iUypF1$=Y+-Q{pU_xFe9^4;s({J z&M`=lm7-KZLiA!_u3Cbn8ma*giqQlXTf<6KtA%O}E5!!Rt6>5x3_Qs$V!%w%_(m37 zLDKI_o)lOtmfh28MCG-{{=^&gXOWe=g7Fp=$w;eB02@Std{H^K zd>POkw2U8+M9wu(Ncj?o5aW}v^(<>vheT`wvIWx=D6zHVkn-ACVMlApL2zX0)&W4C z8KHijTxMCR=-A1E#`t#=5%~Epi>!xQ+#6|41(qVIr5E1l)hBh!BK7A@vA<=(1uZne6b9CI4Bc!U zTck=L6?=lxK1A&#YW(gs9zNc;W*WL#2rGvIl$({O?mK-sqv|i#Fb_PIPHeF%!Tme# zn~gsMD(<-!xASSWE%{H}G2)zr9t(cw;o85CSL)vQ=!@b}34wT!zB-n(_;~JM&@bsp zyCIJx0Tig*MZ28*>-%I2gMOOdS4+^w0R)7a^q3~}Kby;dxJO2Aem=$8rx3WGz@4qM zu6`X4w#TMe?#%HEFO0c*+DYud;+nLtWzgnd9Hk@{#Pm49p{8V032e`qMp09Xd($-Z zEw5}b+ZjLK>8rZjEO`*OYLhh5ugRd5$%eN4Vh+}gdyn^5SVZ8Bn${zSDk63+2y`eS zXLQE(c1o?%tYQ22MSb_`Ni&O(^NO^Gy|11J`Ivrbne}(@jBcScB*Y&-51k1?<#*^V zie&uz`nau8W@Wc6m_;y9-&nqiQB)<%v#X4OiaVp3PJ=HB;(!6X(2BeQTNbk7dz*E< zw|S!QaBM&46zv=XN*+9c1M!2I(4@} z$S6>&tQx)Lyb1)Sf&v#Yi#xx|Dd6*Lv^nx5Sv~>51i5&zN9SZ4uBC;vlj5u{z@dBa zb5zNy=T2bEJmut#elLr%&m+zlezffzD$|16HzxpRwtYDlGB&?L<4tz51 zGfw2*ctLIdD8Um}4UrnhBSk||Z18!g0j3Q12U~!jA176pn}%l~k1M4_$=x3LH5p6K z71U^%mZI-`+$D3cu&bXf{*98psQDuqiLED|%Ycgi&JyrD{z1AOYv!tN$yqI-PM>LI z{xD*__}8Y*Q|Zc!hpyw1$v z60$98~CY;$Xi7nnnfudxdTNJ(Uf3H;peWeu)VDI~L>bcesC zqsN_9qu>F3z$+=!2M~pwza~pVP)us&qDbG*>-Bi6Ycp#RNg%BMO93I^qV#+Q4A=r4 zY@Q{FO#yXr;p~(W*q&dn#$KICt7uK#KgOF&E3}4f{*G&8f#_3A2u={w*F?LK+Ul*D zM>>Xx|IR&NWTp!ehJT+_DJ2WmZE6+nH=y#PKj+YwjOVJFoFudtq{-Y2=+QKU#{C*( zE3K5Lo|Fas;TyA%Z6KhA-7o%CNo(bw{fk$}Swl5KTCsbRsF63BYYjUD##l$gq2+%*mDo`!}@gySjPmv^IDodyVq^BH=^-G#CDfF<(p2Uq&w=O6#~fG-vw zNosJtK+@kbXn~&>@(`Q#V-=oy;AA?t(*?I0ZzMAGw3ivJ6P`a!_M@u#_}Rs3`{E|P zn^TQ^8e-%rJqg;0l}1Ljrry@E4?^$QW<@a|9!!SXuOzm&3^yeevn6;pe|+@@IzVa#=HGZx>n+ zz8LX9MX%am%zS~L|16hDzPJx~YN}OJF4!#SD{-e9B9s+^>nX=B5UvNW_}7sFxB7BR zZFA_?(BSstZA-ZD&q4invc5?Sn=!5^QRog6C@Y4vn*F(1^66w5)^2$ybG4Yh`WEJO zf%>C5C+V?>3vxxL4q^w3yIl2pi?aTeI0d%8k5q({avHM6^Tw>J44NRlCi?338z$*{ zY|z0mkXMAV7W+f%G?7Tz)m^ZyA7DS_?wb|)T(vm+@@-DQqy|eRoOo(|F~Z-ZF=;vK za;vuweh&AYC8gjkgS+%VJk{p}i%x-`SNe9dfXUbT(yWBj6HMV`lBU!dCD!d z+BzkGr1E7Zjq*ex)NH%?OAVC-?e1IcIU6f1^gkMqAx5uSsR^p&+fKD-*z3q5A>`~2 z^75oV`HKR^Iq&sFZE7r3?3Hn4P_NIuX)b*(K$EAC1=7+8+ixi~#o+n?o0TG6~<&Xb8?Bn^J!T)27NU{-+Me1psc}Z5`y;K^Y134 z48fx75s&)XLX%WQAL-^c{_+V>I{f|Xp1ESzCYnCv?-)~SFDoc7RxpB!aa@*G5KdbCP!~(6W7yH@CnO;@U^Cr+DFyONHnopm=9!@@xS% z-;DBQd^v;Gl&1N#H3#EuHSU`Tf>x^I{Sg-O;z*2d9BFgow~_VL=wNW)#WBH5nrt;G z?z1|FJr8@H*}|G>44?(yx^V#Mue~-`5_@Uahu~xNabFryFqoVlML&ikAKJ5?^LE?P z%dDBUxkd&}bCXI|LYn~8L4D*P8No5-Tm0f`R^cww&7j}o1;J^bpue zJjQ{XhzGbmT%uKz@4e;MD&Dh@FMa%Gy30_aBQ)UR!3^fFqJ9i(MueLM&~7zcMECpk z1f#SJ9BG_2?4X9y`r0yY zfJBwbw<)A#Xz!_lhrMo zjS-9%-Ur|33h6#59MKsi>z0L-wpl|LBRxEKf~+K_4G^07Ii;X-5n8Yy>7p80ZeKv6 zrr0EdxHubOj0y(1;K7<)=>rnbRY)J;^&P2;L{FV^^1-nN9O+me#zt4j>$v(EwQrDk zT=W(B@*Dw*@mr}(9OKL@-{hivq9Ev;We<9|Q=x4GwA%!O@KZ@t-jJU43PL5718MoCLg4xYLMqzdh-C+e_hI!*a-CiIebP%}}YBpIi^% zKZ7rTI28gRlJ){xzX>ntxl<~22*}ucVkR2snQE6H%-Ggq|Kz&MY0mue9ZE{WjWx_ zGBQ{txMu~Uco^?%*}MV&odGX?Ht07$hh=iO11M=av z{%kttW;`GcbFCqs(`f~xuCNnSmPTy+3{dW)2s%d%(p8}rVp?IiV6!zyY-lozG2TKx z(3%0S!O!(^14N%M@RLBoxQ$Jl;^UR&8PI&e?y(qdUa+9?6v(o{93Vj1e^kbMC8Ww# zWEHhY0O{lox0w*LDhgP^5U^n;rkxPq2Qq1h}cpzh}sRO?K=T*0iF*a$(G1 z=UKxX(*hKvhi!?Z$Nhjp7JeUQA#6Z`$4ij}1od;_SNoh%vr|l{TfhC)zM6yiLgry6B} zrmRH0TH;h3Wzi&3U;X48_+)-fNUWEhr0Wz69uLd zv17e$IH*JNLPR{FUs@caRHsmvM1PpeEsD(d9K%8yyU02kQ)nhK_2`Uv0+(-BY zQ-G=oNU;{AbWvxKK6wK;0@}Ki_FG*NZJ=svD57PTphv3#E6~}D?kaG2uU5H7%kS{o z=~mHoAal*l1oAaIULNvMsJi$MAcL$_O%7@5!GHed^qD^Cd97fY3!{e>;(fFN9t3nw zQ@x_Hop*LOZCW@$+PLdRw<`*I6{pZzenV z?&Q_wWzavPhtx96?Tui9b=ix-Bs|_$y?otblkhuJON1vkrZ+GLGoS}uq6mMpBd+fv z{7#2aQ$M^t`e+bVMwdQF&$GwQ^{g!U+AnALtiWj|Wc2s9@I_K8*9-Exp3&|B2 zcv{Ld)SNfMR0_`xvp~{l+~s48c!nCX!<7*`jZ<#;fmmiWSv5d9;(vEvpPY()aa8?) zRs1(Yp?%|#V^s4BvF5W|On#{9 z)V)J8!Ji3g`n?gqpg*&C4VP9%FS%RR?xkkfK<%#e{!8ww4}GVq$o&xQ z>Iv7qnw53$C+B^!`M=cOqaOsO9wj#we%*{CR+bl(sUg)jPE#ZLhz5Rv8EFm^B9U{p z)sWOdoKOu}T)Sp|++5>4{0aKb)(0#YmMM0FhbpW|#X%7IAnP@@C!88=? z;HlOhoa*w8ACi@+2W!vA7ZP-6x6S3)-H`@u#?^Zq0ZjI%R=f3lTuJmWaByxM3J|Kn zA#-oIj}i}VR(DyZzrXyjn~s?bqs24l8h>BAP8gQ+3l4_iehd0|Sk$7-PL#Wlx%hDm2m2xb7}~d!PTy@`Y77;j&}Y1VEKqMLzVS0#L6Hx^QyrcG{_v9Yz3=pz#5w;O*#08Le;N_B7o9 zYS3v2{K*5GZXzmsf9_`#)L%oBC8>w`lRDg|sty`ZvjE}!T7B)sK&%}uTkT8qpV}r| z>k1ip0?9c4nbEF+dteAA7T?Bm0pw=M^jDBXKY34HZVWE$JPxovyybc`gp7V`|h->k&?2N33i0Av6A8MJ3mnAJyzy zBY?aaTQO@KUCmCbN7Scah#n$O8a#9-o%hWeN<9dqS3e2+CF3tWYSDfuNx-*p+Fp0wa}@g+2oXqygl^2i&nLSvt!ITn3nnVQ zx+JnnS4YIXyZYv*<2R7|j4bgcJd^xxZ6EPlA!r^SHyY@gBo1%N97}5dvS!y>>#}eC z)lAgc=kk4r7NSEQ>s}>}wEjA$!lLyU5*dcx7^BI>uTyvgb7d&Rv zyQ$7yd-W`r884CJ10S>Jn^rI$b-T%qR4>EZN6_sJJWu3YXn6VVfr)y_d8)B`#Befz zv^VbZ_tVBAlY#5Vkw?_}Eq}fY?~gI58475gN*v=YHR-aSL$DVgceN&7Mbf#>#e z$QGzW{Pxy^wP(_rRD2QkVpi74ooI^_~?3xFi7ApON>0``a#&3%~Wh zMl=V7H_*~8eSb|RSj>QcNEan;kMBUi@UonggbaXP99VMH&2*X1AQ5>x;C4JOp$~76 zXMO*x@@?XP3ILvKHAT@9kBHCDOyhIku(X%-w-hO!^I+YAJrN75q21-i`QfN=){G*j zeo$Wh-gU@W#(P772`x(%KYe~ko-})6;J-2DX8X4=cJ|}0(d;1)5s-^=ql{vFXYFL< zCS1VJ#1FXXV|1-y=TGHitT1K+pTWusCvgoziiLK=xrFXgAeq#OpKY{Y$*)<)Z&_SfDS-TQG7X2tiAjBUJ=e9BKrb?HRs2rWWxB5U5^qn@oXG`!fkX9w z1jaNeM@(O!B~H^4gFHC(&HG9tL-XYscRN%OTmZYk%w_zjOd!4|;VK@IX?z?&G2zMZ zy9|hbuA-SZYp3_QDH|;UJf7xxr{Yt8qRJ{2 zcNHWpIjGk4IPU5aKTWTY00m<~r6ss>)q;=SHpelc_Jlb8zk+Ad@7E@Ol?+@4OW^SfJ{AOyg0Z znkrVZ8)hl^_+gn5=K|yNn1WG1h}Y57wHNRUN0?c!N@~5Wi(}=AdRNGH-gCj8NFx$D zMgZKK$I|;3^BM>x3u51`!a}>K=Z1g+= z#CgoO|I;p%K7gNH#lA7sC(EzTRrbkPGbjFDW~Qav7ucD}6i_z10KQD3qCi!7E7}<|BWN>$nXcms|S9DN2Omw(c6v49&vG1$FN`q zPNI>EL#zv)7;Bj4&=uP;?lrrDa7_=DODlbzxlLHjtwvq~XmIM4!1lwuE&!r`^}as| zro!FH7XY!z?5}L#nI;V2k$P1H$0yT?%L@vQ14bVSm0j45XXnrtE}ca85n(uXQ1Wk_ zxVRE@?rGQRRsW@5x4{@QKvkPhpEb+>{1v%?yoB%MEfe7N0qaw88E~(oXq&YN*W+ql zJ_~}8);_%>ka~$^09>I|Yt*~DOFExW8+5R2;OK|gkoGLe=Ed%Qrb7Kn1rK6pYf9C* zpv#Loa-rG>=z*m_Tj>knw=OU=#O}rm)`x4|Kev(B<7X!KQxyic<5HPq!@s37v;~N_ z0lcztczyoiPS7kkRPCPa3R##-Kn!>ZVgNw5{?8zAAH1O#kkG&U@z9p{KHHAJxy+ZB zErGhtnQZUSPnil*6c9|z*Ysh~Er0l20F@eWc@!)wp*M70tUl#O402PFGk&2oZuy9n zI7_JMhREqEePSO>w^Ccj*)Ws)DWe9Uo5iW)UZQJ4faT4(o-0E!PGz} z9}5!hH`ocF#YrOD$_jrmhfFqrObmXmHL9KeLwent85Panf(JKk$}cwC``ecPlp8iO zudAKD>Vpgw((Hf2e96v*PWg1J9k{4gq4Qk7W@7Tj?IJ%b}1zv7v4oTZ_Q6 z&Hz**Vu|jur1$>OhqCi;Jpuxn+zNwb#pz2b8Teg4tI8>uML<33Vp@?LcFsgx`EPFc zzbqY>ONnkVexD3{i1{Bsyf#fWK2*7iZmkEZ*R?l~d|1f!GOZ9ajodqs0TpCyByU-u zj8D9-s>9C-9DZSQXc@0%>V8DI^Hb1BdDDG|WI<-drcsj1;tm@}k>%xX`s!I7?1fIL z>_@Q{QAwT)KE4A}qN&@x$kn*Dl=g|xNnYZ8zn<{OV@+`4cxp4S|)3eeyCIvQ>|9^Dd2i%G0h zN??vdYo3nrCTytnS&iMCCP#qD+&W=fFlc*l5986L9!7je*Bg*CXT57keAiHXGWM@M z*6xcm)zSB}`>pSF^mDqM%_-P7Bvd`scgXgXT_0K7zfU>L)5zhOI-!*}C)ES)p0@`2_0R-G8J~bvKY4Mxw>|v z#rmd#Ni)CKa;JRm(WdeSVVrz(n1=&4>3MD~38`HC0(*I3q@;m%yS;t=P5$F=o@8tV zGi0Sq{ANz)h&Su^(T0>1QfuYxZsuj{nkUVZkCU3WouoB6r8Xxp1R>!|*w5e0cw02! zk2tShR{A9COVf!imr<{b(p!-`kD(k$%RICb_L>KR2D|Os-*gx23=R1vHYz2MAgl7`Sf;|H zmayrjuIF9@uy|j1Xm@1PUR3wFMRSaF7b7_q@2_OS@C#@rF0w*ZkFp}Z2Yt3yJMDbc z1kuT9}PPewW~`yJ?i`?VNEhoH5C&B2961bbwBPuJ>FAnwR|C-He2!}!u1yK_|s z;EsPs@0d+}Xb|DIXHJlIcj1pHpb@!gZxZ{|G;GhZhlARxrkh>vsj0@+u(iZ7Yf)lw zVp}UNYvJ-|Q@wCy((PycSo+65&K$Fq^~qKIF8BQytu09|_=*^ly2f7?$l)gXVteFj zheuVN;xG^gtUXMH%ymvj=UDLGha?B&yun8_&@ua!;yDqmC4U?d#-{P~Wu>-Pb!-d% zuHo4$UfXit0FK*aONyLVdT7lIUjJtgkUr|a{@qQ-s!?%9xsPHo-(ID_$n{;pHIVeq zJKO4jmy?9!KQbN`zCIZZ950D{xwqTFlGxlh;QPAq*k;g|)>m6MVN&}|)RKD;6#R4x zSM?)ro9JDUJJR5rmckf$?=#O=(5-P;gIzhr&t$kP%$FGE7rNoA;Q^1yhFd-tMi`rt zK-1}$H_IRJhi^Y}3-yeTxIjD{o^?5fpmRC4e4*SZW1;9JrMWeqj<#*Fu;oe(*!wPq z-I$BrnovtX=;zH&4f}fa&sm#Qb$z5Ty@1#4^tBNK?xw=5WL1ZoHweqSUrat3>@nKC z`i#-Rn(metnWQ^>0LM;rYnnoK$E8x1DUd}`EceRAwTO)Mfw?y?iZ4gheII4%n}F`N zGf`8$;0%b!v;s$I0-BtUA1j4Co62y#W3Fem>tpR#l)6>6VH$0vxpG zQySavIj#%<$d?&AK=0H1V@7GCViBfjT`G?W*uU^G~+$0XP9<3&`RgY*tiq z`#nSrj_!E9)`y_51NBQ<%!?2;XP7k;)A23ZDVfIhLzDK%<9C}ki(8%pxPZ;@w_bM)5h#w>!4rIFYL@01w~ug|gnG&LU~4xcDnQa|MA zT6Y0vIUrTULC>91O0EO?=q`OhSxAjq$ag6V@m33aVevqGp|PW;gCCvfO$Y=k#wltH zfj&Z4gEzAR7FfcR*vRo<1a?u)=XEjqM zozqJNh@W(hg9)_Y0pFi?LQ3kTN}0_X%5c3#pQ_}`cs&pJ3q+^mgu6krj$W-@C5D>pW4hL0Y+%D@P{;R4Qd#u0Q62CCcwZeLHpHF+WETfQhBv|!l0Xh5b^-$ zypI((d4VT)`YcZ%uLBVTEIV`1XM&2hP}O+@eJe6@%ZmE}U0T9*pUl+Q(`|}=2Ho`V z>HbK+*Zein2a-u-K@+-V7;iAgZHup_3xsAphXNPW{2rgAS-NyrsT6ldC#bIwki#c) zdEcM;x5ulhT^QN5>M5`!EAKFyS#O!mf4f@rmT%knCi-ui|9L6sf?rkq8nl{J0-e%; zdwNg&n5Ng{90V!RKVNuj0}Ec~Yts&{$f3B+y%adS|A1AX=6 z2rVW_(*k8XmS^5XH^27jslOfH$unX^x@Z6SE<1i+FnwVcOM4Jic|&`R&U)XmB^E2* zBvx!8AgpWv7v-J-2yj3Vp9|%BhI3p_AnpI;=t% zHbz3Jh!j%fG{jvLaz5mIc7I>%{(L^a-ydCD9xmIiz2C3vb$A}KmI~HJWO(a?FY_Z# zG*Bjq4^bMIeKxV&3Mwgna>$TUF1CES4|rNgXr;2>D=TfEO27pbgwO0CpZzN1#IS8F z2b$4E%)?0QO0&$U;|OBN3PfaAZwA7$zjm$JFe&v1T2?BFJN^;Li8*F)oR#h=9Q@jU zJ+lqK?@sV}tutdC^t48lJeyOyWq|}2Wc)v;9qu#i18SiMvTB}n_Pe`KEO`l9n@@Zb z=h6h0;CqQvv7Btx5j@q(KseCU-cuWfVM>ur5ArZO{{MJF_4{pxFF z==&3XCuAmf{)jbIt~>D9$(ve*X2CuD>~`#QSp-ckzYj^CNOta6Fx@7z=fsoP zDrU|>T`N-&D&nhGd2^orO?vifNf@BoO!{kV=5eh7_IvA{ZmH4KVwG>=poMCN`sFAR zc*1G~XCz<6NYb>Xd1MQoMB$n8`bNS|W!f1_9i;t<9{scV7JDCAQHm9E*TrY1DqJV3 z<|HIzRr=eWiEmS(wyBL^r)SYsSEGK*F7R)Vsr`;x*Bag=3Rt5@3GY`pDWG@}oZ(W} zo2bn}9)1YxBzJpFf#@L>OFSXYq{92-X5Ed7eUi*7IpAhme#(%h%6~QUE>TDQ01$A}+!rSsa9`;K*_qiKj+r*+vktBHqx zUAsu^)b5)((+9o78JYDp9^)em!)3u%$|*_6kdxf87`$SNGEF^ggJ_e>W}<80){`&! z9WJNmn6DR>rtxvl*0wL3wYol+!$sp2ZJXJOp(nvh=3M+L`7ZflY!+|=v#+AJ zu(s{Q(TcjCy#^Bf2jG3dHd@x~SYJEB3ze7$k6mYHrQ4@mN9!GaIwrZo%hdw~TW67? zH05RxG)ePGT0YfUj<7nS9X6Hml`={99Pt!F=+yfi_ak>nCs`9j2)9kjG2clDCqM9hLdY8mo(fv!Y>uKb`)lg9H`fM)gZXWQbnD9?z_4azvx`e@vBEB=a) z&5Lx#e3VwsiW#&d9DsPAqklGrk68}f;Z3?|VbUuPrCUj7v(&SU#y3?p@uz5c$OO0P z{ADdAc2Re7V>_8csaRQK0=^SU>q+abZLSS=(;^ZCZ_K^dc-@_Vxz3osoiV<{il4a{ zRmxKTDgMYU$bR2eA@ljG{o-6Qmw;o5*I1olCt$TJeY+oENX+>t0S3kcUO_S8ecr-| zzUlk}b%Aozeg)^0S)?Vjfw7aFeG3_5Q!DFN5JNg`1(MAEQgV;GA^g}bK-R{9!Xf4+)Cg{EjAV!vdx<@*wy79oz-B3N-QL8n?#`KiJa`Ub$V#Cs;Iht!J_TM|iqeNs5~WHzSvdabTLQ)- z#P=H%3vSVHIqSytUMH^65w1@@f1k^8iFV7E!G6jMrUOUyc$(lI=c~EE%p9rA7#mK( z$|k2@N(aUV`Tr=5h*wUdsO7D58-#tZA6>@94tHg)Xb3wdAuR=-qqPclD9@n}cQ!50 z=JaFS-&?E~Bi&`&BU9lbGqw0J+aH&vZL0BwyDsx?lycXD@g1;!ikec%U(bEj8o~PvB9=J=e44r_XKtz}g4 zxcmnvmmNl1Piyx~Qn1sW7lLHm0}hV)8BZFGneH%llieR@?OC!QDzQZ=zBq8Car5>& z$({~(^-;D#Ulmcr@dI@QhmsLXh6h7g`=QaW$-eE+%g3){zJHmH-6pj@#~GA8@MyoyGAWwGju!`XVd&JM9klQ6vJe4PH`2=-k%MW+9P~}}B{B6?)(C|fFL2ou`KCTZDZ}6Al zFT#4OY+Bm)x>z7(7OgZ^<+hc!xtljGFoEsJ)rfAaa8CYWR8pU0XNgfgDx?-&-@yJ}w_f0SKIe<(i_~tDhVia9`)(xmoJz4PWhZ~V zH$VN|b3^6`#uj_g}TBYQbH1fd$Z~o~qifCC}&LuEb4j(jLXO`xzSLNl6eccu-WORv>2#cXH zC(T`pHQ1Q}o3$B$-4A)g+hapVo|VS?R{!1LhqgDhZ3;?e*8KjohfB!St!1VOW@vLH z`bz9qG{a^?Q-EQ1_Oc5PgPhZ%YC*V?x$AHT3VCsMn0P4e>+#HEhb%T`*W6XwpTo0d zrA1`YX8nughAs`ng`6)U<2H745^X^gpGT)~cONvW4^R&YKW8@NzS+Q5R~p9dh* zG>|+&y0vDsMYiOUt^~iZL?NECPE^cYJ#Z~&0&tT}{CiD}#$1#rZ?_0dprqpvQ4)sYJ2 zo@?OW$6};SGz_N9LEePe${{K^&86q3q(f0_2uga)BMn?|V%nBvIR=pA(9c%)4WH?Y zw6BO;b*7tLI+v|}eOjqrqBTNrDZA2k*?bPG6G4`I=~;MIHQXE$yi47~nsF!Ihvlj8jOp<4+J zD4avK(!=zpGol9KOzC|_0NB7>U3(XKIFB;>!CXDb>BlR}j}=k_=@*IX6V ziuEeUx6~8kWGss~oEvs@R9qPh*!62Eno`p|pJk{< zE$kWwnjF7%>Y6{XnL7cu=ODTaG;JK_UwK~+s9hNp;Nzw0h<|w7cIfg8v6qHx)juh7 zqpbT|85Z{q)$EBHUWNfzV^}1T!Rffy6>rMg^#||#4q?C8*wOY{t?`h#refeJev92B zUpMt(O+S?=ayo<_NtA4+-@L|5Y9Eom=-U6gbgIFqM4Xdrg~QG=4M|bKpvZAykE&`; z**}?DiS({F>Nom^3B~GD$oM%I+S{w^j0%kzFOwN9GtD4pnrd+*1D#O12qP>*sM*gH zLe3O7N;*K@V!gfkkSCPwTb+W-vMia*+VyQyB9AXM-$I@=n-{yUQ;BA{)i!D+7;HEiN& zjd)qkWefp$6oikE0Lx;LpM4`PQQFk3-dCpa0dp}8Gy>5p8N;SV*}pL+_DzWLQ4Pzv zm^n6D3%U&Ooim+D)~KSV3qp z6wW(S?lc3Y zb2atI;19S-f7U6t)?a60NeK2lE}>yxUH?%;!x>hf)97(kM1`04(x742KkGqlA;NeA zfpRX_CoXQ@*J5YSc(Z%+NF{*p$128KF0Lr|EPx6kAmC4p>TX|qo)Tqr`TH!+%F2Vs zSN`EeO}D4>PV=nYl(}3Iv}h`TYDA7Jy129u+Eqextr$ri22y8B&ks~6v*HaF;OQB> zO-+12d~m60wmABF@T!fZm(g~hP{GF>(XQ7iw^4>ZK|z;hMWV-&$I6-Yp`87gatiwh z_u^2-XaS8ngDWLgv~sU{0-~`wxJ(R#At(Y2JZy(_@q4Wm{%fw)~ep%~789L;p zu!*h$n}h2s(8LJ)q*JlXrEUQ|KFZD^Q8O0gT(=+cU*R*_+8k)h(zyh z*6fhwqzmBj;)>!XF`xfC=85-*`1gLB-Mo|5bCI5Occrv(ui?VuuHp=Xt69(Z13o?J zv(Rk$mfKgcqdNW6yqDn)vc+qF)}^?t)Gph`eNkvs_;O@dLYpHFQ-!T14gE=*KM{K8|*0wZ2mGrXeUV! z3!AbtuWG>j0L|KQ-xIcT$RB~lj^o#J?7NR%-hW=}1aZ4%fyMouPbKeXKf;Sx=Psn& zNKcW!Yl&v^wr8TMvuU?(MWF~0^oSG%m0Gq*xxPUIGfcrz~G$$_o@zs%)AblJd2uGFPxLcx%fKWh#X- zx8z-_3i;tfTvDcl*2?sCUN%1;6!^#Z0R z0|MxrE8Pq@yNh%GBD}6*$p0eF-vyR9=kGqpy&cCK{!yz&m}QPrIxq_!NT37@z@5%S z`!j4{VnPeFivHWhv?6_kTNn)aiK#_8tmb+5IT9j|yy^se3aijZYx>9C>?%*KLN`Al zRnJg60w_(v;@X^)Thu*p;}6ARpS!5s>rEVM_};`PyMaxL*hPyQt4S&7_wnGV5iaM$ z1V@g00$~u&LR^~sLqMPS7m>O4^=QlHAT1l}9yP^+<*vZa{y7rSa?fNKWbNKGbr!@o z%L%$=arS~aU?JA7&_Xxwu~s;1@vk%>&tKI?I+f2 z&qBqi2k9AC>yu@>Pyu95gVX`1`_w&MT0>o0Bek8G>(fn_xCb6C%JB@Er1=j-zuxTK z$zyUnTtViXP>&U>*({?TkKOQzRy{ZNvF&!|33Q`EpWX)5tFw?+keXO2PD!We*WTb) z4^U+@wS@HNZ_WZ;(-MMyb^+dak^XpW;yU;0gBle^y}vykBgd-xQ7aQ^F;n#i26`?) zp@i!~hU>!L;ywYQDKMxTELU}D&r^GQ*G_gq!@Z_O;7d)HduQs(ERz4si$og7&iWB#r$B@9l?6C-ZRHEnfTIn@b2?kGYD(pvle z;FzD7d;IT*|04E|TeQt7JS8nndP@|yNEtexkef%7-t{Sd$+&T|`^EK?4YE5%O+VpG zg!VAd!xHH~z#oA>;vahms};t)miE(G8EXYG>l zyxaPp)tu?ojItSf+Y=LK2BPUy2UtvUubn37&LEPKwQLi5_WDilc=;t}L}$4N4@7et z?fUNYxFV$qIi4l~o@nucd4|L#)lep^B%b%fXQn0I62txSd{DG=uiW^P3!y)^)D%n% zzqkn6T(D28p&qN3W(=ZL!bHMN@qSvQx&F+tL;{f1k<=L zMcvWCtacbDqC;ZMPmG_W9#pREO)mwOV1tr)vZ2*gj-8&g)HOv)QhDAUp{P0)}z@I<>r>81Y<&#KNf*x^oP z$JwZr2v+E$nR|xlJ&$y|3{4QJe^3=v*q|P;!L*xFf2blI`_H=%Jc{*C#6P4vG$b^d z@V{NAA@Y?h@wS)rI%`d$yI#QtP0@YN*TkP%mG~!z8&%^+Xc?2f^?D8FHMJwzQH|+2u~1?z9b79%)Vdquz5@<%e-+DY;AlRpJQwP$~c*VgpxI z954q6>Q;H(fq3q8Q|rNMaqd;?FY6@Y8-!lVGpKs-i<}=^{SzQR5i_~?J(=5UWMX%l z31+U&_PQ0)DA*eGPLL_V&3dEyh3?U#g0Fs95y^$u7dnQCL&z!y%--c%J<4aNk!m3QXgBrkIi&UC1s4_^^Max6@?)fA63C^o zbB+t?p&mRXN1vKEAV7rXWr29M*ZPacx#XQ)#ORJ!FE;+Xmh`tgXESOsTZr|&v1DTg)>wRRm zeMs4MeI;e?j>7o#lfrZ7#<7P(*9C6g{rKj^i*MJm1<%iC)8@Xa=$Xi;Kf#I$%IiZ^ zJjl-uS@$bWUy`KF6SaHy!hE&HR#m=UMx~@ogD{(Fu`D?OKDk zmP?YpL6TxQ#a1eQ{^x;a`@8GgNsKf428T}xZ4Hu@ZO=LtZmL+^-tgkuBjJiui!E{m zwokQUcdNZt0>^|l(P3-BTactW7FRQOzEG8s#Ok$VNMdmi-BAA4a#;nW1h1oHEq)Vz zWh}1ch0AFgRv*>I{|LLUo55XQJtlV+po^4i!>xi@ zu_fd$YiFQ5qH_gr1v`4K5b+#h?&KS6;fNe!mI+d79PMPaSIb?9*RXGR4*+QjGPpEQ zHv;7VfR#5{A?e5g8kD-tj)28zvZf;hAoY0%uhcB5a6IlSJo!IyqYvv0ieNmqSC->c zm8pvO^>@d|G3-CF0^JutP-$DZfgEUAxYdXLi>S)$qZhcZQf}HwdLU|f(%{X5#HY?V zD+dY-LqcTni>6sO@tmhYYCG7qZ!Tke`*NGvir^8*h?-M0uc~c6`PG!6`#;w~u)_U8 zlhvi)v*;_lu}Pqrs<-=2M%Ul~W2Pk+wrxK70^;mUOL|gJw!D>iiHA<$ViTWqx1}ER zwo>5O2EX$JmpTLJd*O*d?>;r@uMc}41yh!w^ zc4YM-51vHbCBFGwX;%cVbqayj5HAUx=CyPkyz%^|juj~36DA{G$wc5vwC7ImeXx=acVNG^Wdx(& zIC>oObo1sYG%Z^2ZLLYH#Xd3!!t7gL0F?&rkqAZ>dxD$zn>D2PZMGfn59XLYiJT;P zMI*;`Q6W*G{22Hah+{M)3hCfx_b1Lmqv}?;bFeUM5>B zrTF+`SJdV=TG~6;>J6hoZ=m~M#OM4~gQ-NX4#f-StFL@}90_A^yTIjOTa?1six39R zr2N99)`JV{?ssEqj#sz~Rj62bLWr&+pd^Eu%c(Xh$J*8LtK<1KZOmLFQZxwX;oox! ztRA=5io9^^xK-vINlO>IjxN#T*A2QTrNK5m!PkZ@(EEk((pJ{rd-um0No2B!<-);! zw&(mWq3f?7(EU(2p`F~=GyH<|yF*2PL?ezJ(E70^SW7IL={G_pW@c^%$-FSUl7pDEK-rkp5d;d@$)K1 zER945!uC5EM8~%3DCPy%tLKnr^uu^U-C$aVb`|wdRP*i6<~B+vPM~Xql$3?K3Tie~ z0)QvwG=!NBaT_a(SQ?_2w2I>@kr)YGv|5-8YxXjEi_51!8M$jNd#$ zjW^_x7Qd8PN<{wPx8_tA;N(YX$>qMDSGC&U7MjyoqVvUjLQOWQ=Q|nU&HnE`q?@tj z_r~1jH+MP?9szASnmawU2Ne{x23kLXpfyZ)UgjibJhHp_DQn4(5s=R z;Zq+K!mYOk({An3kMiS84^e1TOmy4s`~$h?ny#pkeoOvd0)Xo&`VkADi{DE8LYWXp z6kUiuimf5DAM^Ln3kN{;L7UZ00m~waGUZ)~$Mf?3Qw9p0GVjXBMZzI8cE)Cs+?4D(s;~TO$NM@8bWT^ zwsw}aS9Gft?QOZ<@5(fRCixUHCDda__5y&b0W3LOyp#}~cXEgPyR#IZCf=~*u#N-U z>5RhKKfUa;8h4&IL_!)*GsA~`Twi3&h^+Kd_q<&hrm+*_YQTJ8OmItY+Qvb#Q@TQR zwS6|1v{wdllg>TaZ};kR;df=g%{1B4LU2QGx z`JyEG%&BU*OjOApb$pw#x+GcotcJr&M8fUHf4W}eY7ErDj zCczh%#-!#wGV;61w0T$}-R^fmn3TMMtd@JUaOoaD`bn3`@9Lvt7d-;j+`ZEwMszjk zA!6mw=p=qoCU3z)ds);Mx~*2QPk+oUhqd=}EH}1;%8M-$GsOAhL$C9`?5<@SU#0|? zwT8S}AJ^FLGhzZ8>t94NCfUH@$imIttt(aGQAahi8yQx|Yq^`{)`8vO>a52S;nO?h zkm*TRvx9(TrP&p}+Z~qN2I@Xux{8)(cq`@_4tAOx@}p}saZ^x2p{?mNoomEnwr&?e zj~uyED~9j7I3-#3Sz7f04ROS4vi~lV2Wb96V%&YmyhMQ9@LD?8;i!y60CV-DmXPh6 zE%k3fm1;|~DfCQAJz%fJfO6{E{4((k(<3iG(6`#c6~}4ftv(gF&Ps7LyPA_Pxy8W) z`YeXOEw;aMRSoi9`;4%IdxX;*3zxA8l~kE~fg^IChl&bQm3lh-?mQ*L%GE3sTn07{ zMB`~k!+vE#Jpl2_E4V$HD_=qgINF*doo_I*UC$XT-s1cX2hHJ)R>dj9$K;8rT^`6H zyxLe}vsXnv1)6GpRIyfdwSd-#uUQOC>I3xo&SdEiFXeb@`1kTk4yPM68lv9D|BDa> zRs{HJ5JTGT7CgS(NPqVClL`D1zcg$c#7e-S^WE}sw^>nO^aTfGq^ zZg_CaSXL&nPW3lhT=0?Cc;5XxQ@4@U5S;|?^F^d!l`YX*pp>~}EGQ-+H$vY1eCO;p z%}y1=&kEPJTOWGT7v5q{r*z9$$@FtrwJ2$ycXsqESNEINUkNII76 z;g0019?HW{u4U7r@zqAc4AdiV(KulpE2MgbfkK*E748_YyCpNBZ9`U`1;<_$+OnE-d7Xk=H>RAa7W>!!o4Hh!gttOF7I1y8aoF2&v~)4xoce+=PqA9 zv_nZd1@k7%a5NMgF3*dEL6S16%6-E1fdL7*vZHgf|PODR9%DTfp$ zH-jHed!P$RnZgC5|FxX~5urS9hT$lfFtWWF`e4+eDa& z+NJ}Jw$N4E4G0Z3FJOQKxf%G=ETsB|m8f&cHU;fetTQg$0{m~n^%YUbX9?@(VwnE+ z0LvxS9TeczNgGPg0IB1$J*Bu>Mx+iTk5_0uAfa|DmGU`$uxFZWq4Node6)@=lYz_w z%uz71XLPr_d}}f+%w1g;bPVN3 zaEY<;!cryIB<7^SCi0~^<|a<&iHkH<^@WzcTAV<_nbl=Fu7e2PX*K`mw@z!85L6HrM*S0 zeQx~&u|S=7$lU&=Xq@D6rjb+x^M`P^OF}imzOf~GP2rdTxDt^NML~6Dz>_=Q$fZgG z{d9IiHsl~XERNW`5x>>{JQ}Tv)ajL5%{`JDyft))XYKu3ColT6d#UP*|4QXn@&a!f z@N1XW2}56HPBlukQKna!FC+|VV}S&)Bv6_WhdWc2;^#5QY1$CAhxgO)nU4}rBHO7( z`x4uWbwx$FOv%1)&2cUymm#@YAg>yJB&(=rySnGFRDAbsNs+(tG3BIezyE4D9@eJO-QEQ>8e*J{aaXU8 zi*i`C->KdU4*vMpZQlKF%90sRzn+PkmZI8(9`J9FD|mZ@up^q7@at{&q~B?#{^^oE zEN4x8wSjzZ<|bq|FOKHJbk9TD$1-RqCCBLIh#ccA@Z%X7|MVBA{aVEa)_EswN?06nVK51J! z`n^#}{pVHYlh?#fpSy`Kj;VT`7j_wO5u_ys@zxE!43op_(Yr7Q>@jI+rszAT6L~_~h!E=X~dkPpwW2 z;@F7rZ{zUEC7b&KC@tRG;W^NCp(gD@3?=~}O zas+s1+v|R|%mYu#@Uyz4yl|5>ua20ckkg&GgIi`y46X|@L3t*d*a0hwF4V~r0B;Kc&6sZIcKSdry_>1jHVAR3A5mXFv!LX zvM=Ds<0mJd-MTN z)ZYJhr=5^psfh-jIHKavmP@T_x{S(q{HXvg|D%&EH`=sLe6}bkY!N|4 z80aj|x~~ot)g9OAGftfWj`1gf4HY0_c6mvam_D3(=54Uw+hp@aXrwhh=oA@NCdO?avXhVF$&5&F+7j4wEbj z+^3!HyTfyjqirT*N-J5f0rSU5u=?S@qpux+;B9wLJ=(1eDgbiT9@YTJdhWp%%7Ec@ zLwa;G9U)9%INb;v)=oavF-+P9CI8>s4Dhm-J@<`*>WLbZ`y$G75Gbc8;zvv0ff(hQ1LvX7uPrnp{1_}?sK7Je-82byYny> zrcRkA8iQh_0GBBCfq|p0FNR|O4C@L|s<&R$0A$e$mEfC^FsF$Ur zcJf1bGz(a^;z62dH1R7U+)dYCnp^;byceOKNr;M%+R1+3!uqCIK})w=^8cYQS3Qu6 zd5zmfZRlGc?H`}HssGDX^KAfb5{4(*J;_tEWz0t1Aspm$#277j0yEpogo{-{a z{X=E%4XJGu>+AH{>H0TzUG!o8w`;rRp4Hy1uDNmXowP9OTEyr_@w*P)5-z`EmBQ>g znkX@T9U_+#(U(We6ACArY@GW$hEzRICZYD^CCsc&GY6*?WJ@vkkC>44)e^<9t?e0jOZ=H)zFbREjJYO3B?0@llP7+Qx7w&g6p}VEX*i9o zMHa2&*CaT%zUM}GrNy^u3uI1z)eLW#>Apvpvo3ddA;(NCQ+Qj_Em zqB3(2$RdFI0PF(Aav*07Yh2NxW;m=X zc%tpC6HFQj0tK*(Q-1)q*b=y#;93Th753S(b#W#gA!Nr*R$)4lPp)RJ^@aX_o(NmG zwx3)-VhYOHiudTAnqB3@gzqO1-^6lnziQ9W28N)*s%>IfP%0{Vt$JoTODv5JMR__?$ugsb0&L*|YI>kO{DBP^)fMJ6LR85ZGIqt7i5xtXZ1G!3a7rsoB zpJ@_D9nYr;Tm&jtOahmbxR?o&VxYbeQgJ_hhcU1{7Z%CSM|F2R+AT1U{g1NeMp?BD z*VN9}um6F0wW8&@C2LWuIend*#)hg$z9H6=Lqay)KYhOC24ehjQl82oyIoUfTJ9g0 zcoVTCmQ(xdKk7HnUu)bT&$aiKkih>=6WAP!O<558;6g78mbz{eG!j;fcn2poH4o^l z&Z>5M!1$%+j*GReOlw(j-I52_VRNO4_vnGJkv7Sb11C=3JL9_jl=y_3EJE^N~^(R>ANaWC%t0nXd11fAdnG;R@-O5+Nd97%IWN)`~nZ9QZFz5Zb>CnB-ao~rj-nTlNl>hRrcmj>QpBu8U@JG1W+H)BcBwABvp8v&rLow;Y+{HMJ)#rw)n@aeb zMzyrRb!cH61J!JTy~syx%#SVk4}%)laM?P*+QA+jW4^Kkx5uJz%Bnc2e3 zv`yO|etPzJpW4N0v9E-yEF3@)oY+o-0;9S@(=O{j(>_RP|IBs{ZqltKA<`VeR|XQV zQ$j&GSPoe~Vf)|!yM~7IOQQo$XECg|3HiS+y7-|?e)+qGM@4R`-m-1%RvoRVu(IMy zagmrNd%14eq}k?clea#cw#m$NNPdgL5EfY5h+H4dzlPDaD&U2k=eSnQdYXg$rw0?t zIve0}VDk>_HgzU+hQWs+sp1DFS;`vL7ZkTxo$b2YjcA31nRJu zX8-z9W`me2QY-uGZc4*ku9P*!hp_WM*~hr^@Z1*r47s^Go7>CdDkV78Q}BlH{<>8^ zbg;!1fZYo|=K4qIa=fPA<-0>i;<_;Ham6}pgd^8z!JB=!ODa#s=(EI}W)r{8Onc;d zQ0_Yu%&&|(mSx>(&tMaaPr5MSNzCJb; z999fWB5*rW0KF%3$9|5`w$m~JIr<;AoTnc8x!i(QA0=ux??3j{zndz3yzZy9|G4c( z<&>H@X`NHLs9P%h9tV7|M=nRo5VU>8z;VyMHx~OK1@wC-0igYzQb4}o5UZ9DSh^H* z;*+HbA{{%Iz07jje?l?|600pzn81mGCzMA?MqXs>@5J{8+nT@J)~&F|iX z;J8V=y7r)BnfH(zCigS>PwDynH%B=0eE;9^0qK^C9+wfg;n^MNwK-=)X=_wGH)@9Zo#Wv$ zT=ls7L5cY-ZXqqCDIO026~|T=Pq>G_c|MR@S`LR~5c$MZ7}!a+kag-Y@}^ zJ>nV8AJ;>EO?1`USRbNhIQV7l*AjkvFjpFvNGt=7O&#nNYcVI+)Bx%pu?+fuv^LGK zRFL$G18aIO-B~r>_u^Z^<7%FX)amZ>A2+g;#6IZSKbK}HKdHMt-tXF)=kZyvu#dle zPwwax6f=i5s-wdsmQ2m3w>b-jGhF?0ft1VD9W!lAY>(7TDMpMoR#>^QtHnP*P8}cz z#-}7vGqFFOEeW?j?U!Zf@11t)2jjrymL1C7%(Zk&@DljTY@7T}%E59jy8Rk)+6#Sp z^GiUairXuHFMP)Y;Nk$z>*Sf(1xtTicjs^WXDp%jHCVgfZXr^0?sXQKP)33(vE#JM zY$jN;TJfcA*(z5#%Q_H*toh$XU&{dR=o037$nDbH&4LMu;N5r-W7!_DReN&Ow(Q32 zn#YPJrjXQkRb*6>sKi{;>O`5laC z{Oec76Nn*kBoz*qF1J7APASlawj9OTx0?zse?N%JH=di9nr-n5I0?u&|98ixP}-Q* zZuh+tt|!jLX<04`{<;44QNd-#n>X=wn(WsP+vB3t?#2Y#(x?05ci(A!aX5Qv@{HQT zBMS$K{o0-(-jr!=|J_kn2QR$X4d&yxmU5S9c8=-qvv0vJtI90GpCtbAai-DRt9er- z{WIeFB8nzhG?O3an$93m3Awr=ikbv3%gh6sSF>Z2MYCg*q7;WmA1Dr!$mE!N<8j~H zs@3Xn-KS2caah;TJabIcj9ps^$!Y+}fPrQ_fJGFJLPXjC-G9a%AJw0p5l^so zB8N@`1bdo%@}CS43h45$=zcMFR_@C%=_SQCt~@Bw{)NZ9rzw3?B30QmTpxWPa~H)Y zl$`p9_B!zM7LWn>1>SxWtv`XpCtvZwB67_Kgg7%`QX>S0dhe2-Xb3jLygO7u{R*lQ zTI?o(?@%?=Gc^+Cd7w%a?#>&oXfOA`lyh~hf}K^bq|K&r|80Ir8DY6_2uD*#Sm%RK zR3SXfX6!0VN-as*LG^cMv-nWf&AK986tq1Qm@pj6+J{2GB!11@GQr9oNS$ed=V?GA zMK(bm2%x**_7EMgKO{*NQ}tRR)ESa6a^O{sH3v5Yys<8F$jex#jqR3lt2l9u)Wp*Z z`in@|Brov0%7H%HLU1WZaIMI3%WalZxdD_B*ue+65IviPysJ(vd5(T~v=eCR>Tx*p zsiUN4yoq=cHw)up?~q*FD&U}(UZeVAB%jR=G=$WrDL&G53N+~+LKu@G3)OP2}GPs5n zqzV&6&|9s0kLR<2vjyZrh)Hp=6^D9E0y}GSts#AoJPX9~oR>~E&syu2s!7~Q1XPc^ zxSYJh+=Gp1wEzm~Y&xI}5;ix%D%chny>U0#Sg@eGFh|z@n(?IM416nssUE&HTz^~r z98eZWy$K9G)08h3)Bbf}G3ZeM<3&W|*zj7PA-HPS+WcEUBbNm|B%q&_d4LtzR4xzrk)*Ckgp^HWg=Se!C~B<4B$KEI z!I55{(K8=j+~xvwvLz_~h>{I*FD-3yF)^4JgssPFLYYJ_{m@yRD}-0F3yKMZO1=ef zAJA^`U69}M`;)zknmtt*ZxJ1*yw!uJw)6c2IJ3RW$^%5qBG^&QU&Sb0>`ff!3wh>m zbM}O}yVBaPQC*SH?90ZL2-kbQ;f|71yQ|H6H_u$U{LyoM{03&GxuT4c4dwBh!%^?# zm2WqXVYc*4P4_)tAMEQcD?-mbI&k*$yCR}j>uTd}rRtsqt0hn=xb^PgG~YVbK)sH! zF2OnG{o=>tE~b#EtzS^l+rfAI%JMY-Mg08|gJBV9LG6J03_L#mwzBx{u{E#cbd+db z$*c^yGCPW6m~Z<+C=^%niO9widsou4@b=R@L`@DW2hnSQY-SFDL*=Nd5Y_gmZTP7XuU+EkV9_qEm*^|`fF5fzf9iE6o0eeipXhZv zQz`6V+nKq$H4-Y>E4en$6XPcWu!iNv1JbcZs}>3o_m@X3%T;f$^V9Ms#=_iWNBjxT zg>!c)4^@r)ux!vYo%7Y@9DF}80(0_9{6Vj@i*J?>!%gq-z*d)5o#H&o;DqqX81YVl zVt-9tuXhG*I*;bSNg<_ntqRl!FZSbeKiLF{Oqh&>#$x+PUo*C9zU?d>Ye-mbSNS|R z?|QpFjsOBYftPyMOf)xbIU+otC@_OAa_>74So!wly(2&3Z=oCTNJsIm^~eE3=W_WE zB^Sf-T-hVwN42!|=yr|yd*4Q3>|WNfW@m2dIq_CIPLqGA~S z&F?=?`R*Dw2SzKWJ0f>nD(R9`^4Ea-{)4O8zn5W?#r1lBedM|Rf^EfzakKc?B6>ro ztig_}YcGztJ#Gj-<4e+moA@y9PBs~z za6kSZPv;)b^#A_j<8jZgfon95YrXzZj3GEarPr)Y zw2ZV_Q^f)I=WH~&ksOns))L{l8#?A_A9t*I=R;0gd~JMf1nSK#sbn+L!$3DzgZz;A zAC3+JhbZ|s^9Y))xAHm+RYoY@v7agqOm0Gai3$U^G!L;15k>s0Tr$BQtZ7c^p@#V7 zHY*PoFy4-~3|{NgbW;2qe`d6K?defscw<#gE+`F7e#b4qwlFV{Lm(|JWyJQw(z?YmP$tE**2pNEuL`t!4KNfA-wpGG+iz2+Yc0T|N*!PWmF zBASJHd6f`#;%|(v4QOs@dP-fZ#=rk{pnJrQdjMJiFGBr+J_SxbZ)nXuGoAs3ZGR?Q zbufmR5lUQZ*mBROKuZF2b7wo6MT<(y7fhuIF^JZouDGy#R+sq#Aj}jX9nQn)GGz?R z#UTYeI{7ktt9}zDxCgMI@zanW0jds$yEnQIawc}cz7KFWAV;BFrQ|PFnSWZKgW(w| zUcbMYy}>||Ad^j&Q}EH{a)d-#E1|o62lKCq8ivNFJe)v}gMi$G z&ERE2N_weCZ5N`zTxEs0pC1SooVxUX5i|v$bMcde5VhZrFr|Npp~^dN4}}L<4~c#+ z3^0j7Ot__p?#b_vdFu`KJ|Hl@h%X_R8%f{RF`z70TZ6@Uc+I^3 z*;TKEjeY$zy>OS&u=}?;8Lf0z?#}#% zg)3*)coR>ic9iV@q>`syuUhoVs!PGJXUpcn*lk>e<(yV&JE3?uM1M=K# z-}-BYZCN2@nPEDzvQkF3iccydmai0W@14~@CLPu>_;UK&uP13<9_=mFyDMa|?%T-i zRwp=SKhST~^`cz!iRZ)~e9X;~2%ijXH~u(cl=^kC!4N0*l0*cnKGWq>@Xx}nxRFcI zMW3zSy6j0K9u8r%*2HMaXWQ4xM$~KZFc2{VxpSfMTf5B?5&gXyl zKQFQF7rZ4J)6;bu2v$F?%k`%I#Q)IQ9+t}wG2u(M5tnZkQg0RvSsIO%P8(*v38o;6 zHb>v-+;RDguFjxS{YVqxLen2L`McNAOi2$#2wp z$q%Q`Lz{~f!ZUbWHsO77jc{0g13Xn6i^OW*VJL?I-SSl7o>izFlkF$BzILG(h!5P8IC^NovYmq@Ix zh@WSz4BOE!jbs8{rUjZFru{tpHBpt$@+ZH6s9rqos}y&O8I}N%iq8Q%nbNmMI&WV9 z{!PnHJVQZ7OW*r!?g{=#P7>F=>9s!z!ECDB!EK8NqgSaKfRLN1!cB{y z&?`=hkSZaUy%Ll=hgCnBrdRmcVQ|Gmk7F(f3gvY{aU}yU*Sr(mO;$N$9d0m-sp z2V*eyH?yN)rngiP8U(P;2KzLNmtp&RN}v5Na2Ej3dp4zuaxfhdiv)RpuH<>^K{P*glD&E)Dxv z9t&Frke-?^NRgl|;6Scw3k}ktsPvH#Rwe>e9QdoV^7cE{K){3*;@IC$Vo}mf$L@dA zVMik|;CD0Z`}5w?H30Goz*|H#^Zy3KxGjQ-U8~5837WA`ykU-hpZ#^+8s!Jq^wTP~Y&xUQ`FJ&d^jaRS z{btGrQ2!!zP|d6ANqD*wY~wcWgki-o6n@0$&W0JMANhBjJay>_cl~NCm=5Zk+kP^4 z_K4B2kq#G-@q6@ey0PVz=DoVokFV!#e)Qve2B_2Nk|*LkdPomi zzZUum^GAgmobT~S9?-t&f1;L}rG3jnjTo8tmMzqugf-R0Y*=YYGq%R;B3YbyuubH{ zc$}WJu8y?koKf~xoAqq7GcH;$Ryu1- znlz_`l?)zS=tl>!q})TM_h0iqGwIj@Dw1sr25~9RwdF2-J#qWd9m}LMSr@WKM%7Qf z+4k<;^e30JIKl8n{m_2Ss4op-%|`6+#ps^q;7pkMxMzBgE&utW9#=U^s%G!ie;K@g ziBPWIz*uj2V%`@wpTNSD4K2UQF_#XRcLa@Y_^RFU5 z!ph|xbP<@iH{AEkL|}z---A(~4QfvfRvc4R+No)Aw5wSt(xC?|r}^6py|P7(O@(7T z^c29b$*9iD-}hHvdP>d~p(#!S@oPkbfh|%#=fNY1zDq^9-v~|quL)}wWFi!dG9xV{ zHiL6ayO9HB@hfyg5J&Og7cu9JGBQwb7H0uSWEEzE8VpVdGmEI#@@1ZIQ*wnWDkjM4 zJ_)*Cx~VNuz!!&xz8Q!K*9kfS*pDIl(TuALS+GFGek79;Y>MyQ>&lgWj9zXRG!yrR zW$GEW%3(icYXlf=EEB{pUSBFCVO1Ov-<1w#Hkeg04g=C~7~F7P z3oH5Cybh#KwS~O&9byu& zZEHT|rbv+$VsEjZpXP@~#yZl;M;Q_82CJ@5*_G|7EU>-0bfRipm3&j&YO?CUVr=dG zVJH6^Z!IsGpW#cdgnUgBaX($XD0NnNy*u4yC@`*KajDyHN=I)m?% zc3dvBv0I<9%Vn6GMDyygN0troH%)n&KL3mlt!5wT&gULp{2IRA%v`a`Yh!eVd}zrb zTZ8DBAZRxRBK4lA-T0evv)f{4e|f(5bRW%yGW!Erokt1d6`xFn<2hw4S(s z>AKyAKZbeb2044&4Clh*W0>snV&QtNvE8QBlWs9DOj6OPQU%GpD-#2NyM_WQ? zb4qXc-$&8F>kW-^abEiHp-HCz2wHk>HkCd^%`>fU1>8Fh7LQE?#OwKgvYJeAkVl4i z)cw&Ct*LMp0zqFKjDh@5ir{Xtj{~T^>|NLaO5h^@k!~;o@1{NP?6+onsC{-*rOZmh z31RWNKW%pdZi`yRAaZ}9xjM?{2PBJPC^3vc{_}`E+~eW16yNyXyq~?Xu)HeXyVr2& zPpjdVS+3U46;#hoRbJ}Il*pRTZLf+1sxn_805s;r8=|o|)@Jj5V&s*kcuorrcmz9Q zz)j&z24ffu{6aB8bB_-%e}!6Cv;@Cj#anJn{e&C!72d&9RT;r7!5q0|;E14TPO#b} zQWzhcbl)Ehg8nytA@TD;c3v{G#$orj_& zPVwygZvJq2YK`rXSzn#eCY-bTXkKVX-4qAIO4}E$EreCD+837Zt_Es> zgNNjtrGu0m?xu(UIR$dM0(&3B!)oM7;6@QWQao6$-Dp5^ar_c{>=#xa3_1uiv+xnd z3y-A}I&Nk^7XzF$JMpW%1a2&2`pQR;E2tiTs|?}>Hy;ntD~+ojhnBp`-1mu60VK3o zLE{+xU1!@Zo3Lt3cholC_wK)bl9qoF;qiLt$!3uYQorL+F~WLnpVD0}G7}xn`TvGc zahG1jw*KbXJClR&rlUXRE_Od-+V8dd=vQl~=Ew4HBbQLmb$4D2Si#8W{uw#h87$d9 zMmI<)Y96X_!V$|k{gW4RSZ~rsM?NNGaqW(tbN6{2B!c zX6hPI16wob(uvvRz%*C;4-x)DB3AY)Rh+2{lM~5ZQdBIFhycBDOpvA0OsUm8sBL&q&nxl0YHr{;4*_vY&Wf4Y!Y`+gyH4#$-1Io=4Hv)qB>U#vwyoa#hD}3Xs7Pw)Gd1Jrww5P1IjB;d_j}#k zOH|Dk{j8p~Ds?pXhI;rmCsR>7<6*xP#-WGyBC!e&D+)tOua%kO*I{#ifA*popx{_; zll6Aq|1njniKbjAtEhB@^n;k|6B6*?!7G$k2gWO-P37U$Ts> z- z?hi*l+~am0DRd#mAIQw~nJmyM2*XrxwqQW#T95o8hrUB7>Ychj5E&2wnU%J@`c<}+ zmquG|?Y9Q^Cm5}+ed7JF5PzVqGwpqDw!wX7L)46S+rGFD>8Z%KX9q0E5{dQxOM}F9 z_VIH*5qqoo**=u}4#&P96FP1G*BrUKKd(o+_ekE#$FGYe4t6=t+r_~B37l+*x!CuK z*JZC(hXXkbWuMUK;wR=!(iWwP{4yv3v`v%gDMN{j4!<&??X8NrvUHV z%1l;$BL7;m$U#uvz?~K&$(mIG){t%@gnnP@1)+>^?f601C=gm8VC_MbI7}XE`r#;l zg9f|HQ)G$T02`?s?V&7}%;krT4hU|kYi0z)B-cg;{?7qa4Uk89H3%i4w6rE(z%ovS zX@8bq!2|iC80@MJoKoCFm#qxoKLuR1{IkCw>2guaRz8Ck@u7w&-}Pc+gqB@%YI zE>bKfF5VR)&`#2YM4biQ1IAkXWOzT%S2#y>m4qsF&3VqX8->ew>(ktK&PQVqgDcmI zBt@!czL}BPGY3@jvXe$j(wegtNnhB1kTZ=sUWnxropu7`o93H(-M3zc;1Q2G1oGIOF zm+{7IO8$!pyI=nzz@?u{J9pPrW>XF!5_N_Ot_3E@-&%$Ls_2q^H=;OsT~Do&)tZej@XYFBgl6G+e_%d0!T8~ZO_$|?vlKciPqI7muKL}pgl-UL7%_4u=o zUgu)ko~3$|O-4@iKPhk+uoQciXKOKkKl1o z_=YG_7V(-0NIz-GqUHQY7(n^ZnwO@WkETIRF97Y_McA=YRv0teJuvSq=GHQY67d@0 zTs(__5H-0h{xIzkh`?AJu$&nqMQV_UMe|9SqGkMA@*EtaaN7LeK2a&r0zotI%@Jy) z2^1KABUsRLeh|R|^&0bER&iFI3N(C3dLC+w6bi(zQ4B1dy&5UOwW99+Ypp)cRZq#9H_(oSq#d)*qe^PTUNSw)>OB|La8P;24L{@k;0kLNmCR~pPly*YcZ8|du_LxA z05mT5Y>^_}@0@913)~P8YdbJJ!LA?$A+m2 zSLSY?!Fq#>VOzime%e>WL`QN+wPG$mJ1wOZgnzqea(;F(h%6yR#}j=U%6>e(8cfXc z*h}Malz3Tve|CH|fqKVyZ>Tb+&_!5!aj(SR=aAGwQ!kobsliw?S8(j^qEo>Dz&vn| zU7uY0^=sM|>F7?S=psv#N)Z?~HeX^hvJ}(0HR(|<#zFi^@1e0yd#+DfrRV{th7Zxa zEPJeLmT57Bwd-BZA7SU37~M&sVI7zjr625)Q5-c46ZB_uigWY_*>d#Fd!!YEq(RnU zbf;#Q0Z~+vogjS%ZE(DYIJuB_ubrN)bI+{yiLF^}!a6U9m$@(d^DVXXFJqFtA!xsp9QdNZ(~hvTPV=Qv#YLc&AmJjdma9guF$ifDcC?q? zkQ;+YlD$gjwASoYhC%~sG$HtC>kODXZOjhEh;BXPB$GyRhj&CyYd zknDHJ#QUzjAjPxcfiAWkR3+K#A0D65o`3B5=F6YU;)@Sg{ED7&&TG}4r>!Hux_Wxi zIi>aI{huB!#n@5FYje}fK?>TJ&mP~uDK2)dUi8{LrQeTxP9xiX>x7E?7iW_*{g2fi zX^icDIj3}qA2fh}htFm6qmTO?bA2thA(LTz-7kQj*{>!=y+CRR&~D`n!yXdP@at6E zx%nlDiF|@}I%9TW?@0QpD{A_-&sKVq#U#D54(xcklk3z=37o(E?C1K*u(1a;`IC;r z4j*Q?&%ee0p$3f*wh0zKVm>)V@M@rY!`1*659pZ2M97H(GFqey=hj^zjjRJ{K2kKd=--;w zF^Hx*IaEXW#spj2$PwJ{#RMoCTZDd=ZCiG_5Y9^9w#>gQ)VNqanHyPe9$WMG9p_nu#JYh_^Kk5G)^N z>%Pj9Zi5c#!y0XD4#9jEM7V{t@{VzGGRx2vgrsILdf3hIJQ&`wZPtx$o)J5>ld>@m zqL247onC{DX{}Fzw`YV571ln^SZ~~6w_K!<&1Gt{I9vumxg%359`Y1eER64QzW>=l zwBZS!gs=}=VcQd$g@c)^6i_@=gm^sD-J})RGvM(uLhgpDJ=^%6qRJa<-FHzHG2t|U zhJ1P4yBb1SzY_HMpn97EDf%@Oa8lb>jf(sZ03plI>A>J|mj3KoKdrRigkl9U9+9+T zz9MUDyDC~jk6gP_m&SOpYjlVf+CSbnknP!67r6P`*X&T=c75KlZmT<07UGY0D)bqM ze}^9G#PklW=ha-ZvWElBe*$H{%^i{XNKnnJtd4-epzl^}Ug>D$y<7;JPj!+Y=6c3D z9F_O5^A4QEFpZ)UXCbb9U{*uLDitL?f~hlB9s@By$}~su@(6}zl#aqvkB_ux%jIiB z@C9tPOkuNyT%CZi2nVFtx!hk=i8`h>n=R{g+}80(9-%}>y^{6PzOtv%AaDQE@+&9y z=)PLeaj`w7Hs+FK#k3d86wXG1GyNu14N#1wgZc8gd5oj2oSIXFGP1ObfN&H9c_1M_ zqaHk+$hS~wbhhTIQPG2(d$WdOMBK@PzGEW1$9l9*>pp!*=l@BnPYSvHqVGA>zm1s5uRB ziK|er=t7*A*_qk*M?c6*5^l{8D(b)d<(?%=2MOGJt;>c!uhNJGqiflt_N^-@`-ui? zet0$y{r>qSQ{wO|)83+e*0>K|lUCnQ@yx)sTr>Kx?3bQq(|lDThS%w-XBd>A*Y$E96|rUU6zySws;$@b@bwNId9>IfwDRf&bt5rwqb-TC%?HfaTn$;%p|)r z@gCQ5MC^?*-BNuLv2mi-{vR?Uv>eTRvqfNu;w0!IG1tJ3gvF+NvQudPR!*9V`SYhvWQB5ejdpx?i+uEWMw1=Q?uMF{L(Pg zpcz>rH|R^2eA3$+F>Y|WG7O8HNM#h!(MHpqoBn3Lvj`6rpS~W{sblU#vEjT2$OCj2 z1A_T8*tM*Mw4_$sojWY&3{a!%GQ&`J_{rhu3OJ*JTZYvRX)Qqf2S`Kg$oK!~(pPGm z`bc4r)Uk{^IV^N3%$`v0_vNhq46IcD52BnzH@LZ{hCjTL$8iw#D~YJ+XG|W^Z@c^M zecQP}BMHm7yz|!rGf1*&f1%-LoVi)kIgda9D8a|Az(>B}BlUQPFyq2ebDc~6bDFrV zD}ZVU7`Gp?Ti?m8o1xEu~qb91FEu0=> z8j*#}Oi7T(3~knxyCbbkW~W|$y|++2I~EO!xfxd(mw4I<6TAcGBk8`T3=W_P5EyZM zGt(+rn8Pr-s~Up4K{ctds#Fy|1FACcCi!mMsr^X`No`f2ATG}}&g}%*i#l7izYG5QchWxM+t#(4qO8+OXhg!TB6MPoOH+_aw)K-Rd*f57KmJ8T z>HRZR@M?nHr8ZO_Jw*O6tH8YE8PE}m;>|J_SrrwSCq0?*>K~bsS{9r#OJQRBmcts? z>$WM)jJTT&_pN`ULqi=P_V=F|(foWZdU|Bp*<~GZ)oDf3<8snQh;ZRIJIps#lQys( z|BFx#HqlC4O#QBB|DL?D_xm?mVNFb=^TT9(XqOtfgF!EEcap9;4{ zji3BD=!c~6(4LcfniF}P?v>(`lG;C|0~i>+THDZ1m%lHVQ+h*>{#p68bkP>XeK~;} z&~ELfP4CCN68-263e*St`syp?_*2X5^~qoD57tzZ;%8PGul-Q{d^PK%zu^v2^NsU+ z%3sb)KI50&x$=Wa`n+ezwn74Z$NB0?&2!MxuRnjz-e)Aja0nwO{y zrSn0kYP#YR^^vsB^#h7>IZ zX1JWcI{Wf{%+*5L%9{MVrBj{!2cB8}tHwwNQd%s>OSBf-KPyD1w3;7%|JD8UlkeKg zee#lD$wSo;zNWIsX<1Zqz3_rgadC>iS6%d?YF5FCu~PK=$Sd`t__ul)-x@UEnDpG? z<7sEUuMMg`*=Ma=XCd@u9*1VVD_ipx!nsp3U7Y5@aW#lP@$-} zt6g>E>vX1?Vrli)oGS(--H^+8>Oc2!>K^Nptph~! zM({%0$r2xiWtIK0R&pf^@k6#Xs^6PM|J;dxr50MsAEt@NWyW%25JVBBNZc>o`}rfI zs3g{yY{d48<+Q`)@BmAYD)g}S|Ke0^6JUnga6Kat2m?{4SN(Rn{Y(i)5WfVB1BW+$ zYnBs~Mgd*q>8Eou*zW=O@#Qj?S?a2eN)GPI_T!-vX_*yD_1Z!fN9shQf7;D#FDW0g zY1rYgs`!Do+XK>fkqp!m_c58mxLHIGbsmXy3u{@zfN=qld9q}oVS6`s!q&i-DyqDk z_KJGWDw8x#W>_J@|Nj*TcKJz9EIM+;Pq)Mm*-?02xMPpjD=%l1L1=#CwZXi95%=6m z|BFIWbGR;H3&i;)EIk|Aa1vfI5s7s=o(uW-z^+F^DjBx6#4pkMQw|}5ZoWM`Li4=* zjYLnd_l4trnC{$OOn!w%sc{^{{0GbjoZmcxE`d34`h11&xkvxjV#n`BzsAdTZlzy1 zXtZUxzhtEoU1>dJA->8%7`$Z=d6QPQNxg7r;GV@UFw2Q5FkQ>G-j2j9x#$rl{~ z%_TKtw4v*gEHkp{2YxrNZ_PC46nL+xlIsHSMMsQ8kE?iua1tmwFJTXM^J?^m2`tyV z=Aij@5mD-fy+<<&{JP7!AC)%9PtV)QtWNzGu_M6!;Af@JN|tJ0;;f4obY`C%jm9ug zP2(dA8K=%-TJGs^Kq+zKiTYU6RtUqEkbujk=~}y-*1UBDM^ja4%_O*VifY={Uh{eO zTE`E%k~s}4qqEuRBOKcD-^? zV}qso6IATiZQA!V3dS#;7)%V2mcA$kQB)X-FU|K+rqFyK{EyfK7iE3fSZrjDq7l)U zA#3;eI-|FanA7z+@FH)cSitWv@s~yZf6U*V*g5?>6n_{>Me+M^WByDK`W9ODSFP8u zNAl?BQ$6G&tkvbS_%4+PSI<`KvF_}(A=p0tg8Y$<*6^G2s=2Op({QZ|Vf^8T!Q`tv_g}=v zIW>mNI5+b&_NLr#vOSuZYj?+FdI5~5mW3Kl0C#OTv9c&3NNj)A%6qS0P|#&{0U@+ zP1r+em!7si%t)gx6`AxUkfepjA9A0JK?Jq0dQVmMVZP5i)1#uH#dQTzk#ko_b8@*DpGdRayp1B|q@N8F zN5|7K55#!i_b_iKfP?e^eq&t%ET|7ddBKG@FLG-W8U@Z=p-XP#{sFkvZ{IaoJt#SF zwBAD zQ5B#rAFC|n`x++<@JWaw)05Lm`~i7*M(XMUFnr*lLn@UxK-XVFvy5b=MZw(79AYix z{V7hKXZ2858-ew)3QVbFg20+eCLkCJeS|kCl{oEV^_2#rELc$WqJbjZ9~Fz&Is#Kx z@-Cy)rYQw>gwhd!=2EQQD8|k_7eHuX{1Dk1I9>a&KAy{Jj8MgQ8*?+&_*U{asE;0jpLdIH zc7wxAHbgQskwK~xv8Qr0*;TQ?HT*9!y7%ufVXl}97Y_nBdIdyQbKU9IOVGDHH~}ug zhzxsz{*r;OFi+Z~F|i$bE6b4Poh{T`0*gGiK0@;(n4${(nF#GU?cnU+L^u??-RG<^ zX@c}G4nX_T1Q8qf=OK9OYVJFu4H&i$5bL~Ff_&RtwSF>`n}y?&C4YR-Q5|uBp;bOo z$U>0Or?C1<9zl`rDnN^{j1bxWuYASsH+}50LNpoJ?gk;3Q`!@HK|MQmP-j^J5d<V*ro7t0npHKn|OTp#i; z!d+=-f7y{y)~@#{kFC@>N18_+ZD~j(a`e1jLJcjOmc9RNOIM_osHFuG7lFm$@=<2j zzTWeko?E!UHe>S8G|z=XNARo+xDprUy$OYN;=yC`=WG@iwPz# ze+;XM*`1gNeb%)Y%>$O48LV19=nA#pKFG%cJUk zx~Hz+aV42WY1DN%UQRqGCUWZDpP3)wx&85bf6tcPO0qj%z|zm+MO?q0VOZ9)qPuAC zPrF?vwJFn$B!}sk*@2!UUt<|hp89+a-O?-+q093dJgC9{O1lZi?B|un(XEHILY^l6 zh?gXWZ+kA&Wt;zD|4h|^X$7YXbLH(ZqM-!?YUOH2UxWn2#5_lx;!VxcQP=z6)D$&7 zcNBkjueA>&BiZva!$GlEwFV&Zhuz`oB}?P$1ds8Yf8t73M%H0_DN@UvoJ~h^tNCSW zQCexLu&6qA_#2t#qlG1$U)T;3;5@2uQcyO4=R}TQrfEmfkcg^eu+cR*^HBfvjip5> zQCrg1{Pp&cWgoMjmi$M9r~v|`6C{RwW#TLO604jWkkDzki=N9Jah3|nJ@H=*hN3 z^DsPcAL?WQw_({qWEk2Lw9DjcixN?+pBTZiL#YD?syIgG&jmRMzH!OvD$BLYtwI&lIMt8Y%AOHg-G5`K zY#rb>&z@#$v1;OP3@8Nc|KFW`iaBX+Y*|E^S4;k<{B{HR0vKP-(goo@MclR{`T}|H zSJ0Ty;r*}q)sp4`*_{Fwl)Jz%v#e>nD1jETSX0I3-^FNHEB@BsKbSAueD}8&D95*S z!Ux7nHn6pkgy`(Y7A=M~;9Y9&z417ZU&fNS1s*(FrKkpRKB{uG%oR!i4)Ru5XX1O~ zpVv(~!Th>Vw0xP>KbHemy?M2-EB`FFg-ReEPi!I=TRqrD@#1XF(-iYjVZ+}^H5ld| zxX$SlP$^c|@axgbg~P!|NuYjwc|+6S!S!(~!PSPGZk^gcOJEo=CNGJ%5@P`-jy z5IjtSJb_gXywJUg*{0q!*B`NE;y{Q5AYG~SJA(-jeyp%;zTEU7o>+#rm^eR)$n3Hy6^wX0h&tFZ#?M2%i z+O;lp3^k@cQXtWce)tx(?h;*1FHBR=JlwG7eG-;Px^%B2|FM= zX4grn8!MXh#S$l1%|nJ;2Ge{Ll+}1YdEy~y$x2px1A{G)uEEE{w>Il2Y#P1aLEN|P z>;+4Glvc1;hckyI6)r!LAytBjn$XKC-2M7=Q-$M-`b1wPX>t4ekSqUO_I}>#km4ar z?3?`3TR={jI4K=!NPOJ1MMWaSWM&Q~|LxVMrFS=I0)xjVo>$bE@X_mh3X9d=fBVp=LSpEdU`A`eM#;kga&&_eRa?ki;GPSIbIKujSS_0?0rbOY!(1^Aua1Y0T01knyP;lhM3yynx3Ue={ zt>B-$y{LyZWOtXu>@}V_LVuiSw0Zl(y6r?PPlVnpuok2rJ(2Y z>wcR5G?G>}_Wu$#LLolLdBeStC*a^Eb|otxG_0em&G(Jb!FT7CHD1htzG`qJc4*Z~ zXQ1kOe-60E=TC|O6N&AE__(oUA5p~t5Hc4z}Rz|Lxsu77_qjy0X3x#;{&1B#l^ z;x~XVd7f1D9|z;vp0h_Swi9w>K98FJ#KRVxko**=dFf%UGeFypn^@v3876HC00uXY z7OkME06s+enqO=mLG9tm&`wC#{=A=&nYX?yPvIK~bd2Z0Q{4rh5WEQNU@ZH;`ZxgY zX@#!T!^^ewk;ubvH-j;>(zJ}nkS6H7HiSy&&;S4R!DS@WRVs0@lF0H+>3Ay^yv7BR0^k5~+I;8wEy2C2!f%_Fkb!(tC$0nRTh7i1e0I?@R53VW znf~uH8S>8QGrzL{GYamPvNIsvFYSZBR&Mb1n|OR5$d=U)|jL7C*aLauz_ zf;(@T61VnPX_C9B5~W#8g20Lt_E@3LNCq<7ism2ag4d{M-dFe&H>af0I*l^n^7De4 z{=L3KKw~^_m~_)l4_^eV(U$qsaBWNZHnmU+N>13=aS{GaVy?cGjpGhdR)D+>BDvS+9hVELr#6&1}pH2f!d~&yZSR_ zNcqxREMvoP5FUZ419J?pa;n+;ITBWm#=aMGvBc}RB;7#!9TJ{Fxt#I+!v{#?O2X7g zQdg|gK@a5?jrPgSu>~;>e0}F=n_JrkZt`a@IPi>Xu9sbtiennq^j}X9+vKp@y{>3f z@|0X>=^pQ0c}Z<&bWmkMw@Y*2Ds(Fci@W0uYgnUOG(x6jO0HET3I{eA5MJKKf$G4x z@Tk+{CXUbPX1T0Cg>Rmy8Q!QcKi~La;Yi+n$8U~*ea-jtm9dc08bzp*Yncq|Tx!;S{_FXM;w%#tw2bDgF{aHY<1Ya+P^jc=Df@ zg=aOqmz1)%{2aW#^_*VL%Z!2Bf+Y+)B|j|pbZ7bR=Dn=WBKbO(3v^hZ75dYx=PC1K zf{7Gm=6AP^6H1Ug<3CQBZEl&jU-tDCYNw2{WHT5SxcfR6UblvKpevyF2#ZOn+Qf+{ z?ydBiX8vz5w@S(Rs>GFfjpuS_!;(O<+$`)$W`g7yKh1Bv0t{U6mQvT6Wx+Djp-rkA z-JxCOpLXbSkCC)ce{37;Ik^n*wf3M2wL(OOFESyag)x7gb!falzBKvg1z+KI(_Ko^ z?r;dk7`A2{ia|s`yaYJQhSE74uQr+S(cPF2xvI6^~vR9A^g%5Az*Qh`+ zX$MujM?elp_9AzMyPc;APAwrq`g#7VT)2ztQ~z{yxmmQzcaRa5#fxu`N5hYZ-H8Ck z+;Ej4A3piezqdciFTS5@8tNdfgYG0lmFpM^LJ;IcrCles{yaM-C;;em5lt`Mf+ah- ziadVXjMoKKu5RLB573ltxGnd`oDBhaY;oRvfn|jVE9Q0NjpnBQ3Vj`DE_i|3LKpH+ zg-!i;pEWhZZ-f+Tp!|h@A+rR&$zh=vXb?E!M8?1I#@W0gUX;yRv#0PS;2j0OSSWMB z84bxhl3R6r2~eYqq0@FDVpp`GZ-3EQ=@W02PQuktW-Ru61A_2zAzRt2(uwN~mylfj zb1(ouqWz?eRRSs3f|qC{ZBAgvuH2mk+TNM1T+WRlkJ{cOAwO*&MpyxKC(ERMV}#Ws zj@PC(x#8p08$Vyjmigutv`-zBQ)S$ABI``uluweyEfB%m^1NK)yn@~_NL6>gJu2H#sYIkrcSYN3<^O08y2?_7J(w36Vb!KTQF6j|0lxC;+mzrR&Xten|#si{Rr2X}B+o?JRz=LiJG!pZB$KU0~ z%c>>ZZ#;fsm%<PMcb-1f{pMN-a7 zd3H{;P^zDnv3PDW=~DKGp>8_ge5P=^MBm;cNK@i>kC}I8CAxP<{D9r2fd?KfKi9Bn zgqtXQ;k0ElXPER{HdWH$?rKty&Pmi6?N2I#R>3IAfpmZOO&yeMVGa#zv?iRZG30+- z=R*c7Iq22cL@TZCqUfbl$(!~njhKS-#m7a%imjJTa_NZ3&r+rsySuJzStKj`Q)+yL zuK1$-D)md>ObvPH5xR-PQ~$y`OA)y{c5})wSH|P!ly`I%+GLk}PvzK1gl^G;ri1cl z=Sco`tY0A~^bIPP5L>q*vzSPUrL5+IIWV)^eYi}|f368^iCZd9LTrmevblcS7!EqF#KQ(FL2epqFpP2o@4gE3x^>54WY z>nS6ca=Fd8x$U(8EgC8aY?fG{K)6xfFsW@wpt?|rvOxsrokzEGjAjKl&7z~KnF$-4 z1YSo=kWzH8E+hel1$6@EJrKi#kQD*-=&V2K|J9yG{`?(}USTf|O9b9hv;uwRV zD^A}I1eajaZ`qtDK1wdL0C`(8%(?YLYAcCpB*@fBj}I{Kq;T-hu8yifCp~vQSU!1I zh3VU)6?ySiP2sG22m;rJJmEM`hb4D@YBnfPi#wV&M`Rp0o_fL_Q<; z?e48mlJje@uLlXxY9Lz9m*!VMR`04Ip3-l!q)I2Hi*n{Indzb}bhq$US&M(J#+J{K zO+TaDb!IqFZVZ(heqB|;d0e!)Zr*QHlnzpps@J!EkOC>e55KX#XV`g9sy^j7nue(j z-9$>D&YdWg5LF2g(`Cn43FX|VzNwGZ!)9&M(a8Lulvaw6US}dqlm6$F$lTW<;EC<3OlE4N1Nkc79Iv=J+QrfF& zg_OmI8-I%in$QcQ-Ti0x8Jv1KFy``+t;EP)B>%V`zhyDA)hc4+TDgTy@k?hnjFM@A z$#bl?Xb1#9{KNQqkbI9d<^%iJrThOPgu~s^re|zO?;S4L_l9VVeH^W3&nK32OkS=; z$sKyX*4pAj@ci&59l4}naPu}fVltAAixb@`FMf5OElp0o?>C_~W;vfUPRj6Y*r~Fw zG@1;oZGS?^mgTY)wb^yFd8fHNFYwg4(izfLCKI+Y z%x^IdzIZCs7C0zUI$b%~ECnC#zlfRi?Mfby}bbG5xsbwHJ3h2Nj!+L((r5Hs1yDgu)EE5lL;(Gy=kE>WNd#YDg2oDxvRs1Rhu zZ>RK-d&R*m6WyLTK20vR^r}X);Ty6q z`NXf;SbO?tFOvgZ=e&r%vfQUxzW=UDW$Gr{LiXcL zqorY~bpcK@#-LPGAUbw5U%)oE9sF9kYsFW%ughV%l^~-`AutI6e>JiQoXq5?TKJc` z3dWP`7y)x5^v8ydkeG|F?9KAO?)Y{j2Tbhq*|Vf`cjn_$QF$ZI=W2I>Q?#DXf!db; z+jmcbw3mX9!W+I8>@gje4-eQvr#c@r*L_`Hv;Ot>vo}lT;ZfJM=AuW8#5?=C;!9X% zZkY*4ej}fQuLr6BRMwQh%-E&>P~a0Jt&j!D9s0Tai4&GMR8)tEYP5>=(1#(mwEjy*ApIR!)Q(O&2`Oe&dN-3Na9oxgLeq({g9W8)^{{ zF@jxs*@+6?$Vti})WLNyJ`=;WQ^rte6tJCBko)B(jb#4#zN1$GH8BfpoXsMz%yjb8 zZTo*joqHgY{r~?j=Thh}C4~-RRw|l`ut`pn^F~HelvzjVfUu$Caz3S{%wgD&3Plkq zhYoTY?v9EOawf;Qf3HjT=llC(3_H8t@9TAXJ|8s+3MY*dvk$;4L?T04Ge!^3#xHV# z#QLn461Jx)N$o&TpZ?GJ2}IvZidBEobL`uL#myXD9oI@B@q-m&9Mf;Ksg;Y~O93t- z^sUpD<5?k9#_MV~A`@BDz2F3(W zv!_)YE9csOMyaeDa(q=_X@w~oOilznPzEJ5a@^it>AE5%Gt#>=j+^YJyUOL2jI8fl0`b$w-p<6Ro+Y6Uvph_j zzjz-QH;RIdo7O56TL+u9mF+F0`DF)Sa^x^?H2T{|y32=HJ^c)5KtyeZl+;nA_n-iO z^OuVFoNAy;=N7-gl^0XIZrw^bYuGohKCL*bL&h=F5vPRU58u`*s|;gy-?n%>NhWv) zM(#UrH0?Xp#+!cAe^?~>=zaBNO`r6(w~ZL&YU(UHT>J-WE1!oHi;qWKm*i+YSBZCu zKOgOB7vtcs%{Xtb;5q**Q=)jRE5E^^Bs-S8NJO9Ia|be}H@Nurez#ozkr}~?>L#u8 z1P#t25#_=a26P)W8I&DUiM^~YrDJ-1(2>~h-7L-69;kiQ%E_>134$k676X_~cFL+|i{Q!8$TMqSn z=>3$sr^-1V|2Qx`oP1PDlBuBeDfeTf3j4;sfdTu^A*%*@K+HSUI3)lR$HEtI5m5re zg>2lH+|3H4>$ME9h1~decdnRE$=hm|boVkWsOPK$JTH1qm`Rn(;$({Dx_D#?hUDCtd_2kN_ zl5VF5eS0-S7rJcpj~~pD8tOix65VKJ%p`4x?!02R*OMn(KaMTpF<u1KtCxnZHNgTFl_v9Lchhz%60(j+&{zKwsk ze2BP0;6&)->HiX1?Q&WXadVOv0`h9Dai0FoAOdya3u5k{AVX7@u#42#bZjg5H|;dL zZ-W<6?=)I>A-nXK&_vP`ip7?j47{zizzO(8@WA%iDcDw4fwK;wxSYiOSnH_%6SM{q@GIf- z_~7A77O@3|hh%tz=&Sj%6J{m3Q;fDe+9f<<8(?Q{gAri|G_u^r;}O}geyYW!Q?GLw zv<>10^P8BfLQH9Oh*#Zw(e>2K<#iT2gtHDVTx3JP;{c;2SO==Rq^Bp)$bIoIye)v` zdzL0R=D7v^z+rNDDGBLHpO_;g_+LCB;8MsD?-dKwpnC;5H$~^M>O;u4 zDGe|$b;UX}8K;9EX-ZoDOYWVK%bk-SR2-V4a@h31wFo+su@_P+m7`KBK76gk6CpZ>6O! zH*?qM%)%E}$xqkwhBUAIGHWdi@aT0G*0dHH7$%6pXOt-k-hOxcEqX1t&vZ^?z>uN= zZXkc=D&LIj24yMRLJ=%#5Jf44Dv4pj!N>$ix!q?d9-o*?DG`LJzOsW4=k<^$*bK=; zpt3@VYpO+msba|`_y_X+zc+}w``W*pFQIRzvgIb;5m#rU3AeC$T<3m3YZ&@H*-GpP zu-5BDLliA0SND>CCBarwX-$BP`4)`D>m^`hCHUZ=hD^44v_BQuouV^h#HN7ucTxt`#XN}m;C ziyXZz8UlEgb8_F*TMPA$7IcD&nAcs51ZIKL+oP2{g)NUQx)<%xYmqwI!&bVrj(zHs z!arkM951MVK3CpUgrtGx98Z?QY6$!Go<0g-vr7*yl?)gkOiO4G4jS+cCo~u0%in+H?!9EYY_a%_rWgI$0vwWVC3^9IIqobIjer@&+cf}97$eP_>bwi%t>HgCv~ zc0U9ee#Qdlz9r>bp&HZ;Chlu6-EEONsFol!mO~R;Mvu924fR8OU{62w!u`%3Mjm3u z0`#QdI#31{>S_)GGP=S|%mY|}V7C4pJo;e4>l!QiM2frCjJp?gxjr}^P(*>S-MJ3Y z;7@u?o|woH7py+q*9KvmmZ^kBzX`G%A_{YE^{;EX~#$~m|FgMWKrGT_clY66Zh3N_1fMq6k(u0UCzH( z{lSmlo%bo|6(E>XRc13oqoSUjump!49CiNpy*WyXmI1x2XDvaIxstg0U4EX~xr&;Q zP&!9`bGGDx-FOk@w)x&U-o^4sn=Yqe4Id)rsKOmzC;itGnIDj=d}c3EaWl|llRjKg zVe>pb#mffRie5RH5*r#Ndx?~jY5cB^Nn2L!M_ zATtd{=)@`I`0Y*XBTo7!a~?*0%`-Pay!=l~ksh6V`{+lpjz>TFLA7cHXUS!FstC~S zJAYIj_k8(^A{4X-IC|@n#ed>40h-TBl^D7M* zL+p{6+4t9lZw>Ru^iNXDyhc}nxfV9xv+~ui3~u&Tv_L`)v@EI8&i%Yi(tEe(ii`XE z_L9T6I~=OqKe7&)X_^)fZOoV_0x{6<%E@qF)K`PIv3=T0FJSp$t2yz;>ZswjFEebnZMQtMXJ|~BK-|*pCn)fD_bmB`u#z>q z#k9y}$&w}J6^k);9I=lK;e|0*E0@Fsy%RZS&@5~3@bc2TTYr=-PCudYgWi<(V#h@I zkmkH=SnC6SmR6iiuu(_BPhBC)HT#zawii2yTi1bxn;76JC1Z2a@)*^X?$Y>T7=_86C(zEewndCst} zbc4h?6=TasQNt;33sFy=B}Z3iD}3((i(_?=J5s>j8BiPuM@9}X!UVn0{vOYdV-$LU zcMJ3bkua1DE=`zJEcBm9AK^$}eD&^JeT}@=`vVi%gEfOSt-M&8m{~wbGAq){@s_kH z{f6>{s>{*1t$r}t9)#TlKgg|MjY=m3Wi%N7X_z@r>@F z4N!d27KLmE-9zz98$+T^b$LrN4JW*^zH$kdxV~le$5Se9dado6HozG~r95{W_!kj` z*$kLJj;xkx%KaS3zvSZ}ft=?*)s+b$uDX;D)abU^%7R~hN3jmQ`HC(GNilXYz>KGq{Yr^| zImof-q{-|>cqAi3WJtgO}dd7e(nYRbC#6GYul(8}hwr8HjhmXP=y~Av=}uoEjZNl&0Tn zpBburumWr#clrPh$IcQ=Nf8_1M4#q6k382ovyrLa*2#`b?XCOA_51wB(xYYdod+(@ zt&h~J@m%ni!i{_QZDyNkhZL2)M>ZmP84c@FOM^yF_^( zKhZQ#@;?ASp=C)|#XeC6WAYRBdBz$mz^WYR!OrG|<{F*c9p?dNY_~p(72(|xhmG;B zWEcLh-I*}bcI~!v!PtO6#&mf+VXTo+%;5U{QQVEI2$fxA5D~Q2Rqi!m@!dyiIhm%0 z4e2{ymsb-?4TM1_a%2vfeNC*UYzVUJj+x-aarY%FCImQTMqWHngcEFW(9}}=rf_}0 zP_@Z@(?-i;$S{@bf})X?*-B!Jh`_tUk3SDEmKE+9i7xQJ!g#hgKR3NTDL2R{Kcd4q zcxL(U^4%bUm^k-q_VKY9joot8-BDC%tVsR}PGEnf79I=-u-Ij>#11A*xpGizDJ*Kg znRq{dML&B(%9Cq-lvi6h6yKQh_XujEm0Gi_M&^TAIJ?-nb^p9 zMPv_meS>KJH)@lT#PRIu#6lwl44OBfN8ItJOM?sJr=^D0_IVfMk%ZvS^9D`X-&;O2 zCKeM{G@62dRK&Vd=siQ$!OztOC&a%&8>DfJ)N{jvjO5PvyHen() zD9QIeFSYMxX+*=}6GDX1wlwA{76~gt!^F!LE{#{lLIoe*L)*?Tf6|B3zCNA19F+${ zjy}kY;N!X=ja5ol9iPp<8_&^Ikf-)1y29$EgKiZ*oB_jGAjqG1^@RS-1W^x&>=#;D z#IRXCNi@-vBj(;`&uYLTn<{UGsFYA$(5X^tlXtil%vgQQ%`hdHE8(`1-YQz@7?o0Q zZ)Vc;2@^m(crj#X8`gpmBz!WS_M#wx>8GYCNm%aIF4`|8nk01w)td6==$Wzv#iu7; z6Cw~BBS3P-m`k_abLyq=>)JPiGsUnsGvR16sP1t~74Ns*L@A>*X;*tujdL2vQIOlC5cQmCu$V{#_uYXCt)EOQiy~AP6xlAMSP>;# z>W!ws-9xoA64w&|4Wfr|$U$Eh>{_=vkGkl%hp_U!TKM&LL}?`Zba50biM*T9_e=D? z)f#-ft=JDj8MVQE#|cA~iFZ_~RE{JQyB^HEKr2s-yPp}SE@%hpbtmdd@& zR&20A%}yjfy$Bmkr_ac}MPD`6=5-CG!rBZ!8#Pg97n#OXit7t#dG6+++r!TKJo48B zg}J$6oDhynZ}C*(3k)kGHqe_guU*+cqawS6$tFfQsd`HLeS7Vi}QeB#eENG!+Rn-iG_? z_*6y5+2Dlk9rBle=Bqm1m5-Kn@f-o3PIpdIr6!<6l+^f}tGNc=x2&me!ei)0cKK94 z-mw*$sP^S5v8Fp-g+#-rW#lMd?ktz;c`q1Gnx1VYrjH#{dGB<8lT)K;@pi?jEdy3+ zsp*{6Qr)p%&Z-FV%Zn}@8PR|7^Un^R4PU>@3hZQ;)pohcyPq{0uyJsU_1bI330&6G zaXN1PclnG&kn7uB_w}TwcYjY#HB_T@mEU@Q_;o6T2{SG8ok3eVif;?M2|t9+eO>Kh z6B@q#rZ#yPrw{jj#1;EGRjf6WPnp|SX!aGD_sYQSg7*2Erj=#$gbKHI_q+7V=92?z9F3A`)%OA?7;g-<=1w3urK8%>!$MASldWEZ#7J#Aq=$)2M3Np3 z>|Tk3iQNjh2i)*NddRnt}I(HK#Oq-rFRJy2J z^~2q8OFZanVjL#J3*T(#xM5Bkw!|ezTGROC)eeh~{`|AOY*wY1L%DR{RR7T&4o7Wt zVCi-_p8LwOFtDbjv2xZ#V)}r{W#6FnF)cRU{?Ueo5=OH(O~lvI!el*K4c0Ovt&TGf zZ~&@DV>VKyz_ITxvGKv2-gBFE+!`Hbpc@L7iWp|)ITvMkvynHP zIFk*WB@I}u2gI!3t|{@sD}cwF%wUIrM2ELl}L zeWyG3l&h>%7iECZ#dZFTMLrq84}%rUA4uxv=(vpusX}iN2X#SXTpukXQ}pKy8`5(b zLu^OEBou6c6Ur0e`0-aj=ih@Z9u<~Y=Nus)y<#UD;nn0xDHmOS7d2bx4}F#MwqmEs zTLeWUM_Tu%QjN?rfx`k3j&46WUshGzA+Tm5XBn>c&S@*ezHZHAo(hgI*a!;5Pe|yy zbw7J_&aCsHoPMNmC~Qsk{`~s)Ac8TzF#muL^uRy7Q}s#zA_N$$9pQjK*}KDoEPari za-?_oY)tOX54+!j$bY5b z3Rrn3BdVTbRP$iFD*Wpt_0uxsv2`nU0-FZ>*Gz234$lhKzy+~E;gmAU9MaZP)~p@P~daH6E?UcC75yq%Fjq4_YrjTyeijOT?LYXh!zs7U zz!c8(iy}gT%A^Fsl1)4K-gCH*JikeP%;2Z#lK!L{)o<4LjO$F8-3B30;E&FCXCC|2 zP{NmrZ5tUs!k)2S%bWf9>e*b%+{!4$e9U`kEXJuVS?sFjuEgjn%<*XZ2Jt^YH~N;7Sr{`@YgWR6N z>{NeModxA7f~cpkK#)KBJ{&@%8>zg`i6f2Q!#!G61U1H2oVl-2cLSV-gSv-&7B5T zEpg399;6fp6E3|2ns4O67V_h89mL9ruMFB%ygz_R3_#bqI%P10>m@q@ES^0xx{R!; z7L6jOGZW@9vnfffjj6gP!@C@p_tCYapC?~EY)>y}uejc|x&C3U|1p2-UIj?cVZw(* zdvN2(>Q7$!k8FOko9Lr4Hw4>Dy7?BD@L7|wTFQ4iavZLn&aT|M>HdXd+cM(Q*fLoB z`-3BZ5Q8f>QMafa_5$#BG0Jq{t(Y+oO$@^!}n_ADnzoQO)lMg&TtN6@6ORvqb zMMk=+T}ra1?O+~>so?>B+ewwlf*1B8l`SViYb&;6M0|(ShjWLtZHDZpW1%afUaC$* zDEls5=*V+9Zo1(E{fx#=7<7x2#aTK$v8K1j7k?)^U`=W{OV)7fy!@ktefl+U035n2 zbfHMPx_5IUKHj6;)y;9Ej!Jhkca9o(o9=AQ_qPy!T51vXoG53%L&Gn3bWrYFdLVS5 zWMuE<=-G|M-5(#)8rJ@pW1*NJ4kL~mCOAUauoGb~fqOO*CCX>GJlvIBdn^9M>|Yvy zS_S0a%!E8?Oh{Yb9N0ELaWKSj|2w#+n*ptJw&n6$KuCjJ^H-+$u9VT{;ha7ENfouP zDVju2VBR8!v$q>|2>c{c#{a=wv4lj#qi)LF25GLcV3)!R@`gEobD zlL+HV>y^WO&ArYar>$sJ01k{tU@(cPaftC?4!5C|`wV1sQ{?sGY=jCu;|wAFzKKgA zp#+-aRj4^6rL@PZHD@wsb;?UhK^?L^Ah90M^0qywljNBn#MSUmMIw8I#uV|KNkX4u z_GXv`Bla8VH^|{d()6UkT?mVuzTWGc*OFuLOtJ2C9&wQbMjDh|sM3a%n1<2<3xPSo zcRJh;G0?O2i#!N4^5?Lk%T18R7~-mv&vd8;^t{_&>4gCeB5o=p58l;|XcjevZ+@>p z7}BU2%|DyJYwCwrw_m`AVY#Wv6q%^Ohc#oOWe3<#A6M_2H831g9O=%imsis8wJ-?| z_4laK#M?J1q=95mL-zHn8p3GT&(Gdy&PH7x8+HbzO(pLq%iP0^hbtMhVz#!zb)W`590y4dP#Fw zm95xx%lL)!OS9I8hxI#8b(fCWp=HhnU|KVt+hhU@g7G!WRunWaQ&VTgKIK!;D|F;q z7&w^&x6XN{C9E~)6C>wL-a!Kz!f(5`dL&1O3hu0U5y!A5)GGo0^Os;`rq%58z<{c7 zoU`uyI(F8D4*jlEM{DQD{za~ykd{|}yg|Uy)B8{+7&v0;fIU9$b|SIlMe#Gji})B} zV+H?po9m9l@82JD`>Z(HH~4W`wq2jUj)H{J$7$|LXGDUy~xY4wtCIJ z#2l^7e@{jY;&-@!GOQ42A4E4G0C1TPgTD*d(^z1+mt^Sg=aSx3|29y)x>^~Vv_%gAo zb&<|#)j?YEnwSVv^OqPAt$XzWfj-lnzLdaFQv;~REf2;c<;O{e?-O?8eTnjFYNETE z_YWH3`#Bck0^GSpoW=ToYN&5VC^4;pdl{al2V#_7yw_EETeuenWCFV%Bqyt8J|zY| z6geC}u}*QzrRo{)rc#IO&mK#<{cq0qpCjZQaq1P&afO|BCtK>3?FwiT`VJ=g6sEL> zi9&aDjr7?)nJjAJuA4FZw>zrrsduOaw7aX~1WosK8(!dQwY~dz2}E8um3r;Pf}&PX;~hxEkUy8N_zJ&%nWy;H8aM0wWX9C)(MbpAv|Yuc4^qyg2fmuf*UNOA zfBj<2#HHldH800wDzAXo3xd1i+CYbMazh;~8=q13^VK)M6Z$p5flbWSx1u%5*u-&b zZ^Y~J--0*G5{%2gJ@5{+a1Xd?HYb@^(-EO#D>~{k|1$SQp3Ahe>&W2gwdcq2OHfmf zgIQJ7OT%|;<`BV|j?X%?j}EjmF|CRPvs+z9cB%P_og$t2q*_DoJ{{=Lz3WKPrmvsp zaN*B{ojoVaRAzJLb#l+@-0!QrVE0j&W-}zsGtBc|72K>HtA`t9kUG?#v3>6xyYxq$ z6@L8Lk;J_2_xFNp?0h@0*GvkR2K_wK>wHO(TUTvdyD#O3o5XR}*$3H2c+DTzyv+qC zsphLY971w-dWI%1Y(1*c4h07!*6=RjVsf?mNgAAvd+hyB_SmtH_wAz}_;PcnsW-DJ%)G8P4lhlT zqV6k(4UW^Cy@Um`?sSq8OfnTBg?1(qa^aq>tjcf^{_@rgMqgqHzpvg!Vu{ha;`$BP z(Usr0HBI3`TQ(K)HWkVh={i{Sl;`)F)VF6xQIXsAac$$gO*VUP^Izq`>3<8DJO|e+ zsdzytejBu+NSP-k)QK%kpul+r|=-1$QPpj+qU@rUueZufxI7h8!^QTmd!z{fa3(e5MG zj$>2$wi51p(sG6I0gZp``oU#1^tfc<9)9)m6@!vVPsME|-WoUYgHnN0Nb`WHz@@UC zRcdlvm)RZqpw>%=S83;4dc5%VT1k4z6R~LT%%i2U0#v`k7iNPvDZQ7tyl(zcNaiv` zm6EchZn{3;@Z;@yh-2rbUdK~}mXUt*Q@2|q+P&7AAkN*9^sG-&TopP5kNq!NT(U?H z8bc!52=9P5+eQ8wJOxuUBA?2_AgXfl-6~4N>yOg2+=%w4bf^r`deekjM%Rl*= z>e>wM`(W(|ZP+*kjzg7pyyCT+kJfi^&8qfkRB}`Qc@hCbb7WIqV~*O*NA3<);E}y$ z7c&|R;jW@F8{pN$tR>@2R!A=x!qKS^vklXiq1ZdjO(VdafHy8THCD{ht~*)A90Y7( z@EZ&>UWIxVoaDaKsiN+{n+wSbb8uH-|Mw73D}q?Um-xnr3$Alw_OD5@*vv+I)WI9w zn`2gl*~qV8`{9$|N%4RLy{ig#bm*0hs*>@FG%oLvYG}NodzN&JET#)0m{wrb_giN= zgz%0k7mk3Jrqzl)4%bP?bm=EIm{HH-fdfvnOSppbPI9;aUjP2eOO%&2R+{X1+HW?;>Ab1r&Fdk4SQ z5;Qx%mqKhM9snFNC>PKsQAH_(wxZvJErt{1b>G>K5lWuPrz~-I0Z$X`|kRQxxroYRnd`fT~c#zoZN{U7scX z^9mz!*suev%?bcQIt_do93ya^44(c4e<<_QY0NDE2Vz=RqisTR3aS40iG46x2yWqx zrZlkhB!KHzniAh7AIvN4WZS8T9O#q!F}G^7n|~Czs|yRK$F=fhY4=T_^cvJ#fuapOS^)!e zALne0k~RQ0whmnnBeq~~FAyBPk2{!4%5zAkkOH1;QM-Edmu{9ArLoV}*S6tJa6I@t z`dCZp$Ut49LU0~d?wa*rF}Vm`!wY%ZdiXWuX(-*}td1te98w5($Pyl|0N@cy0C5zx zg+MYs#*`&&fT<4ZEdNHSJTzpUeFQ1`Ih_tz_XkK$eQd74Gw9KOOFBo}tz zJgEu%BIhjh|xgcO)X!zGV%Vh2T;yA;~5hDhbJi5UON|uyxu83$si>(ks zgZ~+=h?Q3YXYDZ8me2&>p>IyW9EHcd0{t~B(*QvnRFmo96C89!m7 ztxR<=YGxxjL38r1Ew_fg^M5M3OAWH_*TXCt`F~ezC7x-)qJs-6=tvo|`{N+&E10sI zvX$_l2YG)BZhU&?Ye%kh>UJoeL&FY$wEgx$JjSyY$ZeThljeq!b<{sHKJ&h-lu%=t zIFw|0_Fu&8#|v;`g`v0^0ocO45TtwsNfqH4{RvHqjXd=6ZSMMP2FzV<;k;h5G?IHN zGJqu>Z0WTz&&T#qcvb*n*i`s`|ki5*l z52DR#P2?zGe2DUMT{li{im^m7p!q)U2M%zSFo4aO@$pYo3YP+8-P<#K#JgxR^R7d` zlNQ8IoPQD1{ttbJA%!fk2x`HV0i)e!7JqfVaWr4vb)0vyW{4n-T>7|nwlasVM{57a zm1=EvTN`ucD=qWz)MYa_M;B3_di5b10yA@YhHh-(me#S&9<> z%V@xi4oz)jESJbZ@~>qr$>luS$i8z}9_hYk+i;6s(Hz2Oy;i)pZn<;eDs?FX?*OuU za~Qc$Y~d08c(gQdvx8GIxlnuIk&a z>)@^`d5;~gXiGmSHfD`(1seE;vpbmO8}CyE&PQlGxzJ*vm~M`vE?=J|b^w!>n!18z zhr&1-RnbTQ9>*Tu0J_UuHNPog06y6AsuEnR_3*WpqN(w)M zege724Ot|dqUzZ!7HlIMR!9SREiJ=%0Vr-R#iE|lw3M#_UfTIA=|p@D3;k>KAiCh~ zyQAN5Y@3#j%X9xEs8lfYQWt&V%m_*zkRpFi?jV=l+dHWVhdJP*yrMr`8k}tr(RYOE z!tGw?dsolEI*|spY$@&=Ekokey!l59kkC`_T$q27UI$Ag{|_||<|aR8`#PSyw!EO9 zxDxP~sZ@WF489OWC)K4LHGc|81_!20u;+m}0!`7Mp`eomVO4?Q>gRFh6Fko#(2r-T))hac?zaQt3T=y1MKpWWpT zTxiCp91n$bkV%~yUi`t^q`G1CX&=W$A}0(+Rq0@#@<`Zh^jzhuj!Ozv7HsgMk1277@nDk0KYoZAGl3R%r8NUG*B*Jk;0He&^=-H>|SUDB@qO9 z7PhLlmMs!`x6`89M>&gqDceSmxdxCcyLKDMB@4v!+T8=0are^1K<0b#r zTGQ|By8}z^(?nf`U}`Y2`V&~*Y~V;=Uz5?1IqKT@(*PmrwT3f%f)ku-8KgUEwg{wY zCF$&20~%(ZRxmZ*`|R}j#@6kT-&@(VS0z9{tCxk`koC`TOj<kJ8CRo5^EtIzFRF7=)ia(<{ELM8wq0#$?COB&q!)s2G>U+eFDx1DWSPH| z3pnd*#fqUu77+K}h<2z#PyLC^n1ypW^o6ejg-6UzEPJD`nWU+#6)fS3)9@3R{Z#8zVHUfShCDHn0oWzFugk z!6vibTxJ`%QG4%D1sBd8-hK3~7HGr(-uN<$796XySczB6f!(2Dlug^8hC(^u#ogRo zhjb@tbSiM&Ju0$fi+ zpFahyS(EV!Dtod_GbP`^S?->u@t+&QUYYm7<47+D13?lOprc`9oX>B55%iD(`l67X_Ls8l$p;%&lDrPsiWdP-nK38H9c#LS9kT{O zb$V6+K`+U8v+{xaDS3#6*T0A+LArpFCah_n$Hfi6xRn6R*UG<>hYHnsbG+C+$tOwf zl(ei>Ljz`@2xP9WlPAD;7IGcN^Qjt0rEZ==dCT^uf+%Vul<~oHvXhGmgINR72$0F$ z5Il8#2T=u4SLbx|;OUs2X-17L>Jd?SL+A%?YVG_+noAfu5}>*Md$6vSTH- z3B4C+LkLKzObcpsf*BL1$g8=C=8|_JLl)LicUJdd!J7|4J|wsErB1r@(5t7d%rz{m zTBsjq$uNU|n5*xw9)t|#B)?vw=meTA_zXDK1Y})g1WnnBBhs#NBneH#xj>2jrjNeF zFv3AO3vM``eFmUk^z*%xA)B|5nDAKOWC`%E>oY_{fzYv1#=*My@pIdT4_6g}yq~o9 zUvJ8Hu*y0w^-ga-!SGg&>px$3a!cG*hPEFyxRE>8_AKzwaQ&am1RVU z8Q2@ zz`~hc#*zrj72=2x9t|RsFqDUC36)qp2MzWUujsEMXHOtm($}t0W!M+#4+9-uCulB8 zR=h~pImtS!vr1vumn35brGi}$O;iZ{xssW$)F6o8C!?K^XA#OJj}Ttz}8?e{8g1(qA4CMi^W=4vvKxkY66Z5Yq2F8g*~Jh!fBPz86C85G4>YSuI$S zI5>cTe0v}6bS1DQ_+N!T#&Zt!<@L0K9O{9d9g&L>Ioe$2GDIHGD{>tm$|&*I#>^H> z@XByoLT1xJ$v{4u7|T?V)qhDf$kE$Aag(~{0}TOwvov(1>Ndq$uH+XU>ihNfdN;pPLu=9-9VSQt=T;1;8B0hTqt^}O6oPP^o?Zb8DYyDJ2^8f$GG*lga znT>?58m*}x6}$k@qr4kH}`tdYdlU z^lkavcl`!FNv95XVr@*w`e~On*snM8jki{pDjhS7^3{v6cQibO`QbQmZ�*pyyGk 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 G@c#f;|1n7b literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/provider-icons/qiniu-ai.png b/packages/ui/src/assets/provider-icons/qiniu-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..2ff255dc7617f18b63fb5ef18a5c5584f27868b3 GIT binary patch literal 20727 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 ) : null} - {showExternalProviderWarnings ? ( -
- - {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")} -
- ) : null}
@@ -1575,10 +1569,6 @@ export function AddProviderForm({ ) : null} onChange({ apiKey: event.target.value }, true)} /> -
- - {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")} -
{safetyIssue ? (
@@ -1719,8 +1709,8 @@ export function AddProviderForm({ {protocolProbeRows.length ? (
{protocolProbeRows.map((item) => { - const available = item.supported || selectableProtocols.includes(item.protocol); - const selectable = available && selectableProtocols.includes(item.protocol); + const available = item.supported; + const selectable = item.supported && selectableProtocols.includes(item.protocol); const checked = selectable && draft.selectedProtocols.includes(item.protocol); const itemKey = `${item.protocol}-${item.endpoint}`; return ( diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index ab7d25f7..91fea4ac 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -587,7 +587,6 @@ export const appCopy: Record = { "API key created": "API 密钥已创建", "API keys database": "API 密钥数据库", "Copy this key now. It may not be shown again.": "请现在复制保存这个密钥,之后可能不会再次完整显示。", - "Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.": "只输入由当前端点签发的 API 密钥。官方供应商密钥只能用于官方端点。", "Add": "添加", "Add env variable": "添加环境变量", "Add header": "添加请求头", diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index 18ad4c8b..3f47852d 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -46,11 +46,13 @@ import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; import claudeapiProviderIconUrl from "@/assets/provider-icons/claudeapi.png"; import code0ProviderIconUrl from "@/assets/provider-icons/code0.png"; import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; +import fennoProviderIconUrl from "@/assets/provider-icons/fenno.jpg"; import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; +import qiniuAiProviderIconUrl from "@/assets/provider-icons/qiniu-ai.png"; import runapiProviderIconUrl from "@/assets/provider-icons/runapi.jpg"; import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; import teamorouterProviderIconUrl from "@/assets/provider-icons/teamorouter.png"; @@ -324,6 +326,7 @@ export const providerPresetIconUrls: Record = { claudeapi: claudeapiProviderIconUrl, code0: code0ProviderIconUrl, deepseek: deepseekProviderIconUrl, + fenno: fennoProviderIconUrl, gemini: geminiProviderIconUrl, "kimi-coding": moonshotProviderIconUrl, mistral: mistralProviderIconUrl, @@ -331,6 +334,7 @@ export const providerPresetIconUrls: Record = { "moonshot-global": moonshotProviderIconUrl, openai: openaiProviderIconUrl, openrouter: openrouterProviderIconUrl, + "qiniu-ai": qiniuAiProviderIconUrl, runapi: runapiProviderIconUrl, siliconflow: siliconflowProviderIconUrl, teamorouter: teamorouterProviderIconUrl, diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index 49f28855..ac1d6b9b 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -1716,10 +1716,11 @@ export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeC const preset = findProviderPreset(draft.presetId); const protocols = providerProtocolOptions.map((option) => option.value); if (preset) { + const probeAllProtocols = preset.endpoints.length === 1; return preset.endpoints.map((endpoint) => ({ ...endpoint, declaredProtocols: endpoint.protocols, - protocols, + protocols: probeAllProtocols ? protocols : endpoint.protocols, source: "preset" })); } diff --git a/tests/main/provider-preset-utils.test.mjs b/tests/main/provider-preset-utils.test.mjs index 9877cd23..062f72e5 100644 --- a/tests/main/provider-preset-utils.test.mjs +++ b/tests/main/provider-preset-utils.test.mjs @@ -8,10 +8,16 @@ import { providerIdentitySafetyIssueInList, providerPresetMatchesBaseUrl } from "../../packages/core/src/providers/presets/utils.ts"; +import { + fennoProviderPreset +} from "../../packages/core/src/providers/presets/fenno/index.ts"; import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "../../packages/core/src/providers/presets/moonshot/index.ts"; +import { + qiniuAiProviderPreset +} from "../../packages/core/src/providers/presets/qiniu-ai/index.ts"; const openAiPreset = { aliases: ["OpenAI", "ChatGPT"], @@ -57,6 +63,30 @@ test("provider identity lookup prefers exact Kimi regional names over shared ali assert.equal(findProviderPresetByIdentityInList(moonshotPresets, "Kimi API (China)")?.id, "moonshot"); }); +test("sponsor provider presets expose requested endpoints and protocols", () => { + assert.equal(fennoProviderPreset.websiteUrl, "https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=9HHHAB5QLAES"); + assert.deepEqual(fennoProviderPreset.endpoints[0]?.protocols, [ + "openai_chat_completions", + "openai_responses", + "anthropic_messages" + ]); + + assert.equal(qiniuAiProviderPreset.websiteUrl, "https://s.qiniu.com/AVjMVf"); + assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.qnaigc.com"), true); + assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.modelink.ai/v1/models"), false); + assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.qnaigc.com/bypass/openai/v1/responses"), true); + assert.equal(providerPresetMatchesBaseUrl(qiniuAiProviderPreset, "https://api.qnaigc.com/bypass/vertex/v1/models/gemini-pro:generateContent"), true); + assert.deepEqual(qiniuAiProviderPreset.endpoints.map((endpoint) => [endpoint.label, endpoint.baseUrl, endpoint.protocols]), [ + ["China mainland OpenAI", "https://api.qnaigc.com", ["openai_chat_completions"]], + ["China mainland OpenAI Responses", "https://api.qnaigc.com/bypass/openai/v1", ["openai_responses"]], + ["China mainland Anthropic", "https://api.qnaigc.com", ["anthropic_messages"]], + ["China mainland Gemini Generate", "https://api.qnaigc.com/bypass/vertex/v1", ["gemini_generate_content"]] + ]); + assert.deepEqual(qiniuAiProviderPreset.endpoints[0]?.protocols, [ + "openai_chat_completions" + ]); +}); + test("provider identity safety does not block branded third-party endpoints", () => { assert.equal( providerIdentitySafetyIssueInList(presets, { diff --git a/tests/main/provider-url.test.mjs b/tests/main/provider-url.test.mjs index 6270779e..e9bf3744 100644 --- a/tests/main/provider-url.test.mjs +++ b/tests/main/provider-url.test.mjs @@ -27,6 +27,18 @@ test("provider URL parsing handles local and Gemini endpoint variants", () => { assert.equal(parsed.geminiBaseUrl, "http://localhost:8787"); }); +test("provider URL parsing preserves versioned Vertex bypass bases for Gemini", () => { + const parsed = parseProviderBaseUrl("https://api.qnaigc.com/bypass/vertex/v1/models/gemini-pro:generateContent"); + + assert.equal(parsed.normalizedInputBaseUrl, "https://api.qnaigc.com/bypass/vertex/v1"); + assert.equal(parsed.rootBaseUrl, "https://api.qnaigc.com/bypass/vertex"); + assert.equal(parsed.geminiBaseUrl, "https://api.qnaigc.com/bypass/vertex/v1"); + assert.equal( + normalizeProviderBaseUrl("https://api.qnaigc.com/bypass/vertex/v1", "gemini_generate_content"), + "https://api.qnaigc.com/bypass/vertex/v1" + ); +}); + test("provider URL parsing handles Gemini Interactions endpoint variants", () => { const parsed = parseProviderBaseUrl("localhost:8787/v1/interactions/interaction-123/cancel"); diff --git a/tests/renderer/providers.test.ts b/tests/renderer/providers.test.ts index 324e27f5..6f0656cc 100644 --- a/tests/renderer/providers.test.ts +++ b/tests/renderer/providers.test.ts @@ -5,7 +5,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts"; import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts"; import { moonshotGlobalProviderPreset } from "../../packages/core/src/providers/presets/moonshot/index.ts"; -import { ProvidersView } from "../../packages/ui/src/pages/home/components/providers.tsx"; +import { qiniuAiProviderPreset } from "../../packages/core/src/providers/presets/qiniu-ai/index.ts"; +import { AddProviderDialog, ProvidersView } from "../../packages/ui/src/pages/home/components/providers.tsx"; import { applyProviderProbeResult, createProviderConfigFromDeepLink, @@ -40,6 +41,22 @@ test("Gemini preset keeps full protocol probing candidates", () => { assert.deepEqual(candidates[0].declaredProtocols, ["gemini_generate_content", "gemini_interactions"]); }); +test("multi-endpoint presets probe only each endpoint's declared protocols", () => { + setProviderPresets([qiniuAiProviderPreset]); + const draft = { + ...createProviderDraft([]), + presetId: "qiniu-ai" + }; + + const candidates = providerProbeCandidates(draft); + + assert.equal(candidates.length, qiniuAiProviderPreset.endpoints.length); + assert.deepEqual( + candidates.map((candidate) => [candidate.baseUrl, candidate.protocols]), + qiniuAiProviderPreset.endpoints.map((endpoint) => [endpoint.baseUrl, endpoint.protocols]) + ); +}); + test("provider probe result drops unavailable selected protocols", () => { const draft = { ...createProviderDraft([]), @@ -90,6 +107,63 @@ test("provider probe result keeps only supported selected protocols", () => { assert.deepEqual(next.selectedProtocols, ["gemini_generate_content"]); }); +test("provider protocol details keep failed endpoint rows unavailable", () => { + const draft = { + ...createProviderDraft([]), + baseUrl: "https://api.example.com/v1", + name: "Example", + protocol: "openai_chat_completions" as const, + selectedProtocols: ["openai_chat_completions" as const] + }; + const html = renderToStaticMarkup( + React.createElement(AddProviderDialog, { + canSubmit: true, + draft, + error: "", + mode: "edit", + onChange: () => undefined, + onClose: () => undefined, + onSubmit: async () => true, + probe: { + capabilities: [ + { + baseUrl: "https://api.example.com/v1", + endpoint: "https://api.example.com/v1/chat/completions", + source: "detected" as const, + type: "openai_chat_completions" as const + } + ], + detectedProtocol: "openai_chat_completions" as const, + models: [], + normalizedBaseUrl: "https://api.example.com/v1", + protocols: [ + { + baseUrl: "https://api.example.com", + endpoint: "https://api.example.com/chat/completions", + message: "HTTP 404", + protocol: "openai_chat_completions" as const, + status: 404, + supported: false + }, + { + baseUrl: "https://api.example.com/v1", + endpoint: "https://api.example.com/v1/chat/completions", + message: "HTTP 400: model is required", + protocol: "openai_chat_completions" as const, + status: 400, + supported: true + } + ] + }, + probeLoading: false, + providers: [] + }) + ); + + assert.match(html, /Unavailable/); + assert.match(html, /Available/); +}); + test("provider probe result applies detected New API key quota account connector", () => { const draft = { ...createProviderDraft([]), From 9cd0aab309c696e2e080112bfa0c82031de3d832 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 10 Jul 2026 23:26:33 +0800 Subject: [PATCH 03/38] chore: release 3.0.11 --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a22cc47..4bbb5fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-router-monorepo", - "version": "3.0.10", + "version": "3.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-router-monorepo", - "version": "3.0.10", + "version": "3.0.11", "license": "MIT", "workspaces": [ "packages/*" @@ -9542,7 +9542,7 @@ }, "packages/cli": { "name": "@musistudio/claude-code-router", - "version": "3.0.2", + "version": "3.0.3", "license": "MIT", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.4", @@ -9560,7 +9560,7 @@ }, "packages/core": { "name": "@claude-code-router/core", - "version": "3.0.2", + "version": "3.0.3", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.4", "@the-next-ai/bot-gateway-sdk": "^0.1.0", @@ -9578,7 +9578,7 @@ }, "packages/electron": { "name": "@claude-code-router/electron", - "version": "3.0.10", + "version": "3.0.11", "dependencies": { "better-sqlite3": "^12.11.1" }, @@ -9588,7 +9588,7 @@ }, "packages/ui": { "name": "@claude-code-router/ui", - "version": "3.0.10", + "version": "3.0.11", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 605606f8..9b218e18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-router-monorepo", - "version": "3.0.10", + "version": "3.0.11", "private": true, "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", diff --git a/packages/cli/package.json b/packages/cli/package.json index 723d03dc..ef8d9215 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@musistudio/claude-code-router", - "version": "3.0.2", + "version": "3.0.3", "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", "repository": { diff --git a/packages/core/package.json b/packages/core/package.json index ce6ce8dd..91f6862c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/core", - "version": "3.0.2", + "version": "3.0.3", "private": true, "description": "Claude Code Router core gateway, routing, provider, and storage services.", "main": "dist/main/server.js", diff --git a/packages/electron/package.json b/packages/electron/package.json index 9a737cb1..1e55555f 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/electron", - "version": "3.0.10", + "version": "3.0.11", "private": true, "description": "Claude Code Router Electron desktop shell.", "main": "dist/main/main.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 3208c307..609fab2c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/ui", - "version": "3.0.10", + "version": "3.0.11", "private": true, "description": "Claude Code Router web management UI.", "dependencies": { From 1389ff5c75c3b6c7be9858a25071b5b6ced58037 Mon Sep 17 00:00:00 2001 From: jesieleo <90036937+jesieleo@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:41:06 +0800 Subject: [PATCH 04/38] fix: improve Windows agent launching and update status --- packages/core/src/agents/codex/app-launch.ts | 4 + .../agents/codex/cli-middleware-runtime.ts | 88 ++++++++++++++++++- .../src/platform/windows-app-discovery.ts | 55 ++++++++++++ packages/core/src/profiles/launch-service.ts | 47 ++++++++-- packages/core/src/profiles/service.ts | 16 ++-- packages/electron/src/main/update-service.ts | 7 +- .../ui/src/pages/home/components/update.tsx | 22 +++-- packages/ui/src/pages/home/shared/i18n.tsx | 1 + .../codex-cli-middleware-runtime.test.mjs | 57 ++++++++++++ 9 files changed, 272 insertions(+), 25 deletions(-) diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index 6db6a46a..316f2093 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -161,6 +161,10 @@ export function findInstalledCodexAppExecutable(profileAppPath?: string): CodexA return findInstalledCodexCompatibleAppExecutable(codexAppSpec, profileAppPath); } +export function findInstalledZcodeAppExecutable(profileAppPath?: string): CodexAppLookupResult { + return findInstalledCodexCompatibleAppExecutable(zcodeAppSpec, profileAppPath); +} + export function launchZcodeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult { return launchCodexCompatibleAppProfile(configDir, profile, zcodeAppSpec, config); } diff --git a/packages/core/src/agents/codex/cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts index 1da08f60..74ddc638 100644 --- a/packages/core/src/agents/codex/cli-middleware-runtime.ts +++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts @@ -76,7 +76,7 @@ function botBridge() { } async function main() { - const args = process.argv.slice(2); + const args = directProfileDispatchArgs(process.argv.slice(2)); if (process.env.CCR_CLAUDE_CODE_BOT_WORKER === "1" || args[0] === "claude-bot-worker") { await runClaudeCodeBotWorker(args); return; @@ -92,6 +92,20 @@ async function main() { await runCodexCliMiddleware(args.length === 0 ? defaultCodexArgs() : args); } +function directProfileDispatchArgs(args) { + if (process.env.CCR_CLI_DIRECT_PROFILE_DISPATCH !== "1") { + return args; + } + const forwarded = args.slice(1); + if (forwarded[0] === "cli" || forwarded[0] === "--cli") { + forwarded.shift(); + } + if (forwarded[0] === "--") { + forwarded.shift(); + } + return forwarded; +} + async function runClaudeCodeCliWrapper(args) { const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude"); const realArgs = claudeCodeCliWrapperArgs(args); @@ -104,7 +118,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, realArgs, { + const child = spawnAgentCli(realCli, realArgs, { env: { ...withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]), ...claudeCodeUtcTimezoneEnvOverride() @@ -127,6 +141,7 @@ async function runClaudeCodeCliWrapper(args) { }); child.on("error", (error) => { log("claude_code_wrapper_spawn_error", { error: formatError(error) }); + process.stderr.write("Failed to start " + realCli + ": " + formatError(error) + "\n"); remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" }); }); let pending = ""; @@ -290,13 +305,14 @@ async function runCodexCliMiddleware(args) { } const cleanupAuthBootstrap = createEphemeralCodexApiKeyBootstrap(runtimeAgent); - const child = childProcess.spawn(realCli, realArgs, { + const child = spawnAgentCli(realCli, realArgs, { env: childEnvForAgent(runtimeAgent), stdio: ["pipe", "pipe", "inherit"] }); child.on("error", (error) => { cleanupAuthBootstrap(); log("codex_cli_spawn_error", { error: formatError(error) }); + process.stderr.write("Failed to start " + realCli + ": " + formatError(error) + "\n"); }); const requestMap = new Map(); @@ -389,12 +405,13 @@ function createEphemeralCodexApiKeyBootstrap(runtimeAgent) { async function runDirectCodexCli(realCli, realArgs) { const runtimeAgent = codexRuntimeAgent(); - const child = childProcess.spawn(realCli, realArgs, { + const child = spawnAgentCli(realCli, realArgs, { env: childEnvForAgent(runtimeAgent), stdio: "inherit" }); child.on("error", (error) => { log("codex_cli_spawn_error", { error: formatError(error) }); + process.stderr.write("Failed to start " + realCli + ": " + formatError(error) + "\n"); }); const exit = await waitForChildResult(child); log("codex_cli_exit", { code: exit.code, signal: exit.signal, exitCode: exit.exitCode }); @@ -405,6 +422,69 @@ function shouldRunDirectCodexCli(args) { return codexPositionalArgs(args)[0] !== "app-server"; } +function spawnAgentCli(command, args, options) { + if (process.platform !== "win32") { + return childProcess.spawn(command, args, options); + } + + const commandFile = resolveWindowsCommandFile(command, options && options.env); + if (commandFile && /\.(?:com|exe)$/i.test(commandFile)) { + return childProcess.spawn(commandFile, args, options); + } + + const shellCommand = [escapeWindowsCmdCommand(commandFile || command)] + .concat(args.map(escapeWindowsCmdArgument)) + .join(" "); + return childProcess.spawn( + process.env.ComSpec || process.env.COMSPEC || "cmd.exe", + ["/d", "/s", "/c", '"' + shellCommand + '"'], + { ...options, windowsVerbatimArguments: true } + ); +} + +function resolveWindowsCommandFile(command, env) { + const value = String(command || "").trim().replace(/^"|"$/g, ""); + if (!value) return ""; + const commandExt = path.extname(value); + const pathExt = String((env && (env.PATHEXT || env.Pathext)) || process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD") + .split(";") + .map((extension) => extension.trim()) + .filter(Boolean); + const extensions = commandExt ? [""] : ["", ...pathExt]; + const hasPath = path.isAbsolute(value) || value.includes("\\") || value.includes("/"); + const directories = hasPath + ? [""] + : String((env && (env.PATH || env.Path)) || process.env.PATH || "") + .split(path.delimiter) + .map((directory) => directory.replace(/^"|"$/g, "")) + .filter(Boolean); + + for (const directory of directories) { + for (const extension of extensions) { + const candidate = directory ? path.join(directory, value + extension) : value + extension; + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + } + } + } + return ""; +} + +const WINDOWS_CMD_META_CHARS = /([()\][%!^"\`<>&|;, *?])/g; + +function escapeWindowsCmdCommand(value) { + return String(value).replace(WINDOWS_CMD_META_CHARS, "^$1"); +} + +function escapeWindowsCmdArgument(value) { + let escaped = String(value); + escaped = escaped.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\""); + escaped = escaped.replace(/(?=(\\+?)?)\1$/g, "$1$1"); + escaped = '"' + escaped + '"'; + return escaped.replace(WINDOWS_CMD_META_CHARS, "^$1"); +} + function realCliArgs(profile, modelProvider, configFormat, args) { const realArgs = []; if (profile) { diff --git a/packages/core/src/platform/windows-app-discovery.ts b/packages/core/src/platform/windows-app-discovery.ts index 827e6f77..3e9c00c9 100644 --- a/packages/core/src/platform/windows-app-discovery.ts +++ b/packages/core/src/platform/windows-app-discovery.ts @@ -51,6 +51,9 @@ export function windowsDesktopAppCandidates(options: WindowsDesktopAppDiscoveryO for (const candidate of windowsAppExecutionAliasCandidates(options)) { pushUnique(candidates, candidate); } + for (const candidate of windowsShortcutTargetCandidates(options)) { + pushUnique(candidates, candidate); + } for (const candidate of windowsMsixPackageCandidates(options)) { pushUnique(candidates, candidate); } @@ -60,6 +63,58 @@ export function windowsDesktopAppCandidates(options: WindowsDesktopAppDiscoveryO return candidates; } +function windowsShortcutTargetCandidates(options: WindowsDesktopAppDiscoveryOptions): string[] { + const names = unique([ + ...options.packageKeywords, + ...options.appDirs, + ...options.exeNames.map((name) => path.basename(name, path.extname(name))) + ].map((name) => name.trim()).filter(Boolean)); + if (names.length === 0) { + return []; + } + + const pattern = names.map(escapeRegExp).join("|"); + const script = [ + "$ErrorActionPreference = 'SilentlyContinue';", + "$roots = @(", + " [Environment]::GetFolderPath('Programs'),", + " [Environment]::GetFolderPath('CommonPrograms'),", + " [Environment]::GetFolderPath('Desktop'),", + " [Environment]::GetFolderPath('CommonDesktopDirectory')", + ") | Where-Object { $_ } | Select-Object -Unique;", + `$pattern = '${powerShellSingleQuotedString(pattern)}';`, + "$shell = New-Object -ComObject WScript.Shell;", + "Get-ChildItem -LiteralPath $roots -Filter '*.lnk' -File -Recurse |", + " Where-Object { $_.BaseName -match $pattern } |", + " ForEach-Object {", + " try {", + " $target = $shell.CreateShortcut($_.FullName).TargetPath;", + " if ($target) { $target }", + " } catch {}", + " }" + ].join(" "); + + const result = spawnSync(windowsSystemCommand("powershell.exe"), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script + ], { + encoding: "utf8", + windowsHide: true + }); + if (result.status !== 0) { + return []; + } + + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + export function normalizeWindowsDesktopAppCandidate( candidate: string, options: WindowsDesktopAppNormalizeOptions diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index 17cd4d57..d84425f1 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -12,7 +12,7 @@ import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-midd import { CONFIGDIR } from "@ccr/core/config/constants"; import { gatewayService } from "@ccr/core/gateway/service"; import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config"; -import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; +import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service"; import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "@ccr/core/platform/windows-system"; @@ -56,7 +56,7 @@ export async function getProfileOpenCommand(config: AppConfig, request: ProfileO const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); if (options.ensureLauncher) { - ensureCcrCliLauncher(); + ensureCcrCliLauncher(config); } return { command: profileOpenCommand(profile, surface, options.commandName ?? "ccr", commandProfileRef(config, profile)), @@ -1090,7 +1090,7 @@ function commandProfileRef(config: AppConfig, profile: ReturnType 0 + ? [ + "if /I \"%~2\"==\"app\" goto ccr_run_cli", + "if /I \"%~2\"==\"--app\" goto ccr_run_cli", + ...dispatches.map((dispatch, index) => `if /I \"%~1\"==\"${cmdValue(dispatch.profileRef)}\" goto ccr_profile_${index}`), + ":ccr_run_cli" + ] + : []), `set "${desktopCliCommandNameEnv}=${desktopCliCommandName}"`, `set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`, `set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`, @@ -1202,10 +1211,36 @@ function windowsCcrLauncher(runtimeFile: string): string { ")", "set \"ELECTRON_RUN_AS_NODE=1\"", `${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`, - "exit /b %ERRORLEVEL%" + "exit /b %ERRORLEVEL%", + ...dispatches.flatMap((dispatch, index) => [ + `:ccr_profile_${index}`, + "set \"CCR_CLI_DIRECT_PROFILE_DISPATCH=1\"", + `call ${cmdQuote(dispatch.launcher)} %*`, + "exit /b %ERRORLEVEL%" + ]) ].join("\r\n") + "\r\n"; } +function windowsProfileCliDispatches(config: AppConfig): Array<{ launcher: string; profileRef: string }> { + const dispatches: Array<{ launcher: string; profileRef: string }> = []; + const refs = new Set(); + for (const profile of config.profile.profiles) { + if (!profile.enabled || !profileOpenSurfaces(profile).includes("cli")) { + continue; + } + const launcher = buildProfileLaunchPlan(CONFIGDIR, profile, "cli").command; + for (const profileRef of uniqueStrings([commandProfileRef(config, profile), profile.id])) { + const normalized = profileRef.trim().toLowerCase(); + if (!normalized || refs.has(normalized)) { + continue; + } + refs.add(normalized); + dispatches.push({ launcher, profileRef }); + } + } + return dispatches; +} + function bundledNodePath(): string { const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; const candidates = [ diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 7376260b..efcc84bf 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -1027,15 +1027,15 @@ function nodeRuntimeCmdExecLines(runtimeFile: string): string[] { const quotedRuntime = cmdQuote(runtimeFile); const quotedHost = cmdQuote(process.execPath); return [ - "if defined CCR_NODE_BIN (", - ` "%CCR_NODE_BIN%" ${quotedRuntime} %*`, - " exit /b %ERRORLEVEL%", - ")", + "if not defined CCR_NODE_BIN goto ccr_try_system_node", + `"%CCR_NODE_BIN%" ${quotedRuntime} %*`, + "exit /b %ERRORLEVEL%", + ":ccr_try_system_node", "where node >nul 2>nul", - "if %ERRORLEVEL%==0 (", - ` node ${quotedRuntime} %*`, - " exit /b %ERRORLEVEL%", - ")", + "if errorlevel 1 goto ccr_use_electron_node", + `node ${quotedRuntime} %*`, + "exit /b %ERRORLEVEL%", + ":ccr_use_electron_node", "set \"ELECTRON_RUN_AS_NODE=1\"", `${quotedHost} ${quotedRuntime} %*`, "exit /b %ERRORLEVEL%" diff --git a/packages/electron/src/main/update-service.ts b/packages/electron/src/main/update-service.ts index a507e606..9d1c3ba6 100644 --- a/packages/electron/src/main/update-service.ts +++ b/packages/electron/src/main/update-service.ts @@ -10,9 +10,11 @@ type UpdateCheckOptions = { }; const startupCheckDelayMs = 12_000; +const defaultUpdateSource = "GitHub Releases (musistudio/claude-code-router)"; class AppUpdateService { private activeSilentCheckFailureRestoreStatus?: AppUpdateStatus; + private configuredUpdateSource = defaultUpdateSource; private initialized = false; private installingUpdate = false; private prepareInstall?: InstallPreparation; @@ -34,7 +36,7 @@ class AppUpdateService { this.initialized = true; this.configureUpdater(); this.registerUpdaterEvents(); - this.publishStatus({ feedUrl: autoUpdater.getFeedURL() || undefined }); + this.publishStatus({ feedUrl: this.configuredUpdateSource }); if (this.isUpdaterSupported()) { this.queueStartupCheck(); @@ -177,6 +179,7 @@ class AppUpdateService { private configureUpdater(): void { const feedUrl = readEnvString("CCR_UPDATE_FEED_URL"); if (feedUrl) { + this.configuredUpdateSource = feedUrl; autoUpdater.setFeedURL({ provider: "generic", url: feedUrl @@ -304,7 +307,7 @@ class AppUpdateService { ...this.status, ...patch, currentVersion: app.getVersion(), - feedUrl: autoUpdater.getFeedURL() || this.status.feedUrl, + feedUrl: this.configuredUpdateSource, supported: this.isUpdaterSupported() }); windowsManager.broadcast(IPC_CHANNELS.appUpdateStatusChanged, this.status); diff --git a/packages/ui/src/pages/home/components/update.tsx b/packages/ui/src/pages/home/components/update.tsx index a8fba2bf..87af7b66 100644 --- a/packages/ui/src/pages/home/components/update.tsx +++ b/packages/ui/src/pages/home/components/update.tsx @@ -50,14 +50,20 @@ export function UpdateDialog({
{updateStateLabel(status, t)}
{updateStateDescription(status, t)}
- +
- + - +
{status.state === "downloading" ? ( @@ -129,11 +135,17 @@ export function UpdateDialog({ ); } -function UpdateInfoRow({ label, value }: { label: string; value: string }) { +function UpdateInfoRow({ label, scroll = false, value }: { label: string; scroll?: boolean; value: string }) { return (
{label}
-
{value}
+
+ {value} +
); } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 91fea4ac..45bab28a 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -1688,6 +1688,7 @@ export const appCopy: Record = { "Update downloaded": "更新已下载", "Update downloaded. Restart to finish updating.": "更新已下载,请重启应用以完成版本更新。", "Update failed": "更新失败", + "Update check complete": "检查完成", "Update ready to install": "更新已准备安装", "Updates are only available in packaged builds.": "在线更新仅在打包后的应用中可用。", "After deletion, this bot data cannot be recovered.": "删除后数据不可恢复。", diff --git a/tests/main/codex-cli-middleware-runtime.test.mjs b/tests/main/codex-cli-middleware-runtime.test.mjs index cc4675b8..0cbf3cfa 100644 --- a/tests/main/codex-cli-middleware-runtime.test.mjs +++ b/tests/main/codex-cli-middleware-runtime.test.mjs @@ -13,6 +13,63 @@ test("generated Codex CLI middleware runtime is valid JavaScript", () => { execFileSync(process.execPath, ["--check", file], { stdio: "pipe" }); }); +test("Codex CLI middleware launches Windows cmd shims", { skip: process.platform !== "win32" }, () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-windows-cmd-")); + const runtimeFile = writeRuntimeScript(dir); + const fakeCliScript = path.join(dir, "fake-codex.js"); + const fakeCli = path.join(dir, "fake-codex.cmd"); + writeFileSync(fakeCliScript, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + writeFileSync(fakeCli, [ + "@echo off", + `"${process.execPath}" "%~dp0fake-codex.js" %*`, + "exit /b %ERRORLEVEL%", + "" + ].join("\r\n")); + + const result = spawnSync(process.execPath, [runtimeFile, "--version"], { + encoding: "utf8", + env: { + ...process.env, + CCR_CODEX_MODEL_PROVIDER: "claude-code-router", + CCR_CODEX_PROFILE: "claude-code-router", + CCR_REAL_CODEX_CLI_PATH: fakeCli + } + }); + + assert.equal(result.status, 0, result.stderr); + const forwardedArgs = JSON.parse(result.stdout); + assert.equal(forwardedArgs.at(-1), "--version"); + assert.equal(forwardedArgs.includes("claude-code-router"), true); +}); + +test("Windows direct profile dispatch strips the profile command arguments", { skip: process.platform !== "win32" }, () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-windows-dispatch-")); + const runtimeFile = writeRuntimeScript(dir); + const fakeCliScript = path.join(dir, "fake-claude.js"); + const fakeCli = path.join(dir, "fake-claude.cmd"); + writeFileSync(fakeCliScript, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + writeFileSync(fakeCli, [ + "@echo off", + `"${process.execPath}" "%~dp0fake-claude.js" %*`, + "exit /b %ERRORLEVEL%", + "" + ].join("\r\n")); + + const result = spawnSync(process.execPath, [runtimeFile, "Claude Code", "cli", "--", "--version"], { + encoding: "utf8", + env: { + ...process.env, + CCR_CLAUDE_CODE_WRAPPER: "1", + CCR_CLI_DIRECT_PROFILE_DISPATCH: "1", + CCR_REAL_CLAUDE_CODE_BIN: fakeCli, + CCR_REMOTE_SYNC_ENABLED: "0" + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), ["--version"]); +}); + test("Codex app-server exposes a ChatGPT-shaped workspace identity without credentials", { skip: process.platform === "win32" }, () => { const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-virtual-auth-")); const runtimeFile = writeRuntimeScript(dir); From faa721fed8e9d0207e05d439a0f357c8832ded7d Mon Sep 17 00:00:00 2001 From: jesieleo <90036937+jesieleo@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:07:46 +0800 Subject: [PATCH 05/38] fix: preserve profile preparation before TTY dispatch --- packages/cli/src/cli.ts | 4 +++ packages/core/src/profiles/launch-service.ts | 24 ++++++++----- tests/main/windows-ccr-launcher.test.mjs | 37 ++++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 tests/main/windows-ccr-launcher.test.mjs diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index fe66d047..cb1867c9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -57,6 +57,7 @@ const serviceStopTimeoutMs = 10_000; const webAuthHeader = "x-ccr-web-auth"; const webAuthQueryParam = "ccr_web_token"; const defaultCliCommandName = "ccr"; +const prepareProfileOnlyEnv = "CCR_CLI_PREPARE_PROFILE_ONLY"; async function main(): Promise { const options = parseArgs(process.argv.slice(2)); @@ -123,6 +124,9 @@ async function main(): Promise { throw new Error(runtimeResult.message); } } + if (resolvedSurface === "cli" && process.env[prepareProfileOnlyEnv] === "1") { + return; + } if (profile.agent === "claude-code" && resolvedSurface === "app") { applyClaudeAppGatewayConfig(launchConfig); applyClaudeAppGatewayConfig(launchConfig, { diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index d84425f1..ff7b9a5a 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -1183,20 +1183,12 @@ function posixCcrLauncher(runtimeFile: string): string { ].join("\n") + "\n"; } -function windowsCcrLauncher(runtimeFile: string, config?: AppConfig): string { +export function windowsCcrLauncher(runtimeFile: string, config?: AppConfig): string { const nodePath = bundledNodePath(); const dispatches = config ? windowsProfileCliDispatches(config) : []; return [ "@echo off", "setlocal", - ...(dispatches.length > 0 - ? [ - "if /I \"%~2\"==\"app\" goto ccr_run_cli", - "if /I \"%~2\"==\"--app\" goto ccr_run_cli", - ...dispatches.map((dispatch, index) => `if /I \"%~1\"==\"${cmdValue(dispatch.profileRef)}\" goto ccr_profile_${index}`), - ":ccr_run_cli" - ] - : []), `set "${desktopCliCommandNameEnv}=${desktopCliCommandName}"`, `set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`, `set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`, @@ -1205,6 +1197,14 @@ function windowsCcrLauncher(runtimeFile: string, config?: AppConfig): string { ") else (", " set \"NODE_PATH=%CCR_CLI_NODE_PATH%\"", ")", + ...(dispatches.length > 0 + ? [ + "if /I \"%~2\"==\"app\" goto ccr_run_cli", + "if /I \"%~2\"==\"--app\" goto ccr_run_cli", + ...dispatches.map((dispatch, index) => `if /I \"%~1\"==\"${cmdValue(dispatch.profileRef)}\" goto ccr_profile_${index}`), + ":ccr_run_cli" + ] + : []), "if defined CCR_NODE_BIN (", ' "%CCR_NODE_BIN%" "%CCR_CLI_RUNTIME%" %*', " exit /b %ERRORLEVEL%", @@ -1214,6 +1214,12 @@ function windowsCcrLauncher(runtimeFile: string, config?: AppConfig): string { "exit /b %ERRORLEVEL%", ...dispatches.flatMap((dispatch, index) => [ `:ccr_profile_${index}`, + "set \"CCR_CLI_PREPARE_PROFILE_ONLY=1\"", + "set \"ELECTRON_RUN_AS_NODE=1\"", + `${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`, + "if errorlevel 1 exit /b %ERRORLEVEL%", + "set \"CCR_CLI_PREPARE_PROFILE_ONLY=\"", + "set \"ELECTRON_RUN_AS_NODE=\"", "set \"CCR_CLI_DIRECT_PROFILE_DISPATCH=1\"", `call ${cmdQuote(dispatch.launcher)} %*`, "exit /b %ERRORLEVEL%" diff --git a/tests/main/windows-ccr-launcher.test.mjs b/tests/main/windows-ccr-launcher.test.mjs new file mode 100644 index 00000000..a050bb43 --- /dev/null +++ b/tests/main/windows-ccr-launcher.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { windowsCcrLauncher } from "../../packages/core/src/profiles/launch-service.ts"; + +test("Windows CCR launcher prepares CLI profiles before direct TTY dispatch", { skip: process.platform !== "win32" }, () => { + const config = { + profile: { + profiles: [ + { + agent: "claude-code", + enabled: true, + id: "claude-main", + model: "provider/model", + name: "Claude Main", + scope: "ccr", + surface: "cli" + } + ] + } + }; + const runtimeFile = path.join("C:\\CCR", "ccr-cli.js"); + const launcher = windowsCcrLauncher(runtimeFile, config); + + assert.match(launcher, /if \/I "%~1"=="Claude Main" goto ccr_profile_0/); + assert.match(launcher, /set "CCR_CLI_PREPARE_PROFILE_ONLY=1"/); + assert.match(launcher, /set "ELECTRON_RUN_AS_NODE=1"/); + assert.match(launcher, /set "CCR_CLI_DIRECT_PROFILE_DISPATCH=1"/); + assert.match(launcher, /call ".*ccr-claude-code-wrapper-claude-main\.cmd" %\*/); + + const prepareIndex = launcher.indexOf('set "CCR_CLI_PREPARE_PROFILE_ONLY=1"'); + const directDispatchIndex = launcher.indexOf('set "CCR_CLI_DIRECT_PROFILE_DISPATCH=1"'); + const wrapperIndex = launcher.indexOf("ccr-claude-code-wrapper-claude-main.cmd"); + assert.equal(prepareIndex >= 0, true); + assert.equal(directDispatchIndex > prepareIndex, true); + assert.equal(wrapperIndex > directDispatchIndex, true); +}); From 57b1169fffba51667a731081aebf6d57aa187ee2 Mon Sep 17 00:00:00 2001 From: jesieleo <90036937+jesieleo@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:52:53 +0800 Subject: [PATCH 06/38] fix: improve startup and restore global profiles --- packages/core/src/platform/windows-system.ts | 16 +- packages/core/src/profiles/launch-service.ts | 53 ++++- packages/core/src/profiles/service.ts | 217 ++++++++++++++++-- packages/electron/src/main/main-app.ts | 39 +++- packages/electron/src/main/update-service.ts | 2 +- .../ui/src/pages/home/components/update.tsx | 19 +- packages/ui/src/pages/home/shared/i18n.tsx | 1 + tests/main/profile-service.test.mjs | 86 ++++++- 8 files changed, 383 insertions(+), 50 deletions(-) diff --git a/packages/core/src/platform/windows-system.ts b/packages/core/src/platform/windows-system.ts index 094b71ca..800798b6 100644 --- a/packages/core/src/platform/windows-system.ts +++ b/packages/core/src/platform/windows-system.ts @@ -32,12 +32,7 @@ export function broadcastWindowsEnvironmentChanged(): void { return; } - const script = [ - "$signature = '[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);';", - "Add-Type -MemberDefinition $signature -Namespace Win32 -Name NativeMethods;", - "$result = [UIntPtr]::Zero;", - "[Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x2, 5000, [ref]$result) | Out-Null;" - ].join(" "); + const script = windowsEnvironmentChangedPowerShellLines().join(" "); spawnSync(windowsSystemCommand("powershell.exe"), [ "-NoProfile", @@ -51,3 +46,12 @@ export function broadcastWindowsEnvironmentChanged(): void { windowsHide: true }); } + +export function windowsEnvironmentChangedPowerShellLines(): string[] { + return [ + "$signature = '[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);';", + "Add-Type -MemberDefinition $signature -Namespace Win32 -Name NativeMethods;", + "$result = [UIntPtr]::Zero;", + "[Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x2, 5000, [ref]$result) | Out-Null;" + ]; +} diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index ff7b9a5a..55e8153c 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -14,7 +14,7 @@ import { gatewayService } from "@ccr/core/gateway/service"; import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config"; import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service"; -import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "@ccr/core/platform/windows-system"; +import { windowsEnvironmentChangedPowerShellLines, windowsSystemCommand } from "@ccr/core/platform/windows-system"; const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>"; const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<"; @@ -29,6 +29,15 @@ type ProfileOpenCommandOptions = { ensureLauncher?: boolean; }; +export type CcrCliLauncherPreparation = { + binDir: string; + persistentPathRequired: boolean; +}; + +type EnsureCcrCliLauncherOptions = { + persistPath?: boolean; +}; + type ProfileAppLaunchResult = { child: ChildProcess; claudeDesignProxy?: boolean; @@ -1090,8 +1099,9 @@ function commandProfileRef(config: AppConfig, profile: ReturnType key.toLowerCase() === "path") || "Path" + : "PATH"; + return pathSegmentsInclude((process.env[pathKey] || "").split(path.delimiter).filter(Boolean), binDir); +} + function prependProcessPath(binDir: string): void { const pathKey = process.platform === "win32" ? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path" @@ -1309,7 +1343,8 @@ function prependProcessPath(binDir: string): void { process.env[pathKey] = [binDir, ...segments].join(delimiter); } -function ensureWindowsUserPath(binDir: string): void { +function ensureWindowsUserPath(binDir: string): boolean { + const broadcastLines = windowsEnvironmentChangedPowerShellLines().map((line) => ` ${line}`); const script = [ "$ErrorActionPreference = 'Stop'", `$bin = ${powershellString(binDir)}`, @@ -1322,6 +1357,10 @@ function ensureWindowsUserPath(binDir: string): void { "$expandedSegments = $segments | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\\\') }", "if ($expandedSegments -notcontains $expandedBin) {", " [Environment]::SetEnvironmentVariable('Path', ((@($bin) + $segments) -join ';'), 'User')", + ...broadcastLines, + " Write-Output 'CHANGED'", + "} else {", + " Write-Output 'UNCHANGED'", "}" ].join("\n"); const result = spawnSync(windowsSystemCommand("powershell.exe"), [ @@ -1341,7 +1380,7 @@ function ensureWindowsUserPath(binDir: string): void { if (result.status !== 0) { throw new Error((result.stderr || result.stdout || `powershell.exe exited with ${result.status}`).trim()); } - broadcastWindowsEnvironmentChanged(); + return result.stdout.trim().split(/\r?\n/).includes("CHANGED"); } function ensurePosixShellPath(binDir: string): void { diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index efcc84bf..8773cb66 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -34,11 +34,23 @@ const managedToolHubMcpStart = "# BEGIN CCR managed ToolHub MCP"; const managedToolHubMcpEnd = "# END CCR managed ToolHub MCP"; const originalBackupSuffix = ".ccr-original"; const originalMissingSuffix = ".ccr-original-missing"; +const globalProfileTakeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json"); const fallbackClientToken = "ccr-local"; const privateDirMode = 0o700; const privateExecutableMode = 0o700; const privateFileMode = 0o600; const publicExecutableMode = 0o755; +let ownedGlobalProfileTakeovers: GlobalProfileTakeoverRecord[] | undefined; + +type GlobalProfileTakeoverRecord = { + agent: ProfileClientKind; + codexHome?: string; + configFile?: string; + id: string; + name: string; + providerId?: string; + settingsFile?: string; +}; export async function applyProfileConfig(config: AppConfig): Promise { cleanupGeneratedBinBackups(); @@ -49,9 +61,15 @@ export async function applyProfileConfig(config: AppConfig): Promise profile.enabled) }; + const takeoverStatuses = synchronizeGlobalProfileTakeovers( + profiles, + result.enabled && hasAvailableGatewayModels(config) + ); if (!result.enabled) { result.clients = profiles.map(disabledProfileStatus); + result.clients.push(...takeoverStatuses); + result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles)); return result; } @@ -76,6 +94,8 @@ export async function applyProfileConfig(config: AppConfig): Promise profile.agent === "codex"); + if (codexProfiles.length > 0 && !codexProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + for (const file of uniqueResolvedPaths([ + ...codexProfiles.map(globalCodexConfigCandidate) + ])) { + const restoreResult = restoreGlobalConfigFile(file, { + isManagedContent: (content) => isManagedCodexConfigContent(content, "claude-code-router"), + mode: privateFileMode + }); + if (restoreResult.changed || restoreResult.missingBackup) { + statuses.push(inactiveGlobalCleanupStatus("codex", file, restoreResult)); + } + } + } + const zcodeProfiles = profiles.filter((profile) => profile.agent === "zcode"); + if (zcodeProfiles.length > 0 && !zcodeProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + const providerIds = [...new Set([ + "claude-code-router", + ...zcodeProfiles.map((profile) => sanitizeCodexProviderId(profile.providerId || "")).filter(Boolean) + ])]; + const configFiles = uniqueResolvedPaths([ + ...zcodeProfiles.map((profile) => resolveZcodeConfigFile(profile)) + ]); + for (const configFile of configFiles) { + const storageRoot = zcodeHomeFromConfigFile(configFile); + for (const file of [ + configFile, + path.join(storageRoot, "v2", "config.json"), + path.join(storageRoot, "v2", "bots-model-cache.v2.json") + ]) { + const restoreResult = restoreGlobalConfigFile(file, { + isManagedContent: (content) => providerIds.some((providerId) => isManagedZcodeConfigContent(content, providerId)), + mode: privateFileMode + }); + if (restoreResult.changed || restoreResult.missingBackup) { + statuses.push(inactiveGlobalCleanupStatus("zcode", file, restoreResult)); + } + } + } + } return statuses; } +function globalCodexConfigCandidate(profile: ProfileConfig): string { + const codexHome = profile.codexHome?.trim(); + if (codexHome) { + return path.join(resolveUserPath(codexHome), "config.toml"); + } + return profile.configFile || "~/.codex/config.toml"; +} + +export function restoreGlobalProfileConfigsOnExit( + profiles: ProfileConfig[], + options: { manageMarker?: boolean } = {} +): ProfileClientApplyStatus[] { + const manageMarker = options.manageMarker !== false; + const records = dedupeGlobalProfileTakeovers([ + ...(manageMarker ? ownedGlobalProfileTakeovers ?? readGlobalProfileTakeoverMarker() : []), + ...globalProfileTakeoverRecords(profiles) + ]); + const statuses = restoreGlobalProfileTakeoverRecords(records); + if (manageMarker && statuses.every((status) => status.ok)) { + clearGlobalProfileTakeoverMarker(); + ownedGlobalProfileTakeovers = []; + } + return statuses; +} + +function synchronizeGlobalProfileTakeovers(profiles: ProfileConfig[], canTakeOver: boolean): ProfileClientApplyStatus[] { + const next = canTakeOver ? globalProfileTakeoverRecords(profiles) : []; + const previous = ownedGlobalProfileTakeovers ?? readGlobalProfileTakeoverMarker(); + if (ownedGlobalProfileTakeovers !== undefined && JSON.stringify(previous) === JSON.stringify(next)) { + return []; + } + + const statuses = previous.length > 0 ? restoreGlobalProfileTakeoverRecords(previous) : []; + const markerRecords = statuses.every((status) => status.ok) + ? next + : dedupeGlobalProfileTakeovers([...previous, ...next]); + if (markerRecords.length > 0) { + writeGlobalProfileTakeoverMarker(markerRecords); + } else { + clearGlobalProfileTakeoverMarker(); + } + ownedGlobalProfileTakeovers = markerRecords; + return statuses; +} + +function globalProfileTakeoverRecords(profiles: ProfileConfig[]): GlobalProfileTakeoverRecord[] { + return dedupeGlobalProfileTakeovers(profiles + .filter((profile) => profile.enabled && isGlobalProfile(profile)) + .map((profile) => ({ + agent: profile.agent, + codexHome: profile.codexHome?.trim() || undefined, + configFile: profile.configFile?.trim() || undefined, + id: profile.id, + name: profile.name, + providerId: profile.providerId?.trim() || undefined, + settingsFile: profile.settingsFile?.trim() || undefined + }))); +} + +function restoreGlobalProfileTakeoverRecords(records: GlobalProfileTakeoverRecord[]): ProfileClientApplyStatus[] { + return records.map((record) => disabledProfileStatus({ + ...record, + enabled: false, + env: {}, + model: "", + scope: "global", + surface: "auto" + })); +} + +function dedupeGlobalProfileTakeovers(records: GlobalProfileTakeoverRecord[]): GlobalProfileTakeoverRecord[] { + const seen = new Set(); + return records.filter((record) => { + const key = JSON.stringify(record); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function readGlobalProfileTakeoverMarker(): GlobalProfileTakeoverRecord[] { + try { + const parsed = JSON.parse(readFileSync(globalProfileTakeoverFile, "utf8")) as { profiles?: unknown }; + if (!Array.isArray(parsed.profiles)) { + return []; + } + return parsed.profiles.filter((value): value is GlobalProfileTakeoverRecord => + isRecord(value) && + (value.agent === "claude-code" || value.agent === "codex" || value.agent === "zcode") && + typeof value.id === "string" && + typeof value.name === "string" + ); + } catch { + return []; + } +} + +function writeGlobalProfileTakeoverMarker(records: GlobalProfileTakeoverRecord[]): void { + mkdirSync(path.dirname(globalProfileTakeoverFile), { recursive: true }); + writeFileSync(globalProfileTakeoverFile, `${JSON.stringify({ profiles: records, version: 1 }, null, 2)}\n`, { + encoding: "utf8", + mode: privateFileMode + }); +} + +function clearGlobalProfileTakeoverMarker(): void { + rmSync(globalProfileTakeoverFile, { force: true }); +} + function inactiveGlobalCleanupStatus( client: ProfileClientKind, file: string, @@ -1513,6 +1685,20 @@ function restoreGlobalConfigFile( return { changed: false, file, missingBackup: false, restored: false }; } + const snapshot = originalSnapshotCandidate(file, options.isManagedContent); + if (snapshot) { + if (current === snapshot.content) { + chmodFileIfRequested(file, options.mode); + return { changed: false, file, missingBackup: false, restored: true }; + } + + const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); + chmodFileIfRequested(file, options.mode); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; + } + if (existsSync(originalMissingFilePath(file))) { if (currentManaged) { const backupFile = backupCurrentConfigFile(file, options.mode); @@ -1522,33 +1708,22 @@ function restoreGlobalConfigFile( return { changed: false, file, missingBackup: false, restored: current === undefined }; } - const snapshot = originalSnapshotCandidate(file, options.isManagedContent); - if (!snapshot) { - return { - changed: false, - file, - missingBackup: Boolean(currentManaged), - restored: false - }; - } - - if (current === snapshot.content) { - chmodFileIfRequested(file, options.mode); - return { changed: false, file, missingBackup: false, restored: true }; - } - - const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode); - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); - chmodFileIfRequested(file, options.mode); - return { backupFile, changed: true, file, missingBackup: false, restored: true }; + return { + changed: false, + file, + missingBackup: Boolean(currentManaged), + restored: false + }; } function originalSnapshotCandidate( file: string, isManagedContent: (content: string) => boolean ): { content: string; file: string } | undefined { - for (const candidate of [originalBackupFilePath(file), ...backupFiles(file)]) { + // Prefer the most recent non-CCR snapshot captured immediately before the + // latest takeover. The permanent .ccr-original file can be stale when the + // user changes the agent config between separate CCR sessions. + for (const candidate of [...backupFiles(file).reverse(), originalBackupFilePath(file)]) { if (!existsSync(candidate)) { continue; } diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 9f3ab9a9..4cc37d15 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -5,8 +5,8 @@ import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "@ccr/ import { deepLinkService } from "./deep-link"; import { gatewayService } from "@ccr/core/gateway/service"; import "./ipc"; -import { applyProfileConfig } from "@ccr/core/profiles/service"; -import { ensureCcrCliLauncher } from "@ccr/core/profiles/launch-service"; +import { applyProfileConfig, restoreGlobalProfileConfigsOnExit } from "@ccr/core/profiles/service"; +import { ensureCcrCliLauncher, persistPreparedCcrCliPath, prepareCcrCliLauncherRuntime, type CcrCliLauncherPreparation } from "@ccr/core/profiles/launch-service"; import { syncLaunchAtLogin } from "./launch-at-login"; import { proxyService } from "@ccr/core/proxy/service"; import trayController from "./tray-controller"; @@ -43,13 +43,25 @@ function startPrimaryInstance(): void { void app.whenReady().then(() => { configureProxyDesktopIntegration(); + let ccrLauncherPreparation: CcrCliLauncherPreparation | undefined; try { - ensureCcrCliLauncher(); + ccrLauncherPreparation = prepareCcrCliLauncherRuntime(); } catch (error) { - console.error(`Failed to install ccr CLI launcher: ${formatError(error)}`); + console.error(`Failed to prepare ccr CLI runtime: ${formatError(error)}`); } setupApplicationMenu(); - windowsManager.createMainWindow(); + const mainWindow = windowsManager.createMainWindow(); + if (ccrLauncherPreparation?.persistentPathRequired) { + mainWindow.once("ready-to-show", () => { + setTimeout(() => { + try { + persistPreparedCcrCliPath(ccrLauncherPreparation); + } catch (error) { + console.error(`Failed to persist ccr CLI PATH: ${formatError(error)}`); + } + }, 0); + }); + } trayController.start(); appUpdateService.start(); appUpdateService.setInstallPreparation(prepareForUpdateInstall); @@ -164,7 +176,17 @@ function stopServicesForQuit(): Promise { .catch((error) => { console.error(`Failed to stop services before quit: ${formatError(error)}`); }) - .finally(() => { + .finally(async () => { + try { + const config = await loadAppConfig(); + for (const status of restoreGlobalProfileConfigsOnExit(config.profile.profiles)) { + if (!status.ok) { + console.error(`Failed to restore ${status.client} global profile config before quit: ${status.message}`); + } + } + } catch (error) { + console.error(`Failed to restore global profile configs before quit: ${formatError(error)}`); + } try { restoreClaudeAppGatewayConfig(); } catch (error) { @@ -185,6 +207,11 @@ function startConfiguredServices(reason: string): Promise { } catch (error) { console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); } + try { + ensureCcrCliLauncher(config, { persistPath: false }); + } catch (error) { + console.error(`Failed to install ccr CLI launcher during ${reason}: ${formatError(error)}`); + } try { syncLaunchAtLogin(config); } catch (error) { diff --git a/packages/electron/src/main/update-service.ts b/packages/electron/src/main/update-service.ts index 9d1c3ba6..2af3ac9c 100644 --- a/packages/electron/src/main/update-service.ts +++ b/packages/electron/src/main/update-service.ts @@ -10,7 +10,7 @@ type UpdateCheckOptions = { }; const startupCheckDelayMs = 12_000; -const defaultUpdateSource = "GitHub Releases (musistudio/claude-code-router)"; +const defaultUpdateSource = "GitHub Releases"; class AppUpdateService { private activeSilentCheckFailureRestoreStatus?: AppUpdateStatus; diff --git a/packages/ui/src/pages/home/components/update.tsx b/packages/ui/src/pages/home/components/update.tsx index 87af7b66..6215a13b 100644 --- a/packages/ui/src/pages/home/components/update.tsx +++ b/packages/ui/src/pages/home/components/update.tsx @@ -46,12 +46,13 @@ export function UpdateDialog({
-
-
{updateStateLabel(status, t)}
-
{updateStateDescription(status, t)}
+
+ {updateStateDescription(status, t) ? ( +
{updateStateDescription(status, t)}
+ ) : null}
@@ -59,8 +60,8 @@ export function UpdateDialog({
@@ -156,8 +157,10 @@ function UpdateStateBadge({ label, status }: { label: string; status: AppUpdateS "shrink-0 rounded-full border px-2 py-1 text-[11px] font-medium", status.state === "error" ? "border-destructive/25 bg-destructive/10 text-destructive" + : status.state === "not-available" + ? "border-emerald-200 bg-emerald-50 text-emerald-700" : status.state === "available" || status.state === "downloaded" || status.state === "downloading" - ? "border-primary/25 bg-primary/10 text-primary" + ? "border-amber-200 bg-amber-50 text-amber-700" : "border-border bg-muted/40 text-muted-foreground" )}> {label} @@ -181,7 +184,7 @@ function updateStateDescription(status: AppUpdateStatus, t: (value: string) => s if (!status.supported) return t("Updates are only available in packaged builds."); if (status.state === "available" && status.availableVersion) return `${t("Available version")}: ${status.availableVersion}`; if (status.state === "downloaded") return t("Update downloaded"); - if (status.state === "not-available") return t("No updates available"); + if (status.state === "not-available") return ""; if (status.state === "downloading") return t("Downloading update"); return t("Online updates"); } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 45bab28a..002d0af6 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -1679,6 +1679,7 @@ export const appCopy: Record = { "System status": "系统状态", "System Proxy": "系统代理", "Available version": "可用版本", + "Latest version": "最新版本", "Download update": "下载更新", "Downloading update": "正在下载更新", "Feed URL": "更新源", diff --git a/tests/main/profile-service.test.mjs b/tests/main/profile-service.test.mjs index 46283389..e04e2a1d 100644 --- a/tests/main/profile-service.test.mjs +++ b/tests/main/profile-service.test.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import test from "node:test"; import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; import { CONFIGDIR } from "../../packages/core/src/config/constants.ts"; -import { applyProfileConfig, cleanupGeneratedBinBackups, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts"; +import { applyProfileConfig, cleanupGeneratedBinBackups, restoreGlobalProfileConfigsOnExit, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts"; test("profile service cleans stale generated bin backups only", () => { const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-generated-bin-cleanup-")); @@ -386,3 +386,87 @@ test("profile service keeps managed global Claude settings when a global Claude rmSync(home, { force: true, recursive: true }); } }); + +test("profile service restores global agent configs on exit", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-global-profile-exit-")); + try { + const claudeFile = path.join(root, "claude", "settings.json"); + const codexFile = path.join(root, "codex", "config.toml"); + const zcodeFile = path.join(root, "zcode", "cli", "config.json"); + const zcodeRoot = path.dirname(path.dirname(zcodeFile)); + const zcodeV2File = path.join(zcodeRoot, "v2", "config.json"); + const zcodeCacheFile = path.join(zcodeRoot, "v2", "bots-model-cache.v2.json"); + const files = [claudeFile, codexFile, zcodeFile, zcodeV2File, zcodeCacheFile]; + const originals = new Map(files.map((file, index) => [file, `original-${index}\n`])); + const latestSnapshots = new Map(files.map((file, index) => [file, `latest-${index}\n`])); + + for (const [file, original] of originals) { + mkdirSync(path.dirname(file), { recursive: true }); + if (file === zcodeCacheFile) { + writeFileSync(`${file}.ccr-original-missing`, ""); + } else { + writeFileSync(`${file}.ccr-original`, original); + } + writeFileSync(`${file}.ccr-backup-2026-07-11T00-00-00-000Z`, latestSnapshots.get(file)); + } + writeFileSync(claudeFile, `${JSON.stringify({ + apiKeyHelper: "ccr-claude-code-api-key-test", + env: { + ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456", + ANTHROPIC_BASE_URL: "http://127.0.0.1:3456", + CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456" + } + })}\n`); + writeFileSync(codexFile, "# BEGIN CCR managed profile\nmodel = \"test\"\n# END CCR managed profile\n"); + for (const file of [zcodeFile, zcodeV2File]) { + writeFileSync(file, `${JSON.stringify({ provider: { "claude-code-router": {} } })}\n`); + } + writeFileSync(zcodeCacheFile, `${JSON.stringify({ providers: [{ id: "claude-code-router" }] })}\n`); + + const statuses = restoreGlobalProfileConfigsOnExit([ + { + agent: "claude-code", enabled: true, env: {}, id: "claude", model: "test", name: "Claude", + scope: "global", settingsFile: claudeFile, smallFastModel: "", surface: "cli" + }, + { + agent: "codex", configFile: codexFile, enabled: true, env: {}, id: "codex", model: "test", name: "Codex", + providerId: "claude-code-router", scope: "global", surface: "cli" + }, + { + agent: "zcode", configFile: zcodeFile, enabled: true, env: {}, id: "zcode", model: "test", name: "ZCode", + providerId: "claude-code-router", scope: "global", surface: "app" + } + ], { manageMarker: false }); + + assert.equal(statuses.length, 3); + assert.equal(statuses.every((status) => status.ok), true); + for (const [file, latest] of latestSnapshots) { + assert.equal(readFileSync(file, "utf8"), latest); + } + + writeFileSync(codexFile, "# BEGIN CCR managed profile\nmodel = \"test\"\n# END CCR managed profile\n"); + for (const file of [zcodeFile, zcodeV2File]) { + writeFileSync(file, `${JSON.stringify({ provider: { "claude-code-router": {} } })}\n`); + } + writeFileSync(zcodeCacheFile, `${JSON.stringify({ providers: [{ id: "claude-code-router" }] })}\n`); + const inactiveStatuses = restoreInactiveGlobalProfileConfigs([ + { + agent: "codex", configFile: codexFile, enabled: false, env: {}, id: "codex", model: "test", name: "Codex", + providerId: "claude-code-router", scope: "ccr", surface: "cli" + }, + { + agent: "zcode", configFile: zcodeFile, enabled: false, env: {}, id: "zcode", model: "test", name: "ZCode", + providerId: "claude-code-router", scope: "ccr", surface: "app" + } + ]); + assert.equal(inactiveStatuses.filter((status) => status.client === "codex").length, 1); + assert.equal(inactiveStatuses.filter((status) => status.client === "zcode").length, 3); + for (const [file, latest] of latestSnapshots) { + if (file !== claudeFile) { + assert.equal(readFileSync(file, "utf8"), latest); + } + } + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); From 8d549296c1e723de8171726db94d873718df365a Mon Sep 17 00:00:00 2001 From: musistudio Date: Sat, 11 Jul 2026 21:03:53 +0800 Subject: [PATCH 07/38] Add configurable upstream proxy support --- packages/core/src/config/config.ts | 56 +++++ packages/core/src/config/default-config.ts | 11 +- packages/core/src/contracts/app.ts | 15 ++ packages/core/src/gateway/runtime-change.ts | 1 + packages/core/src/gateway/service.ts | 2 +- packages/core/src/proxy/service.ts | 34 ++- packages/core/src/proxy/system-proxy-fetch.ts | 76 +++++-- packages/core/src/proxy/system-proxy.ts | 61 +++++- packages/ui/src/pages/home/App.tsx | 22 +- .../ui/src/pages/home/components/settings.tsx | 206 ++++++++++++++---- packages/ui/src/pages/home/shared/config.ts | 37 +++- packages/ui/src/pages/home/shared/i18n.tsx | 13 ++ packages/ui/src/pages/home/shared/types.ts | 2 +- tests/main/gateway-runtime-change.test.mjs | 16 ++ tests/main/proxy-upstream.test.mjs | 53 +++++ 15 files changed, 530 insertions(+), 75 deletions(-) create mode 100644 tests/main/proxy-upstream.test.mjs diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4e93f9cc..5e28eb01 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1968,6 +1968,10 @@ function parseProxy(value: unknown): Partial | undefined { } else if (typeof value.systemProxyEnabled === "boolean") { proxy.systemProxy = value.systemProxyEnabled; } + const upstream = parseProxyUpstream(value.upstream ?? value.upstreamProxy ?? value.outboundProxy); + if (upstream) { + proxy.upstream = upstream; + } const targets = parseProxyTargets(value.targets); if (targets) { proxy.targets = targets; @@ -1975,6 +1979,58 @@ function parseProxy(value: unknown): Partial | undefined { return proxy; } +function parseProxyUpstream(value: unknown): ProxyRuntimeConfig["upstream"] | undefined { + const fallback = DEFAULT_CONFIG.proxy.upstream; + if (typeof value === "string") { + const mode = parseProxyUpstreamMode(value); + return mode ? { ...fallback, mode } : undefined; + } + if (!isObject(value)) { + return undefined; + } + + const mode = parseProxyUpstreamMode(value.mode ?? value.type); + const customInput = isObject(value.custom) ? value.custom : value; + const server = readString(customInput.server ?? customInput.host ?? customInput.hostname); + const port = readPort(customInput.port); + const username = readString(customInput.username ?? customInput.user); + const password = typeof customInput.password === "string" + ? customInput.password + : typeof customInput.pass === "string" + ? customInput.pass + : undefined; + const hasCustomInput = server !== undefined || port !== undefined || username !== undefined || password !== undefined; + + return { + ...fallback, + custom: { + ...fallback.custom, + ...(server !== undefined ? { server } : {}), + ...(port !== undefined ? { port } : {}), + ...(username !== undefined ? { username } : {}), + ...(password !== undefined ? { password } : {}) + }, + mode: mode ?? (hasCustomInput ? "custom" : fallback.mode) + }; +} + +function parseProxyUpstreamMode(value: unknown): ProxyRuntimeConfig["upstream"]["mode"] | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, ""); + if (["none", "off", "disabled", "direct", "noproxy"].includes(normalized)) { + return "none"; + } + if (["system", "systemproxy", "os", "osproxy", "env", "environment"].includes(normalized)) { + return "system"; + } + if (["custom", "manual", "http", "httpproxy"].includes(normalized)) { + return "custom"; + } + return undefined; +} + function parseProxyTargets(value: unknown): ProxyRouteTarget[] | undefined { if (!Array.isArray(value)) { return undefined; diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 58a38681..bf6eb9d7 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -158,7 +158,16 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon mode: "gateway", port: 7890, systemProxy: false, - targets: DEFAULT_PROXY_TARGETS + targets: DEFAULT_PROXY_TARGETS, + upstream: { + custom: { + password: "", + port: 7890, + server: "", + username: "" + }, + mode: "system" + } }, providerPlugins: [], overviewWidgets: DEFAULT_OVERVIEW_WIDGETS, diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 1fa0e905..d0c6d5a6 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -613,6 +613,20 @@ export type ProxyMode = "gateway" | "transparent"; export type ProxyForwardMode = ProxyMode | "plugin"; +export type ProxyUpstreamMode = "none" | "system" | "custom"; + +export type ProxyUpstreamCustomConfig = { + password: string; + port: number; + server: string; + username: string; +}; + +export type ProxyUpstreamConfig = { + custom: ProxyUpstreamCustomConfig; + mode: ProxyUpstreamMode; +}; + export type ProxyRouteTarget = { host: string; paths?: string[]; @@ -929,6 +943,7 @@ export type ProxyRuntimeConfig = { port: number; systemProxy: boolean; targets: ProxyRouteTarget[]; + upstream: ProxyUpstreamConfig; }; export type ObservabilityConfig = { diff --git a/packages/core/src/gateway/runtime-change.ts b/packages/core/src/gateway/runtime-change.ts index 1097362b..c9171292 100644 --- a/packages/core/src/gateway/runtime-change.ts +++ b/packages/core/src/gateway/runtime-change.ts @@ -13,6 +13,7 @@ export function shouldRestartGatewayForRuntimeConfigChange(previousConfig: AppCo previousConfig.proxy.port !== nextConfig.proxy.port || previousConfig.proxy.systemProxy !== nextConfig.proxy.systemProxy || JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) || + JSON.stringify(previousConfig.proxy.upstream) !== JSON.stringify(nextConfig.proxy.upstream) || JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) || JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) || JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) || diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 11319544..9ee87789 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -399,7 +399,7 @@ class GatewayService { } await proxyService.refreshUpstreamProxyFromCurrentSystem(); const runtimeId = randomUUID(); - const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https"); + const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config); this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, this.coreAuthToken); const managedChild = this.child; writeManagedCoreGatewayMarker(config, this.child, runtimeId); diff --git a/packages/core/src/proxy/service.ts b/packages/core/src/proxy/service.ts index 8c8cfa06..bc1782c6 100644 --- a/packages/core/src/proxy/service.ts +++ b/packages/core/src/proxy/service.ts @@ -37,7 +37,16 @@ import { readProxyCertificateAuthority, type CertificateAuthority } from "@ccr/core/proxy/certificates"; -import { formatUpstreamProxy, readCurrentSystemUpstreamProxy, systemProxyManager, type UpstreamProxyConfig, type UpstreamProxyServer } from "@ccr/core/proxy/system-proxy"; +import { + customUpstreamProxyFromConfig, + formatUpstreamProxy, + readCurrentSystemUpstreamProxy, + systemProxyManager, + upstreamProxyAuthorizationHeader, + upstreamProxyUrl, + type UpstreamProxyConfig, + type UpstreamProxyServer +} from "@ccr/core/proxy/system-proxy"; type MitmServer = { host: string; @@ -147,6 +156,7 @@ class ProxyService { async start(config: AppConfig): Promise { await this.stop(); this.config = config; + this.upstreamProxy = configuredCustomUpstreamProxy(config); this.networkCaptureEnabled = config.proxy.captureNetwork; this.status = createProxyStatus(config, proxyEndpoint(config), config.proxy.port); @@ -195,6 +205,7 @@ class ProxyService { async attach(config: AppConfig, server: Server): Promise { await this.stop(); this.config = config; + this.upstreamProxy = configuredCustomUpstreamProxy(config); this.networkCaptureEnabled = config.proxy.captureNetwork; this.status = createProxyStatus(config, sharedProxyEndpoint(config), config.gateway.port); @@ -351,10 +362,13 @@ class ProxyService { if (!upstreamProxy) { return undefined; } - return `http://${formatProxyHost(upstreamProxy.host)}:${upstreamProxy.port}`; + return upstreamProxyUrl(upstreamProxy); } async refreshUpstreamProxyFromCurrentSystem(): Promise { + if (this.config?.proxy.upstream?.mode === "none" || this.config?.proxy.upstream?.mode === "custom") { + return; + } if (this.upstreamProxy || this.status.state !== "running" || !this.status.endpoint) { return; } @@ -800,7 +814,9 @@ class ProxyService { private async activateSystemProxy(): Promise { const result = await systemProxyManager.enable(this.status.endpoint); - this.upstreamProxy = result.upstreamProxy; + this.upstreamProxy = this.config?.proxy.upstream?.mode === "none" + ? undefined + : configuredCustomUpstreamProxy(this.config) ?? result.upstreamProxy; const effectiveUpstream = formatUpstreamProxy(this.upstreamProxy); this.status = { ...this.status, @@ -988,10 +1004,14 @@ function forwardHttpRequestViaHttpProxy({ upstreamUrl: URL; }): Promise { return new Promise((resolve) => { + const proxyAuthorization = upstreamProxyAuthorizationHeader(proxyServer); const upstreamRequest = http.request( { agent: false, - headers: createForwardHeaders(request.headers, upstreamUrl, routedToGateway, config, { pluginRoute, targetUrl }), + headers: { + ...createForwardHeaders(request.headers, upstreamUrl, routedToGateway, config, { pluginRoute, targetUrl }), + ...(proxyAuthorization ? { "proxy-authorization": proxyAuthorization } : {}) + }, hostname: proxyServer.host, method: request.method, path: upstreamUrl.toString(), @@ -1070,10 +1090,12 @@ function forwardHttpsRequestViaHttpProxy({ }): Promise { return new Promise((resolve) => { const targetPort = Number(upstreamUrl.port || 443); + const proxyAuthorization = upstreamProxyAuthorizationHeader(proxyServer); const connectRequest = http.request({ agent: false, headers: { host: `${upstreamUrl.hostname}:${targetPort}`, + ...(proxyAuthorization ? { "proxy-authorization": proxyAuthorization } : {}), "proxy-connection": "keep-alive" }, hostname: proxyServer.host, @@ -1378,6 +1400,10 @@ function cloneUpstreamProxy(upstreamProxy: UpstreamProxyConfig | undefined): Ups }; } +function configuredCustomUpstreamProxy(config: AppConfig | undefined): UpstreamProxyConfig | undefined { + return customUpstreamProxyFromConfig(config?.proxy.upstream); +} + function selectUpstreamProxy(upstreamProxy: UpstreamProxyConfig | undefined, protocol: "http" | "https"): UpstreamProxyServer | undefined { if (protocol === "https") { return upstreamProxy?.https ?? upstreamProxy?.http; diff --git a/packages/core/src/proxy/system-proxy-fetch.ts b/packages/core/src/proxy/system-proxy-fetch.ts index f1e10bcc..bd7dea65 100644 --- a/packages/core/src/proxy/system-proxy-fetch.ts +++ b/packages/core/src/proxy/system-proxy-fetch.ts @@ -1,6 +1,13 @@ import { ProxyAgent, type Dispatcher } from "undici"; import { loadAppConfig } from "@ccr/core/config/config"; -import { readCurrentSystemUpstreamProxy, systemProxyManager, type UpstreamProxyConfig, type UpstreamProxyServer } from "@ccr/core/proxy/system-proxy"; +import { + customUpstreamProxyFromConfig, + readCurrentSystemUpstreamProxy, + systemProxyManager, + upstreamProxyUrl, + type UpstreamProxyConfig, + type UpstreamProxyServer +} from "@ccr/core/proxy/system-proxy"; import type { AppConfig } from "@ccr/core/contracts/app"; type FetchInitWithDispatcher = RequestInit & { @@ -27,7 +34,7 @@ export async function fetchWithSystemProxy(input: RequestInfo | URL, init?: Requ return fetch(input, init); } - const proxyUrl = await systemProxyUrlForRequest(url); + const proxyUrl = await configuredProxyUrlForRequest(url); if (!proxyUrl) { return fetch(input, init); } @@ -44,29 +51,52 @@ export function readEnvProxyUrl(): string | undefined { return envProxy ? envProxy.trim() : undefined; } -export async function getSystemProxyUrlForProtocol(protocol: "http" | "https" = "https"): Promise { - const cache = await readSystemProxy(); +export async function getSystemProxyUrlForProtocol(protocol: "http" | "https" = "https", config?: AppConfig): Promise { + const proxyConfig = config ?? await loadProxyConfig(); + const mode = proxyConfig?.proxy.upstream.mode ?? "system"; + if (mode === "none") { + return undefined; + } + if (mode === "custom") { + const server = proxyServerForRequest(customUpstreamProxyFromConfig(proxyConfig?.proxy.upstream), protocol); + return server ? formatProxyUrl(server) : undefined; + } + + const cache = await readSystemProxy(proxyConfig); const server = proxyServerForRequest(cache.upstreamProxy, protocol); if (server) return formatProxyUrl(server); return readEnvProxyUrl(); } -async function systemProxyUrlForRequest(url: URL): Promise { - const cache = await readSystemProxy(); - const server = proxyServerForRequest(cache.upstreamProxy, url.protocol === "https:" ? "https" : "http"); +async function configuredProxyUrlForRequest(url: URL): Promise { + const proxyConfig = await loadProxyConfig(); + const mode = proxyConfig?.proxy.upstream.mode ?? "system"; + if (mode === "none") { + return undefined; + } + const protocol = url.protocol === "https:" ? "https" : "http"; + if (mode === "custom") { + const server = proxyServerForRequest(customUpstreamProxyFromConfig(proxyConfig?.proxy.upstream), protocol); + return server ? formatProxyUrl(server) : undefined; + } + + const cache = await readSystemProxy(proxyConfig); + const server = proxyServerForRequest(cache.upstreamProxy, protocol); if (server) return formatProxyUrl(server); return readEnvProxyUrl(); } -async function readSystemProxy(): Promise { +async function readSystemProxy(config?: AppConfig): Promise { const now = Date.now(); const activeManagedEndpointUrl = systemProxyManager.getManagedEndpointUrl(); + const configuredManagedEndpointUrl = config ? managedProxyEndpointUrl(config) : undefined; if ( systemProxyCache && systemProxyCache.expiresAt > now && - (!activeManagedEndpointUrl || systemProxyCache.managedEndpointUrl === activeManagedEndpointUrl) + (!activeManagedEndpointUrl || systemProxyCache.managedEndpointUrl === activeManagedEndpointUrl) && + (!configuredManagedEndpointUrl || systemProxyCache.managedEndpointUrl === configuredManagedEndpointUrl) ) { return systemProxyCache; } @@ -74,7 +104,7 @@ async function readSystemProxy(): Promise { return systemProxyReadPromise; } - systemProxyReadPromise = readSystemProxyUncached() + systemProxyReadPromise = readSystemProxyUncached(config) .then((cache) => { systemProxyCache = { ...cache, @@ -89,8 +119,8 @@ async function readSystemProxy(): Promise { return systemProxyReadPromise; } -async function readSystemProxyUncached(): Promise> { - const { managedEndpointUrl, systemProxyActive } = await readManagedProxyEndpoint(); +async function readSystemProxyUncached(config?: AppConfig): Promise> { + const { managedEndpointUrl, systemProxyActive } = await readManagedProxyEndpoint(config); if (systemProxyActive) { const managedUpstreamProxy = systemProxyManager.getUpstreamProxy(); if (managedUpstreamProxy) { @@ -112,7 +142,7 @@ async function readSystemProxyUncached(): Promise { +async function readManagedProxyEndpoint(config?: AppConfig): Promise<{ managedEndpointUrl: string; systemProxyActive: boolean }> { const activeManagedEndpointUrl = systemProxyManager.getManagedEndpointUrl(); if (activeManagedEndpointUrl) { return { @@ -121,8 +151,15 @@ async function readManagedProxyEndpoint(): Promise<{ managedEndpointUrl: string; }; } + if (config) { + return { + managedEndpointUrl: managedProxyEndpointUrl(config), + systemProxyActive: config.proxy.enabled && config.proxy.systemProxy + }; + } + try { - const config = await loadAppConfig(); + config = await loadAppConfig(); return { managedEndpointUrl: managedProxyEndpointUrl(config), systemProxyActive: config.proxy.enabled && config.proxy.systemProxy @@ -136,6 +173,15 @@ async function readManagedProxyEndpoint(): Promise<{ managedEndpointUrl: string; }; } +async function loadProxyConfig(): Promise { + try { + return await loadAppConfig(); + } catch (error) { + console.warn(`[network] Failed to read proxy config: ${formatError(error)}`); + return undefined; + } +} + function managedProxyEndpointUrl(config: AppConfig): string { const host = normalizeManagedProxyHost(config.gateway.host); return `http://${formatProxyHost(host)}:${config.gateway.port}`; @@ -170,7 +216,7 @@ function proxyDispatcher(proxyUrl: string): Dispatcher { } function formatProxyUrl(server: UpstreamProxyServer): string { - return `${server.protocol}://${formatProxyHost(server.host)}:${server.port}`; + return upstreamProxyUrl(server); } function formatProxyHost(host: string): string { diff --git a/packages/core/src/proxy/system-proxy.ts b/packages/core/src/proxy/system-proxy.ts index c17f7e84..4c23d491 100644 --- a/packages/core/src/proxy/system-proxy.ts +++ b/packages/core/src/proxy/system-proxy.ts @@ -1,14 +1,17 @@ import { execFile } from "node:child_process"; +import { Buffer } from "node:buffer"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import type { ProxySystemStatus } from "@ccr/core/contracts/app"; +import type { ProxyRuntimeConfig, ProxySystemStatus } from "@ccr/core/contracts/app"; import { DATADIR } from "@ccr/core/config/constants"; import { windowsSystemCommand } from "@ccr/core/platform/windows-system"; export type UpstreamProxyServer = { host: string; + password?: string; port: number; protocol: "http"; + username?: string; }; export type UpstreamProxyConfig = { @@ -261,6 +264,45 @@ export function formatUpstreamProxy(upstreamProxy: UpstreamProxyConfig | undefin return values.join(", "); } +export function customUpstreamProxyFromConfig(upstream: ProxyRuntimeConfig["upstream"] | undefined): UpstreamProxyConfig | undefined { + if (upstream?.mode !== "custom") { + return undefined; + } + const host = normalizeCustomProxyServer(upstream.custom.server); + const port = upstream.custom.port; + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { + return undefined; + } + + const server: UpstreamProxyServer = { + host, + password: upstream.custom.password, + port, + protocol: "http", + username: upstream.custom.username.trim() + }; + return { + http: server, + https: server + }; +} + +export function upstreamProxyAuthorizationHeader(server: UpstreamProxyServer): string | undefined { + if (!server.username && !server.password) { + return undefined; + } + return `Basic ${Buffer.from(`${server.username ?? ""}:${server.password ?? ""}`).toString("base64")}`; +} + +export function upstreamProxyUrl(server: UpstreamProxyServer): string { + const username = server.username ?? ""; + const password = server.password ?? ""; + const auth = username || password + ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` + : ""; + return `${server.protocol}://${auth}${formatProxyHost(server.host)}:${server.port}`; +} + export async function readCurrentSystemUpstreamProxy(managedEndpointUrl: string): Promise { if (process.platform !== "darwin" && process.platform !== "win32") { return undefined; @@ -286,6 +328,23 @@ function parseManagedEndpoint(endpoint: string): ManagedProxyEndpoint { }; } +function normalizeCustomProxyServer(server: string): string { + const trimmed = server.trim(); + if (!trimmed) { + return ""; + } + + try { + const parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`); + return parsed.hostname; + } catch { + return trimmed + .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "") + .replace(/\/.*$/, "") + .replace(/^\[(.*)]$/, "$1"); + } +} + async function captureMacSystemProxySnapshot(managedEndpoint: ManagedProxyEndpoint): Promise { const services = await listNetworkServices(); const snapshots: MacNetworkServiceSnapshot[] = []; diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index d557b14a..b7e9623a 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -18,7 +18,7 @@ import { isTraySupportedPlatform, isRoutingRewriteDraftRowValid, LayoutGroup, mergeModelDisplayNames, mergeModelMetadata, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels, modelMetadataForModels, - navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, + navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProxyConfig, normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft, @@ -2188,6 +2188,24 @@ function App() { })); } + function changeProxyConfig(patch: Partial) { + updateConfig((config) => ({ + ...config, + proxy: normalizeProxyConfig({ + ...config.proxy, + ...patch, + upstream: { + ...config.proxy.upstream, + ...(patch.upstream ?? {}), + custom: { + ...config.proxy.upstream.custom, + ...(patch.upstream?.custom ?? {}) + } + } + }) + })); + } + function changeToolHubConfig(patch: Partial) { updateConfig((config) => ({ ...config, @@ -3235,6 +3253,7 @@ function App() { onChangeLaunchAtLogin: changeLaunchAtLogin, onChangeLanguage: changeLanguagePreference, onChangeObservability: changeObservabilityConfig, + onChangeProxy: changeProxyConfig, onChangeTheme: changeThemePreference, onChangeToolHub: changeToolHubConfig, onChangeTrayBalanceProgress: changeTrayBalanceProgress, @@ -3243,6 +3262,7 @@ function App() { onClose: () => setSettingsOpen(false), observability: draftConfig.observability, profiles: draftConfig.profile.profiles, + proxy: draftConfig.proxy, providers: draftConfig.Providers, systemLanguage, systemTheme, diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index 4ef57a51..b37605ab 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -6,9 +6,9 @@ import { DialogFooter, DialogHeader, DialogTitle, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge, Globe, createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, createRouteModelOptions, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle, - mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeToolHubConfig, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel, + mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeProxyUpstreamConfig, normalizeToolHubConfig, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel, PanelLeftOpen, Power, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl, - PointerSensor, rectSortingStrategy, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName, + PointerSensor, rectSortingStrategy, Settings, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName, translateOptions, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, appLogoUrl, trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, Textarea, Trash2, trayWidgetVariantOptions, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, validateMcpServerDraft, X @@ -27,6 +27,7 @@ export function AppSettingsDialog({ onChangeBotConfigs, onChangeLaunchAtLogin, onChangeObservability, + onChangeProxy, onChangeToolHub, onChangeTrayBalanceProgress, onChangeLanguage, @@ -36,6 +37,7 @@ export function AppSettingsDialog({ onClose, observability, profiles, + proxy, providers, providerAccountSnapshots, systemLanguage, @@ -57,6 +59,7 @@ export function AppSettingsDialog({ onChangeBotConfigs: (configs: BotGatewaySavedConfig[]) => void; onChangeLaunchAtLogin: (checked: boolean) => void; onChangeObservability: (patch: Partial) => void; + onChangeProxy: (patch: Partial) => void; onChangeToolHub: (patch: Partial) => void; onChangeTrayBalanceProgress: (config: TrayBalanceProgressConfig) => void; onChangeLanguage: (value: string) => void; @@ -66,6 +69,7 @@ export function AppSettingsDialog({ onClose: () => void; observability: AppConfig["observability"]; profiles: ProfileConfig[]; + proxy: AppConfig["proxy"]; providers: GatewayProviderConfig[]; providerAccountSnapshots: ProviderAccountSnapshot[]; systemLanguage: ResolvedLanguage; @@ -83,14 +87,24 @@ export function AppSettingsDialog({ initialPage={initialPage} onClose={onClose} renderPage={(activePage) => { + if (activePage === "general") { + return ( + + ); + } if (activePage === "appearance") { return ( ); } - if (activePage === "data") { - return ( - - ); - } return null; }} traySupported={traySupported} @@ -198,6 +204,13 @@ function SettingsLayout({ label={copy.settings.appearance} onClick={() => setActivePage("appearance")} /> + setActivePage("general")} + /> setActivePage("bots")} /> - setActivePage("data")} - /> {traySupported ? ( - - - - ); @@ -292,9 +292,6 @@ function SettingsPageButton({ function AppearanceSettingsPage({ copy, languagePreference, - launchAtLogin, - launchAtLoginSupported, - onChangeLaunchAtLogin, onChangeLanguage, onChangeTheme, systemLanguage, @@ -303,9 +300,6 @@ function AppearanceSettingsPage({ }: { copy: AppCopy; languagePreference: AppLanguagePreference; - launchAtLogin: boolean; - launchAtLoginSupported: boolean; - onChangeLaunchAtLogin: (checked: boolean) => void; onChangeLanguage: (value: string) => void; onChangeTheme: (value: string) => void; systemLanguage: ResolvedLanguage; @@ -333,20 +327,141 @@ function AppearanceSettingsPage({ - {launchAtLoginSupported ? ( - - ) : null}
); } +function GeneralSettingsPage({ + appInfo, + copy, + launchAtLogin, + launchAtLoginSupported, + onChangeLaunchAtLogin, + onChangeProxy, + proxy +}: { + appInfo: AppInfo; + copy: AppCopy; + launchAtLogin: boolean; + launchAtLoginSupported: boolean; + onChangeLaunchAtLogin: (checked: boolean) => void; + onChangeProxy: (patch: Partial) => void; + proxy: AppConfig["proxy"]; +}) { + return ( +
+

{copy.settings.general}

+ {launchAtLoginSupported ? ( + + ) : null} + + +
+ ); +} + +function ProxySettingsSection({ + copy, + onChange, + proxy +}: { + copy: AppCopy; + onChange: (patch: Partial) => void; + proxy: AppConfig["proxy"]; +}) { + const t = (value: string) => copy.text[value] ?? value; + const upstream = normalizeProxyUpstreamConfig(proxy.upstream); + const modeOptions = [ + { label: t("Do not use proxy"), value: "none" }, + { label: t("Use system proxy"), value: "system" }, + { label: t("Use custom proxy"), value: "custom" } + ]; + + const patchUpstream = (patch: Partial) => { + onChange({ + upstream: normalizeProxyUpstreamConfig({ + ...upstream, + ...patch, + custom: { + ...upstream.custom, + ...(patch.custom ?? {}) + } + }) + }); + }; + const patchCustom = (custom: Partial) => { + patchUpstream({ + custom: { + ...upstream.custom, + ...custom + } + }); + }; + + return ( +
+

{copy.settings.proxy}

+
+ + patchUpstream({ mode: mode as AppConfig["proxy"]["upstream"]["mode"] })} + options={modeOptions} + value={upstream.mode} + /> + + + {upstream.mode === "custom" ? ( + <> + + patchCustom({ server: event.target.value })} + placeholder="127.0.0.1" + value={upstream.custom.server} + /> + + + { + const port = Number(event.target.value); + if (Number.isFinite(port)) { + patchCustom({ port }); + } + }} + type="number" + value={String(upstream.custom.port)} + /> + + + patchCustom({ username: event.target.value })} + value={upstream.custom.username} + /> + + + patchCustom({ password: event.target.value })} + type="password" + value={upstream.custom.password} + /> + + + ) : null} +
+
+ ); +} + function ObservabilitySettingsPage({ copy, observability, @@ -935,7 +1050,7 @@ function SettingsSwitchRow({ ); } -function DataSettingsPage({ +function DataSettingsSection({ appInfo, copy }: { @@ -971,10 +1086,9 @@ function DataSettingsPage({ } return ( -
+
-

{copy.settings.data}

-
{t("Configuration is stored in SQLite. The legacy JSON file is only read once for migration.")}
+

{copy.settings.data}

@@ -1011,7 +1125,7 @@ function DataSettingsPage({
) : null}
-
+ ); } diff --git a/packages/ui/src/pages/home/shared/config.ts b/packages/ui/src/pages/home/shared/config.ts index ec478c70..8e985309 100644 --- a/packages/ui/src/pages/home/shared/config.ts +++ b/packages/ui/src/pages/home/shared/config.ts @@ -409,11 +409,7 @@ export function normalizeConfig(config: AppConfig): AppConfig { }, launchAtLogin: Boolean(config.launchAtLogin), observability: normalizeObservabilityConfig(config.observability), - proxy: { - ...fallbackConfig.proxy, - ...(config.proxy || {}), - targets: Array.isArray(config.proxy?.targets) ? config.proxy.targets : fallbackConfig.proxy.targets - }, + proxy: normalizeProxyConfig(config.proxy), profile: { ...fallbackConfig.profile, ...profileConfig, @@ -454,6 +450,37 @@ export function normalizeObservabilityConfig(config: Partial | undefined): AppConfig["proxy"] { + return { + ...fallbackConfig.proxy, + ...(config || {}), + targets: Array.isArray(config?.targets) ? config.targets : fallbackConfig.proxy.targets, + upstream: normalizeProxyUpstreamConfig(config?.upstream) + }; +} + +export function normalizeProxyUpstreamConfig(config: Partial | undefined): AppConfig["proxy"]["upstream"] { + const mode = config?.mode === "none" || config?.mode === "system" || config?.mode === "custom" + ? config.mode + : fallbackConfig.proxy.upstream.mode; + const port = typeof config?.custom?.port === "number" && Number.isFinite(config.custom.port) + ? Math.min(Math.max(Math.floor(config.custom.port), 1), 65535) + : fallbackConfig.proxy.upstream.custom.port; + return { + ...fallbackConfig.proxy.upstream, + ...(config || {}), + custom: { + ...fallbackConfig.proxy.upstream.custom, + ...(config?.custom || {}), + password: typeof config?.custom?.password === "string" ? config.custom.password : fallbackConfig.proxy.upstream.custom.password, + port, + server: typeof config?.custom?.server === "string" ? config.custom.server.trim() : fallbackConfig.proxy.upstream.custom.server, + username: typeof config?.custom?.username === "string" ? config.custom.username.trim() : fallbackConfig.proxy.upstream.custom.username + }, + mode + }; +} + export function normalizeToolHubConfig(config: Partial | undefined): AppConfig["toolHub"] { const maxTools = typeof config?.maxTools === "number" && Number.isFinite(config.maxTools) ? Math.min(Math.max(Math.floor(config.maxTools), 1), 20) diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 91fea4ac..132b7688 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -15,6 +15,7 @@ export type AppCopy = { close: string; data: string; done: string; + general: string; language: string; languageChinese: string; languageEnglish: string; @@ -22,6 +23,7 @@ export type AppCopy = { launchAtLogin: string; launchAtLoginDescription: string; observability: string; + proxy: string; requestLogs: string; requestLogsDescription: string; theme: string; @@ -127,6 +129,7 @@ export const appCopy: Record = { close: "Close", data: "Data", done: "Done", + general: "General", language: "Language", languageChinese: "Chinese", languageEnglish: "English", @@ -134,6 +137,7 @@ export const appCopy: Record = { launchAtLogin: "Launch at login", launchAtLoginDescription: "Open Claude Code Router automatically when you sign in to this computer.", observability: "Logs & Observability", + proxy: "Proxy", requestLogs: "Request logs", requestLogsDescription: "Record gateway requests and show the Logs page.", theme: "Theme", @@ -489,6 +493,7 @@ export const appCopy: Record = { close: "关闭", data: "数据", done: "完成", + general: "通用", language: "语言", languageChinese: "中文", languageEnglish: "英文", @@ -496,6 +501,7 @@ export const appCopy: Record = { launchAtLogin: "开机自启", launchAtLoginDescription: "登录系统后自动打开 Claude Code Router。", observability: "日志与观测", + proxy: "代理", requestLogs: "请求日志", requestLogsDescription: "记录网关请求并显示日志页。", theme: "主题", @@ -980,8 +986,13 @@ export const appCopy: Record = { "Provider website": "供应商网站", "Open provider website": "打开供应商网站", "Providers": "供应商", + "Do not use proxy": "不使用代理", "Proxy": "代理", "Proxy mode": "代理模式", + "Proxy server": "代理服务器", + "Proxy source": "代理来源", + "Use custom proxy": "使用自定义代理", + "Use system proxy": "使用系统代理", "Preset provider": "预设供应商", "Profile": "配置", "Profile name": "配置档案名称", @@ -991,6 +1002,7 @@ export const appCopy: Record = { "Profile opening is only available in the Electron app.": "配置档案打开功能仅在 Electron App 中可用。", "Profile stopping is only available in the Electron app.": "配置档案停止功能仅在 Electron App 中可用。", "Profile ready": "配置档案已就绪", + "Password": "密码", "Recent Errors": "最近错误", "Recent Requests": "最近请求", "Refresh": "刷新", @@ -1205,6 +1217,7 @@ export const appCopy: Record = { "Token Mix": "令牌构成", "Total": "总计", "Total tokens": "总令牌", + "Username": "用户名", "Today": "今天", "Token threshold": "令牌阈值", "Truncated": "已截断", diff --git a/packages/ui/src/pages/home/shared/types.ts b/packages/ui/src/pages/home/shared/types.ts index 08b58038..40a46c0e 100644 --- a/packages/ui/src/pages/home/shared/types.ts +++ b/packages/ui/src/pages/home/shared/types.ts @@ -380,7 +380,7 @@ export type OnboardingStepId = "provider" | "profile" | "enter"; export type AppLanguagePreference = "system" | "en" | "zh"; export type ResolvedLanguage = "en" | "zh"; export type ResolvedTheme = "light" | "dark"; -export type SettingsPageId = "appearance" | "toolhub" | "observability" | "bots" | "tray" | "data"; +export type SettingsPageId = "general" | "appearance" | "toolhub" | "observability" | "bots" | "tray"; export type TrayEditableModuleId = Exclude; export type TrayComponentOptionGroup = { key: keyof TrayComponentVariants; diff --git a/tests/main/gateway-runtime-change.test.mjs b/tests/main/gateway-runtime-change.test.mjs index f6dd0882..ed560fc2 100644 --- a/tests/main/gateway-runtime-change.test.mjs +++ b/tests/main/gateway-runtime-change.test.mjs @@ -24,3 +24,19 @@ test("ToolHub config changes restart the gateway runtime", () => { assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true); }); + +test("upstream proxy config changes restart the gateway runtime", () => { + const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + next.proxy.upstream = { + custom: { + password: "secret", + port: 8888, + server: "proxy.example.com", + username: "alice" + }, + mode: "custom" + }; + + assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true); +}); diff --git a/tests/main/proxy-upstream.test.mjs b/tests/main/proxy-upstream.test.mjs new file mode 100644 index 00000000..b91cf431 --- /dev/null +++ b/tests/main/proxy-upstream.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Buffer } from "node:buffer"; +import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; +import { + customUpstreamProxyFromConfig, + upstreamProxyAuthorizationHeader, + upstreamProxyUrl +} from "../../packages/core/src/proxy/system-proxy.ts"; + +test("custom upstream proxy config creates authenticated proxy URLs", () => { + const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + config.proxy.upstream = { + custom: { + password: "pa:ss", + port: 8888, + server: "http://proxy.example.com:8888", + username: "alice@example.com" + }, + mode: "custom" + }; + + const upstream = customUpstreamProxyFromConfig(config.proxy.upstream); + assert.ok(upstream?.https); + assert.equal( + upstreamProxyUrl(upstream.https), + "http://alice%40example.com:pa%3Ass@proxy.example.com:8888" + ); + assert.equal( + upstreamProxyAuthorizationHeader(upstream.https), + `Basic ${Buffer.from("alice@example.com:pa:ss").toString("base64")}` + ); +}); + +test("none and incomplete custom upstream proxy configs do not create proxy servers", () => { + const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + config.proxy.upstream = { + ...config.proxy.upstream, + mode: "none" + }; + assert.equal(customUpstreamProxyFromConfig(config.proxy.upstream), undefined); + + config.proxy.upstream = { + custom: { + password: "", + port: 8888, + server: "", + username: "" + }, + mode: "custom" + }; + assert.equal(customUpstreamProxyFromConfig(config.proxy.upstream), undefined); +}); From 5ddbacee186e3b78b2d9a2c907725e5c9c1e287b Mon Sep 17 00:00:00 2001 From: musistudio Date: Sat, 11 Jul 2026 21:27:56 +0800 Subject: [PATCH 08/38] Document CCR as a local control plane and add ZCode home support --- README.md | 59 ++-- README_zh.md | 58 ++-- .../src/agents/local-providers/service.ts | 1 + .../core/src/agents/local-providers/zcode.ts | 33 +- .../core/src/providers/account-service.ts | 328 +++++++++++++++++- tests/main/provider-account-service.test.mjs | 239 +++++++++++++ 6 files changed, 656 insertions(+), 62 deletions(-) create mode 100644 tests/main/provider-account-service.test.mjs diff --git a/README.md b/README.md index f32ddf13..09f87a76 100644 --- a/README.md +++ b/README.md @@ -41,28 +41,47 @@
-Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. +Claude Code Router Desktop is a local control plane for coding agents. It gives Claude Code, Codex, ZCode, and compatible API clients one stable local endpoint, then lets you decide which provider, model, routing policy, tool stack, and account should handle each request. + +Instead of wiring every agent to every model service by hand, CCR centralizes the model layer on your own machine: provider presets, custom endpoints, credential pools, fallback chains, Fusion-enhanced models, MCP tools, request logs, account usage, and desktop launch profiles all live in one app.

Claude Code Router Desktop screenshot

+## What CCR Helps You Do + +| Goal | CCR gives you | +| --- | --- | +| Keep the same agent workflow while switching models | Local profiles for Claude Code, Codex, and ZCode, with CLI/app launch entries and per-profile model selection | +| Try many providers without rebuilding config every time | Built-in provider presets, custom OpenAI/Anthropic/Gemini-compatible endpoints, protocol probing, model discovery, and connectivity checks | +| Make routing a runtime policy | Built-in agent routing, conditional rules, request rewrites, model-prefix routing, retries, and fallback model chains | +| Control cost and quota pressure | Credential pools, key rotation, local usage limits, account balance snapshots, token/cost dashboards, and tray status | +| Upgrade a model without replacing it | Fusion models that add vision, web search, or selected MCP tools to an existing base model | +| Keep large tool sets usable | ToolHub, a compact MCP entry point that lets agents resolve and invoke the tools needed for the current task | +| Debug what actually happened | Request logs, resolved provider/model fields, latency, token usage, estimated cost, network capture, and agent observability | + ## Why Use CCR -- Use one local endpoint for multiple agent tools instead of configuring every client separately. -- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand. -- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. -- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. +- **One gateway for your agent stack**: point clients at CCR once, then move routing, models, keys, and providers from scattered client configs into a single desktop UI. +- **Provider freedom without workflow churn**: use OpenAI Chat/Responses, Anthropic Messages, Gemini Generate Content/Interactions, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom compatible providers. +- **Reliability policies you can see and change**: define when a request should be rewritten, retried, or moved to another model, then verify the result in local logs. +- **Operational visibility for AI work**: track requests, tokens, cost estimates, success rate, latency, model distribution, provider usage, and account balances from the dashboard or tray. +- **Agent-native tools and extensions**: add Fusion capabilities, expose dynamic MCP tools through ToolHub, automate the built-in browser, relay agents through IM bots, or install local extensions. -## Features +## Feature Highlights -- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards. -- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available. -- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites. -- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles. -- **Gateway compatibility**: translate supported client requests through the local CCR model gateway. -- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture. -- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model. +- **Agent profiles**: create profiles for Claude Code, Codex, and ZCode with model overrides, scopes, CLI/app launch surfaces, environment settings, and multi-instance app workflows. +- **Provider management**: add preset providers or custom endpoints; probe supported protocols; detect model lists; run real connectivity checks; manage single keys or credential pools; import local agent login state where supported. +- **Model catalog**: search all configured models, edit model descriptions, and use those descriptions to guide Claude Code subagent, Task, and Workflow model selection. +- **Routing engine**: combine built-in agent routing, request-header/body conditions, model-prefix routing, request rewrites, retry policy, and ordered fallback targets. +- **Fusion models**: publish reusable virtual models that keep a base model's behavior while adding vision, hosted web search, or selected MCP tools. +- **ToolHub**: merge multiple MCP servers into one dynamic MCP server so agents can resolve tools only when a task needs them; desktop builds can also expose built-in browser automation and Chrome login-state import. +- **API keys and quotas**: create CCR client keys with expiration and local request/token/image limits, separate from upstream provider credentials. +- **Logs and observability**: inspect request/response details, resolved provider and model, credential, status, latency, token usage, estimated cost, tool calls, and agent execution traces. +- **Proxy and networking**: run CCR as a local HTTP/HTTPS proxy, optionally install the CA certificate, route supported API traffic through CCR, and capture network exchanges for debugging. +- **Bot relay**: connect agent profiles to supported IM platforms including Weixin iLink, WeCom, Slack, Discord, Telegram, LINE, Feishu, and DingTalk. +- **Extensions**: install wrapper plugins and core gateway plugins that can register local routes, proxy routes, provider account connectors, apps, and virtual models. ## Documentation @@ -83,7 +102,7 @@ Read the full documentation at [ccrdesk.top](https://ccrdesk.top/). CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists. -After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status. +After the service is started from the **Server** page, CCR listens on `http://127.0.0.1:3456` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status. ## Quick Start @@ -91,25 +110,23 @@ CCR can be configured entirely from the desktop UI. Use this setup order for a c ### 1. Add a provider -Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider. +Open **Providers**, click **Add Provider**, then choose a built-in preset, import a supported local agent login state, or select **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider. ### 2. Configure routing -Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior. - -Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets. +Open **Routing** to enable built-in agent routes, add conditional rules, configure request rewrites, and set fallback behavior. Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets. ### 3. Start the gateway -Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens. +Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://127.0.0.1:3456` by default. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens. ### 4. Connect your agent tool -Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR. +Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use **Open Agent** to launch the target app through CCR. ### 5. Monitor and adjust -Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status. +Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors. Use the dashboard and tray window for token, cost, model distribution, and account status. ## Acknowledgements diff --git a/README_zh.md b/README_zh.md index 7065c54e..74324a7f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -41,27 +41,47 @@ -Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 +Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 Claude Code、Codex、ZCode 以及兼容 API 客户端提供一个稳定的本地入口,然后由你在 CCR 中决定每个请求应该走哪个供应商、哪个模型、哪套路由策略、哪些工具能力和哪组账号凭据。 + +相比在每个 Agent、每个模型服务里反复改配置,CCR 把模型层收束到本机桌面应用里:供应商预设、自定义端点、凭据池、Fallback、Fusion 组合模型、MCP 工具、请求日志、账号用量和 Agent 启动配置都在一个地方管理。

Claude Code Router Desktop 项目截图

+## CCR 能帮你做什么 + +| 目标 | CCR 提供的能力 | +| --- | --- | +| 保持 Agent 工作流不变,同时自由切换模型 | 为 Claude Code、Codex、ZCode 创建本地配置档案,支持 CLI / App 启动入口和按配置选择模型 | +| 快速接入多个模型供应商 | 内置供应商预设、自定义 OpenAI / Anthropic / Gemini 兼容端点、协议探测、模型发现和连通性检测 | +| 把路由变成可配置策略 | 内置 Agent 路由、条件规则、请求改写、模型前缀路由、自动重试和 Fallback 模型链 | +| 控制成本和额度压力 | 凭据池、Key 轮换、本地限额、账号余额快照、Token / 成本仪表盘和托盘状态 | +| 给稳定模型补能力 | 通过 Fusion 给基础模型叠加视觉、联网搜索或指定 MCP 工具 | +| 让大量工具变得可用 | ToolHub 把多个 MCP server 收束成一个紧凑入口,让 Agent 按任务动态解析和调用工具 | +| 排查每一次请求 | 请求日志、最终供应商 / 模型、耗时、Token、成本估算、网络捕获和 Agent 观测链路 | + ## 为什么使用 CCR -- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 -- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。 -- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。 +- **一个本地网关,接管整套 Agent 模型层**:客户端只需要指向 CCR,模型、供应商、Key、路由和工具能力都可以在桌面 UI 中调整。 +- **换供应商,不换工作流**:支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Content / Interactions、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义兼容供应商。 +- **可见、可改、可验证的可靠性策略**:配置请求什么时候改写、重试或切到备用模型,并在本地日志里确认真实命中结果。 +- **面向 AI 工作流的运营视角**:从仪表盘或托盘查看请求量、Token、成本估算、成功率、延迟、模型分布、供应商用量和账号余额。 +- **Agent 原生工具与扩展**:使用 Fusion 扩展模型能力,通过 ToolHub 暴露动态 MCP 工具,让内置浏览器参与任务,通过 IM Bot 接力 Agent,或安装本地扩展。 -## 功能和特性 +## 功能亮点 -- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。 -- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。 -- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。 -- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。 -- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。 -- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。 -- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。 +- **Agent 配置档案**:为 Claude Code、Codex 和 ZCode 创建配置档案,支持模型覆盖、作用范围、CLI / App 启动方式、环境变量和多开 App 工作流。 +- **供应商管理**:添加预设供应商或自定义端点;探测协议;发现模型列表;运行真实连通性检测;管理单 Key 或凭据池;在支持时导入本机 Agent 登录态。 +- **模型目录**:搜索全部已配置模型,编辑模型描述,并把这些描述用于 Claude Code Subagent、Task 和 Workflow 的模型选择提示。 +- **路由引擎**:组合内置 Agent 路由、请求 Header / Body 条件、模型前缀路由、请求改写、重试策略和有序 Fallback 目标。 +- **Fusion 组合模型**:发布可复用的虚拟模型,在保留基础模型手感的同时增加视觉、托管联网搜索或指定 MCP 工具。 +- **ToolHub**:把多个 MCP server 合并成一个动态 MCP server,让 Agent 只在任务需要时解析工具;桌面端还可暴露内置浏览器自动化和 Chrome 登录态导入。 +- **API Key 与限额**:创建访问 CCR 的客户端 Key,设置过期时间和本地请求 / Token / 图片限额,与上游供应商凭据分开管理。 +- **日志与观测**:查看请求 / 响应详情、最终供应商与模型、凭据、状态、耗时、Token、成本估算、工具调用和 Agent 执行链路。 +- **代理与网络捕获**:把 CCR 作为本地 HTTP / HTTPS 代理运行,可选安装 CA 证书,把支持的 API 流量接入 CCR,并保存网络请求用于排查。 +- **Bot 接力**:把 Agent 配置接入 Weixin iLink、企业微信、Slack、Discord、Telegram、LINE、飞书和钉钉等 IM 平台。 +- **扩展机制**:安装 wrapper plugin 和 core gateway plugin,注册本地路由、代理路由、供应商账号连接器、内置应用和虚拟模型。 ## 文档 @@ -82,7 +102,7 @@ Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 C CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。 -从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。 +从 **服务** 页面启动后,CCR 默认监听 `http://127.0.0.1:3456`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。 ## 快速开始 @@ -90,25 +110,23 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 ### 1. 添加 Provider -打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。 +打开 **供应商**,点击 **添加供应商**,选择内置预设、导入支持的本机 Agent 登录态,或选择 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。 ### 2. 设置路由 -打开 **路由**,添加条件规则,配置请求改写和失败降级。 - -如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。 +打开 **路由**,启用内置 Agent 路由,添加条件规则,配置请求改写和失败降级。如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。 ### 3. 启动网关 -打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。 +打开 **服务**,点击 **启动**。页面显示运行中后,CCR 默认会在本机监听 `http://127.0.0.1:3456`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。 ### 4. 连接 Agent 工具 -打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。 +打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 通过 CCR 打开目标应用。 ### 5. 日常查看和调整 -到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。 +到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用概览仪表盘和托盘窗口查看 Token、成本、模型分布和账号状态。 ## 致谢 diff --git a/packages/core/src/agents/local-providers/service.ts b/packages/core/src/agents/local-providers/service.ts index 6f1ab138..4ecec87a 100644 --- a/packages/core/src/agents/local-providers/service.ts +++ b/packages/core/src/agents/local-providers/service.ts @@ -10,6 +10,7 @@ import { codexCandidate, importCodexProvider, probeCodexProvider } from "@ccr/co import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode"; export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex"; +export { readZcodeLocalProviderCredential, zcodeDefaultBaseUrl } from "@ccr/core/agents/local-providers/zcode"; export { localAgentProviderApiKey, type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[] { diff --git a/packages/core/src/agents/local-providers/zcode.ts b/packages/core/src/agents/local-providers/zcode.ts index f32f3c03..db173c28 100644 --- a/packages/core/src/agents/local-providers/zcode.ts +++ b/packages/core/src/agents/local-providers/zcode.ts @@ -39,7 +39,12 @@ type LocalAgentModelCatalog = { }; const zcodeDefaultModels = ["GLM-5.2", "GLM-5-Turbo"]; -const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic"; +export const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic"; + +export type ZcodeLocalProviderCredential = { + apiKey: string; + baseUrl: string; +}; export function zcodeCandidate(): LocalAgentProviderCandidate { const configuredProvider = readZcodeConfiguredProvider(); @@ -108,6 +113,11 @@ export function importZcodeProvider(candidate: LocalAgentProviderCandidate, prov }; } +export function readZcodeLocalProviderCredential(): ZcodeLocalProviderCredential | undefined { + const provider = readZcodeConfiguredProvider(); + return provider ? { apiKey: provider.apiKey, baseUrl: provider.baseUrl } : undefined; +} + function zcodeProviderAccountConfig(baseUrl: string): ProviderAccountConfig | undefined { return cloneProviderAccountConfig(findProviderPresetByBaseUrl(baseUrl)?.account); } @@ -177,7 +187,7 @@ function readZcodeConfiguredProviders(sourceFile: string): ZcodeConfiguredProvid } function readZcodeRuntime(): { baseUrl: string } & LocalAgentModelCatalog { - const cache = readJsonRecord(path.join(os.homedir(), ".zcode", "v2", "bots-model-cache.v2.json")); + const cache = readJsonRecord(path.join(zcodeStorageRoot(), "v2", "bots-model-cache.v2.json")); const providers = Array.isArray(cache?.providers) ? cache.providers.filter((provider): provider is Record => isRecord(provider)) : []; @@ -292,15 +302,26 @@ function isZcodeModelProvider(providerId: string, provider: Record; +}; + +type CodexOauthRefreshResult = { + accessToken?: string; + accountId?: string; + expiresAtMs: number; + idToken?: string; + isFedrampAccount?: boolean; + refreshToken?: string; + scope?: string; +}; + const defaultRefreshIntervalMs = 5 * 60 * 1000; const minRefreshIntervalMs = 30 * 1000; const maxErrorRefreshIntervalMs = 60 * 1000; @@ -70,7 +91,13 @@ const maxStaleAccountSnapshotMs = 2 * 60 * 1000; const maxCacheEntries = 500; const standardAccountPaths = ["/.well-known/ccr/account", "/v1/account/limits"]; const codexRateLimitResetCreditConsumeEndpoint = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"; +const codexOauthTokenEndpoint = "https://auth.openai.com/oauth/token"; +const codexOauthClientId = "app_EMoamEEZ73f0CkXaXp7hrann"; +const codexOauthDefaultScope = "openid profile email offline_access api.connectors.read api.connectors.invoke"; +const codexOauthRequiredScopes = ["api.connectors.read", "api.connectors.invoke"]; +const codexOauthDefaultTimeoutMs = 8_000; const cache = new Map(); +const codexOauthCache = new Map(); const inFlightRefreshes = new Map>(); let cacheGeneration = 0; @@ -190,6 +217,17 @@ export function newApiUserSelfMetersForTest(payload: unknown): ProviderAccountMe return newApiUserSelfMeters(payload); } +export async function localCodexAccountCredentialForTest(plugin: Record): Promise { + return localCodexAccountCredential(plugin); +} + +export async function localAgentProviderAccountCredentialForTest( + config: Pick, + provider: GatewayProviderConfig +): Promise { + return localAgentProviderAccountCredential(config as AppConfig, provider); +} + export async function resetCodexRateLimitCredit(request: ProviderAccountResetRequest): Promise { const providerName = request.provider?.trim(); const creditId = request.creditId?.trim(); @@ -202,7 +240,7 @@ export async function resetCodexRateLimitCredit(request: ProviderAccountResetReq const config = await loadAppConfig(); const provider = codexResetProvider(config, providerName, request.credentialId); - const materialized = materializeProviderAccountRequest(config, provider); + const materialized = await materializeProviderAccountRequest(config, provider); const payload = await fetchJson( codexRateLimitResetCreditConsumeEndpoint, materialized.provider, @@ -573,7 +611,7 @@ async function resolveStandardConnector( for (const endpoint of endpoints) { try { const request = providerAccountConnectorUsesProviderApiKey(connector) - ? materializeProviderAccountRequest(config, provider) + ? await materializeProviderAccountRequest(config, provider) : { provider }; const payload = await fetchJson(endpoint, request.provider, connector.auth, { ...(connector.headers ?? {}), @@ -603,7 +641,7 @@ async function resolveHttpJsonConnector( connector: ProviderAccountHttpJsonConnectorConfig ): Promise { const request = providerAccountConnectorUsesProviderApiKey(connector) - ? materializeProviderAccountRequest(config, provider) + ? await materializeProviderAccountRequest(config, provider) : { provider }; const payload = await fetchJson(connector.endpoint, request.provider, connector.auth, { ...(connector.headers ?? {}), @@ -1168,15 +1206,15 @@ function normalizeMeterDetail(value: unknown): ProviderAccountMeterDetail | unde return detail.description || detail.effectiveAt || detail.expiresAt || detail.id || detail.label || detail.redeemable !== undefined || detail.status ? detail : undefined; } -function materializeProviderAccountRequest( +async function materializeProviderAccountRequest( config: AppConfig, provider: GatewayProviderConfig -): MaterializedProviderAccountRequest { +): Promise { if (providerApiKey(provider) !== localAgentProviderApiKey) { return { provider }; } - const credential = localAgentProviderAccountCredential(config, provider); + const credential = await localAgentProviderAccountCredential(config, provider); if (!credential?.apiKey) { throw new Error("Local agent account credential was not found. Sign in again, then re-import the local login provider."); } @@ -1198,10 +1236,10 @@ function providerAccountConnectorUsesProviderApiKey( return (connector.auth ?? "provider-api-key") !== "none"; } -function localAgentProviderAccountCredential( +async function localAgentProviderAccountCredential( config: AppConfig, provider: GatewayProviderConfig -): { apiKey?: string; headers?: Record } | undefined { +): Promise { for (const plugin of config.providerPlugins ?? []) { if (!localAgentProviderPluginMatches(plugin, provider)) { continue; @@ -1209,7 +1247,7 @@ function localAgentProviderAccountCredential( const key = readString((plugin as { key?: unknown }).key)?.toLowerCase() ?? ""; if (key.includes("codex-oauth")) { - return localCodexAccountCredential(plugin); + return await localCodexAccountCredential(plugin); } if (key.includes("claude-code-oauth")) { return localBearerAccountCredential(plugin); @@ -1218,6 +1256,19 @@ function localAgentProviderAccountCredential( return localApiKeyHeaderAccountCredential(plugin); } } + if (isLocalCodexProvider(provider)) { + return await localCodexAccountCredential({ + codexOauth: { refreshIfMissingAccessToken: true }, + key: "ccr-local-agent-codex-fallback-codex-oauth", + providerName: provider.name + }); + } + if (isLocalZcodeProvider(provider)) { + const credential = readZcodeLocalProviderCredential(); + if (credential?.apiKey && localZcodeProviderBaseUrlMatches(provider, credential.baseUrl)) { + return { apiKey: credential.apiKey }; + } + } return undefined; } @@ -1236,29 +1287,95 @@ function localAgentProviderPluginMatches(plugin: unknown, provider: GatewayProvi } const providerNames = new Set([ + provider.id, + provider.id && provider.type ? `${provider.id}::${provider.type}` : "", provider.name, provider.type ? `${provider.name}::${provider.type}` : "" - ].map((value) => value.trim().toLowerCase()).filter(Boolean)); + ].map((value) => (value ?? "").trim().toLowerCase()).filter(Boolean)); return providerNames.has(pluginProviderName.trim().toLowerCase()); } -function localCodexAccountCredential(plugin: Record): { apiKey?: string; headers?: Record } { +function isLocalCodexProvider(provider: GatewayProviderConfig): boolean { + return normalizeProviderBaseUrl(providerBaseUrl(provider)) === normalizeProviderBaseUrl(codexDefaultBaseUrl); +} + +function isLocalZcodeProvider(provider: GatewayProviderConfig): boolean { + if (provider.type !== "anthropic_messages") { + return false; + } + const baseUrl = providerBaseUrl(provider); + const normalizedBaseUrl = normalizeProviderBaseUrl(baseUrl); + if (normalizedBaseUrl === normalizeProviderBaseUrl(zcodeDefaultBaseUrl)) { + return true; + } + return zcodeProviderTextMatches([ + provider.id, + provider.name, + baseUrl + ]); +} + +function localZcodeProviderBaseUrlMatches(provider: GatewayProviderConfig, credentialBaseUrl: string): boolean { + const providerBaseUrl = normalizeProviderBaseUrl(providerBaseUrlOrDefault(provider)); + const localBaseUrl = normalizeProviderBaseUrl(credentialBaseUrl); + return providerBaseUrl === localBaseUrl || zcodeProviderTextMatches([providerBaseUrl, localBaseUrl]); +} + +function providerBaseUrlOrDefault(provider: GatewayProviderConfig): string { + return providerBaseUrl(provider) || zcodeDefaultBaseUrl; +} + +function zcodeProviderTextMatches(values: Array): boolean { + const text = values.join(" ").toLowerCase(); + return ( + text.includes("zcode") || + text.includes("z.ai") || + text.includes("bigmodel") || + text.includes("open.bigmodel.cn") + ) && !text.includes("claude-code-router"); +} + +async function localCodexAccountCredential(plugin: Record): Promise { const codexOauth = isRecord(plugin.codexOauth) ? plugin.codexOauth : {}; const codexAuth = readCodexAuth(); // Imported plugins contain a point-in-time access token. Prefer the live Codex // auth file so account checks follow tokens refreshed by Codex CLI/App. - const apiKey = + let apiKey = codexAuth?.accessToken || readString(codexOauth.accessToken) || readString(codexOauth.access_token); - const accountId = + const refreshToken = + codexAuth?.refreshToken || + readString(codexOauth.refreshToken) || + readString(codexOauth.refresh_token); + let accountId = codexAuth?.accountId || readString(codexOauth.accountId) || readString(codexOauth.account_id); + let isFedrampAccount = codexAuth?.isFedrampAccount; + const currentClaims = codexTokenClaims(apiKey); + accountId = accountId || currentClaims?.accountId; + isFedrampAccount = isFedrampAccount ?? currentClaims?.isFedrampAccount; + + if (refreshToken && shouldRefreshCodexAccountToken(apiKey, codexOauth)) { + const refreshed = await refreshCodexAccountToken(codexOauth, refreshToken).catch((error) => { + if (!apiKey || codexAccessTokenExpired(apiKey)) { + throw error; + } + return undefined; + }); + if (refreshed) { + const claims = codexTokenClaims(refreshed.accessToken) ?? codexTokenClaims(refreshed.idToken); + apiKey = refreshed.accessToken || apiKey; + accountId = refreshed.accountId || claims?.accountId || accountId; + isFedrampAccount = refreshed.isFedrampAccount ?? claims?.isFedrampAccount ?? isFedrampAccount; + } + } + const headers = { ...localProviderPluginAuthHeaders(plugin), ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), - ...(codexAuth?.isFedrampAccount ? { "X-OpenAI-Fedramp": "true" } : {}) + ...(isFedrampAccount ? { "X-OpenAI-Fedramp": "true" } : {}) }; return { apiKey, @@ -1266,6 +1383,187 @@ function localCodexAccountCredential(plugin: Record): { apiKey? }; } +function shouldRefreshCodexAccountToken(accessToken: string | undefined, codexOauth: Record): boolean { + if (readBoolean(codexOauth.forceRefresh)) { + return true; + } + if (!accessToken) { + return readBoolean(codexOauth.refreshIfMissingAccessToken) !== false; + } + if (codexAccessTokenExpired(accessToken)) { + return true; + } + const missingScopes = codexMissingRequiredScopes(accessToken); + return Boolean(missingScopes?.length); +} + +async function refreshCodexAccountToken( + codexOauth: Record, + refreshToken: string +): Promise { + const tokenEndpoint = + readString(codexOauth.tokenEndpoint) || + readString(process.env.CODEX_REFRESH_TOKEN_URL_OVERRIDE) || + codexOauthTokenEndpoint; + const clientId = readString(codexOauth.clientId) || codexOauthClientId; + const scope = codexOauthScope(readString(codexOauth.scope)); + const cacheKey = [ + tokenEndpoint, + clientId, + scope, + hashSensitiveValue(refreshToken) + ].join("\n"); + const cached = codexOauthCache.get(cacheKey); + const now = Date.now(); + if (cached?.accessToken && cached.expiresAtMs > now + 60_000) { + return cached; + } + + const timeoutMs = normalizeCodexOauthTimeout(codexOauth.timeoutMs); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchWithSystemProxy(tokenEndpoint, { + body: JSON.stringify({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope + }), + headers: { + "content-type": "application/json" + }, + method: "POST", + signal: controller.signal + }); + const text = await response.text(); + const payload = parseJsonRecord(text); + if (!response.ok) { + throw new Error(`Codex OAuth token refresh returned HTTP ${response.status}${tokenRefreshErrorMessage(payload, text)}`); + } + const accessToken = readString(payload?.access_token) || readString(payload?.accessToken); + if (!accessToken) { + throw new Error("Codex OAuth token refresh did not return an access token."); + } + const idToken = readString(payload?.id_token) || readString(payload?.idToken); + const claims = codexTokenClaims(accessToken) ?? codexTokenClaims(idToken); + const result: CodexOauthRefreshResult = { + accessToken, + accountId: readString(payload?.account_id) || readString(payload?.accountId) || claims?.accountId, + expiresAtMs: codexTokenExpiresAtMs(accessToken) ?? now + 30 * 60 * 1000, + idToken, + isFedrampAccount: claims?.isFedrampAccount, + refreshToken: readString(payload?.refresh_token) || readString(payload?.refreshToken) || refreshToken, + scope: readString(payload?.scope) || readString(payload?.scopes) + }; + codexOauthCache.set(cacheKey, result); + return result; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Codex OAuth token refresh timed out after ${timeoutMs}ms.`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +function codexOauthScope(configuredScope: string | undefined): string { + const scopes = new Set(); + for (const scope of (configuredScope || codexOauthDefaultScope).split(/\s+/)) { + if (scope.trim()) { + scopes.add(scope.trim()); + } + } + for (const scope of codexOauthRequiredScopes) { + scopes.add(scope); + } + return [...scopes].join(" "); +} + +function normalizeCodexOauthTimeout(value: unknown): number { + return Math.max(1, Number.isFinite(value) ? Number(value) : codexOauthDefaultTimeoutMs); +} + +function parseJsonRecord(text: string): Record | undefined { + try { + const payload = JSON.parse(text) as unknown; + return isRecord(payload) ? payload : undefined; + } catch { + return undefined; + } +} + +function tokenRefreshErrorMessage(payload: Record | undefined, text: string): string { + const message = + readString(payload?.error_description) || + readString(payload?.error) || + readString(payload?.message) || + readableResponseSnippet(text); + return message ? `: ${message}` : ""; +} + +function codexAccessTokenExpired(token: string | undefined): boolean { + const expiresAtMs = codexTokenExpiresAtMs(token); + return expiresAtMs !== undefined && expiresAtMs <= Date.now() + 60_000; +} + +function codexTokenExpiresAtMs(token: string | undefined): number | undefined { + const payload = codexJwtPayload(token); + const exp = typeof payload?.exp === "number" ? payload.exp : undefined; + return exp ? exp * 1000 : undefined; +} + +function codexMissingRequiredScopes(token: string): string[] | undefined { + const payload = codexJwtPayload(token); + if (!payload) { + return undefined; + } + const scopes = codexTokenScopes(payload); + if (scopes.length === 0) { + return undefined; + } + return codexOauthRequiredScopes.filter((scope) => !scopes.includes(scope)); +} + +function codexTokenScopes(payload: Record): string[] { + const values: string[] = []; + const scope = readString(payload.scope); + if (scope) { + values.push(...scope.split(/\s+/)); + } + const scp = Array.isArray(payload.scp) ? payload.scp : Array.isArray(payload.scopes) ? payload.scopes : []; + values.push(...scp.map(readString).filter((value): value is string => Boolean(value))); + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function codexTokenClaims(token: string | undefined): { accountId?: string; isFedrampAccount?: boolean } | undefined { + const payload = codexJwtPayload(token); + const auth = isRecord(payload?.["https://api.openai.com/auth"]) + ? payload["https://api.openai.com/auth"] + : {}; + const accountId = + readString(auth.chatgpt_account_id) || + readString(auth.account_id) || + readString(auth.accountId); + const isFedrampAccount = readBoolean(auth.chatgpt_account_is_fedramp); + return accountId || isFedrampAccount !== undefined ? { accountId, isFedrampAccount } : undefined; +} + +function codexJwtPayload(token: string | undefined): Record | undefined { + const encoded = token?.split(".")[1]; + if (!encoded) { + return undefined; + } + try { + const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "="); + const payload = JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as unknown; + return isRecord(payload) ? payload : undefined; + } catch { + return undefined; + } +} + function localBearerAccountCredential(plugin: Record): { apiKey?: string; headers?: Record } { const headers = localProviderPluginAuthHeaders(plugin); const apiKey = readBearerToken(headers.authorization || headers.Authorization); diff --git a/tests/main/provider-account-service.test.mjs b/tests/main/provider-account-service.test.mjs new file mode 100644 index 00000000..33fcfdb3 --- /dev/null +++ b/tests/main/provider-account-service.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + localAgentProviderAccountCredentialForTest, + localCodexAccountCredentialForTest +} from "../../packages/core/src/providers/account-service.ts"; + +const localAgentProviderApiKey = "ccr-local-agent-login"; +const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex"; +const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic"; + +test("Codex local account credential refreshes when only a refresh token is available", async (t) => { + const previousHome = process.env.CCR_INTERNAL_HOME_DIR; + const home = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-account-refresh-")); + mkdirSync(path.join(home, ".codex"), { recursive: true }); + process.env.CCR_INTERNAL_HOME_DIR = home; + t.after(() => { + if (previousHome === undefined) { + delete process.env.CCR_INTERNAL_HOME_DIR; + } else { + process.env.CCR_INTERNAL_HOME_DIR = previousHome; + } + }); + + let requestBody = ""; + let requestUrl = ""; + const accessToken = jwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-refreshed" + }, + exp: Math.floor(Date.now() / 1000) + 3600, + scope: "api.connectors.read api.connectors.invoke" + }); + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requestUrl = String(input); + requestBody = String(init?.body ?? ""); + return new Response( + JSON.stringify({ + access_token: accessToken, + refresh_token: "refresh-next", + scope: "api.connectors.read api.connectors.invoke" + }), + { headers: { "content-type": "application/json" }, status: 200 } + ); + }; + t.after(() => { + globalThis.fetch = previousFetch; + }); + + const credential = await localCodexAccountCredentialForTest({ + codexOauth: { + refreshToken: "refresh-only", + tokenEndpoint: "http://127.0.0.1/oauth/token" + }, + key: "ccr-local-agent-codex-api-codex-oauth", + providerName: "Codex API" + }); + + assert.equal(credential.apiKey, accessToken); + assert.equal(credential.headers?.["ChatGPT-Account-Id"], "acct-refreshed"); + assert.equal(requestUrl, "http://127.0.0.1/oauth/token"); + assert.deepEqual(JSON.parse(requestBody), { + client_id: "app_EMoamEEZ73f0CkXaXp7hrann", + grant_type: "refresh_token", + refresh_token: "refresh-only", + scope: "openid profile email offline_access api.connectors.read api.connectors.invoke" + }); +}); + +test("Codex local account credential matches internal provider plugin names", async (t) => { + useTemporaryCodexHome(t, "ccr-codex-account-internal-plugin-"); + const accessToken = jwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-internal" + }, + exp: Math.floor(Date.now() / 1000) + 3600, + scope: "api.connectors.read api.connectors.invoke" + }); + + const credential = await localAgentProviderAccountCredentialForTest({ + providerPlugins: [ + { + codexOauth: { + accessToken + }, + key: "ccr-local-agent-codex-api-codex-oauth-internal", + providerName: "codex-api::openai_responses" + } + ] + }, { + api_base_url: codexDefaultBaseUrl, + api_key: localAgentProviderApiKey, + id: "codex-api", + models: ["gpt-5-codex"], + name: "Renamed Codex API", + type: "openai_responses" + }); + + assert.equal(credential?.apiKey, accessToken); + assert.equal(credential?.headers?.["ChatGPT-Account-Id"], "acct-internal"); +}); + +test("Codex local account credential falls back to the live auth file when plugin is missing", async (t) => { + const home = useTemporaryCodexHome(t, "ccr-codex-account-live-auth-"); + const codexHome = path.join(home, ".codex"); + mkdirSync(codexHome, { recursive: true }); + const accessToken = jwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-live" + }, + exp: Math.floor(Date.now() / 1000) + 3600, + scope: "api.connectors.read api.connectors.invoke" + }); + writeFileSync(path.join(codexHome, "auth.json"), JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken + } + })); + + const credential = await localAgentProviderAccountCredentialForTest({ + providerPlugins: [] + }, { + api_base_url: codexDefaultBaseUrl, + api_key: localAgentProviderApiKey, + id: "codex-api", + models: ["gpt-5-codex"], + name: "Codex API", + type: "openai_responses" + }); + + assert.equal(credential?.apiKey, accessToken); + assert.equal(credential?.headers?.["ChatGPT-Account-Id"], "acct-live"); +}); + +test("ZCode local account credential matches internal provider plugin names", async () => { + const credential = await localAgentProviderAccountCredentialForTest({ + providerPlugins: [ + { + auth: { + headers: { + "x-api-key": "zcode-plugin-key" + }, + removeHeaders: ["authorization"], + strict: true + }, + key: "ccr-local-agent-zcode-api-zcode-api-key-internal", + providerName: "zcode-api::anthropic_messages" + } + ] + }, { + api_base_url: zcodeDefaultBaseUrl, + api_key: localAgentProviderApiKey, + id: "zcode-api", + models: ["GLM-5.2"], + name: "Renamed ZCode API", + type: "anthropic_messages" + }); + + assert.equal(credential?.apiKey, "zcode-plugin-key"); +}); + +test("ZCode local account credential falls back to the live config when plugin is missing", async (t) => { + const home = useTemporaryCodexHome(t, "ccr-zcode-account-live-config-"); + const zcodeConfigDir = path.join(home, ".zcode", "cli"); + mkdirSync(zcodeConfigDir, { recursive: true }); + writeFileSync(path.join(zcodeConfigDir, "config.json"), JSON.stringify({ + provider: { + zcode: { + enabled: true, + kind: "anthropic", + models: ["GLM-5.2"], + name: "ZCode", + options: { + apiKey: "zcode-live-key", + baseURL: zcodeDefaultBaseUrl + } + } + } + })); + + const credential = await localAgentProviderAccountCredentialForTest({ + providerPlugins: [] + }, { + api_base_url: zcodeDefaultBaseUrl, + api_key: localAgentProviderApiKey, + id: "zcode-api", + models: ["GLM-5.2"], + name: "ZCode API", + type: "anthropic_messages" + }); + + assert.equal(credential?.apiKey, "zcode-live-key"); +}); + +function useTemporaryCodexHome(t, prefix) { + const previousHome = process.env.CCR_INTERNAL_HOME_DIR; + const previousZcodeHome = process.env.ZCODE_HOME; + const previousZcodeStorageDir = process.env.ZCODE_STORAGE_DIR; + const home = mkdtempSync(path.join(os.tmpdir(), prefix)); + mkdirSync(path.join(home, ".codex"), { recursive: true }); + process.env.CCR_INTERNAL_HOME_DIR = home; + delete process.env.ZCODE_HOME; + delete process.env.ZCODE_STORAGE_DIR; + t.after(() => { + if (previousHome === undefined) { + delete process.env.CCR_INTERNAL_HOME_DIR; + } else { + process.env.CCR_INTERNAL_HOME_DIR = previousHome; + } + if (previousZcodeHome === undefined) { + delete process.env.ZCODE_HOME; + } else { + process.env.ZCODE_HOME = previousZcodeHome; + } + if (previousZcodeStorageDir === undefined) { + delete process.env.ZCODE_STORAGE_DIR; + } else { + process.env.ZCODE_STORAGE_DIR = previousZcodeStorageDir; + } + }); + return home; +} + +function jwt(payload) { + return [ + base64url({ alg: "none", typ: "JWT" }), + base64url(payload), + "signature" + ].join("."); +} + +function base64url(value) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} From 48746183cfddfca991d447041d4d7da4f867afc2 Mon Sep 17 00:00:00 2001 From: 810senpai114514 <810senpai114514@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:01:29 +0800 Subject: [PATCH 09/38] feat: web search via Tavily with short-circuit response --- packages/core/src/gateway/service.ts | 307 +++++++++++++++++++-- tests/main/gateway-virtual-models.test.mjs | 4 +- 2 files changed, 288 insertions(+), 23 deletions(-) diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 5936e7aa..d13e7f28 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -859,12 +859,72 @@ class GatewayService { }); if (hostedWebSearchProtocolContext && !this.browserWebSearchMcpIntegration) { - const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); - const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); - const responseBody = JSON.stringify({ error: { message } }); - writeRequestLog(503, responseHeaders, responseBody, false, message); - sendJson(response, 503, { error: { message } }); - return; + const body = parseJsonObjectSafe(bodyToForward); + if (body) { + const queryHint = extractHostedWebSearchQueryHint(body, hostedWebSearchProtocolContext.protocol); + if (queryHint) { + const provider = fusionWebSearchProviderForToolName(this.config, hostedWebSearchProtocolContext.toolName); + const records = provider ? await runWebSearch(queryHint, provider) : []; + if (records.length > 0) { + // Short-circuit: return synthetic response (LiteLLM-style). + // Use server_tool_use + web_search_tool_result (nested) format + // matching https://docs.anthropic.com/en/api/web-search-tool + const toolUseId = `srvtoolu_${randomUUID().replace(/-/g, "").slice(0, 24)}`; + const content: Record[] = []; + content.push({ + type: "server_tool_use", + id: toolUseId, + name: "web_search", + input: { query: queryHint } + }); + const resultItems: Record[] = []; + const textParts: string[] = []; + for (const record of records) { + for (const result of record.results) { + resultItems.push({ + type: "web_search_result", + url: result.url, + title: result.title, + page_age: null, + encrypted_content: "" + }); + const snippet = (result.snippet || result.content || "").slice(0, 500); + textParts.push(`Title: ${result.title}\nURL: ${result.url}\nSnippet: ${snippet}`); + } + } + content.push({ + type: "web_search_tool_result", + tool_use_id: toolUseId, + content: resultItems + }); + if (textParts.length > 0) { + content.push({ type: "text", text: textParts.join("\n\n") }); + } + const syntheticPayload: Record = { + id: `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`, + type: "message", + role: "assistant", + model: routedModel, + content, + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0, server_tool_use: { web_search_requests: records.length } } + }; + const responseBody = JSON.stringify(syntheticPayload); + writeRequestLog(200, new Headers({ "content-type": "application/json" }), responseBody, false); + sendJson(response, 200, syntheticPayload); + return; + } + } + } + if (!hostedWebSearchProtocolContext.records) { + const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); + const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); + const responseBody = JSON.stringify({ error: { message } }); + writeRequestLog(503, responseHeaders, responseBody, false, message); + sendJson(response, 503, { error: { message } }); + return; + } } if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) { @@ -1166,7 +1226,14 @@ async function writeCoreGatewayConfig( ...pluginService.getVirtualModelProfiles() ])), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + const proxyUrl = process.env.CCR_UPSTREAM_PROXY_URL || process.env.HTTPS_PROXY || process.env.https_proxy; + const proxyPreloadFile = proxyUrl + ? pathJoin(dirname(config.gateway.generatedConfigFile), "gateway-proxy-preload.cjs") + : undefined; + const proxyEnv = proxyUrl ? { CCR_UPSTREAM_PROXY_URL: proxyUrl, CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule() } : undefined; + const builtinToolArtifacts = await fusionBuiltinToolArtifacts( + virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration, proxyPreloadFile, proxyEnv + ); const providers = [ ...config.Providers .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) @@ -1461,7 +1528,9 @@ async function fusionBuiltinToolArtifacts( profiles: unknown[], coreEndpoint: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + proxyPreloadFile?: string, + proxyEnv?: Record ): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { const providers: CoreGatewayProvider[] = []; const mcpServers: GatewayMcpServerConfig[] = []; @@ -1495,7 +1564,9 @@ async function fusionBuiltinToolArtifacts( ...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}), ...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {}) }, - name: `fusion-vision-${sanitizedProfileId}` + name: `fusion-vision-${sanitizedProfileId}`, + proxyPreloadFile, + proxyEnv })); } } @@ -1528,7 +1599,9 @@ async function fusionBuiltinToolArtifacts( ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), ...(webSearchConfig.env ?? {}) }, - name: `fusion-web-search-${sanitizedProfileId}` + name: `fusion-web-search-${sanitizedProfileId}`, + proxyPreloadFile, + proxyEnv })); } } @@ -1542,25 +1615,32 @@ export async function fusionBuiltinToolArtifactsForTest( profiles: unknown[], coreEndpoint: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + proxyPreloadFile?: string, + proxyEnv?: Record ): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> { - return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration, proxyPreloadFile, proxyEnv); } function fusionBuiltinMcpServer({ entry, env, - name + name, + proxyPreloadFile, + proxyEnv }: { entry: string; env: Record; name: string; + proxyPreloadFile?: string; + proxyEnv?: Record; }): GatewayMcpServerConfig { return { - args: [entry], + args: proxyPreloadFile ? ["--require", proxyPreloadFile, entry] : [entry], command: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1", + ...(proxyEnv ?? {}), ...env }, name, @@ -3257,15 +3337,16 @@ export function hostedWebSearchProtocolResponseStream( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration | undefined ): Readable { - if (!integration?.recentBrowserWebSearchResults && !integration?.runBrowserWebSearch) { + const hasIntegration = integration?.recentBrowserWebSearchResults !== undefined || integration?.runBrowserWebSearch !== undefined; + if (!hasIntegration && !context.records?.length) { return input; } const contentType = headers.get("content-type")?.toLowerCase() ?? ""; if (contentType.includes("text/event-stream")) { if (context.protocol === "anthropic_messages") { - return anthropicHostedWebSearchProtocolSseStream(input, context, integration); + return anthropicHostedWebSearchProtocolSseStream(input, context, integration!); } - return hostedWebSearchProtocolSseStream(input, context, integration); + return hostedWebSearchProtocolSseStream(input, context, integration!); } if (!contentType.includes("application/json")) { return input; @@ -3280,7 +3361,7 @@ export function hostedWebSearchProtocolResponseStream( flush(callback) { const body = Buffer.concat(chunks).toString("utf8"); void (async () => { - const records = await selectHostedWebSearchProtocolRecords(context, integration); + const records = context.records?.length ? context.records : await selectHostedWebSearchProtocolRecords(context, integration!); if (records.length === 0) { this.push(body); return; @@ -3302,7 +3383,9 @@ function hostedWebSearchProtocolSseStream( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration ): Readable { - const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + const recordsPromise = context.records?.length + ? Promise.resolve(context.records) + : selectHostedWebSearchProtocolRecords(context, integration); let records: BrowserWebSearchProtocolRecord[] | undefined; let pending = ""; let passThrough = false; @@ -3498,7 +3581,9 @@ function anthropicHostedWebSearchProtocolSseStream( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration ): Readable { - const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + const recordsPromise = context.records?.length + ? Promise.resolve(context.records) + : selectHostedWebSearchProtocolRecords(context, integration); let records: BrowserWebSearchProtocolRecord[] | undefined; let pending = ""; let passThrough = false; @@ -4723,8 +4808,10 @@ function textPartsFromGeminiContents(contents: unknown): string[] { } export function fusionWebSearchToolNameForRequest(config: AppConfig, model: string | undefined): string | undefined { + // Router already determines which Fusion profile handles web search. + // Match the requested model against all web search candidates (browser + non-browser). const normalizedModel = model ? fusionModelNameFromSelector(model) : ""; - for (const candidate of fusionBrowserWebSearchToolCandidates(config)) { + for (const candidate of allWebSearchToolCandidates(config)) { if (!normalizedModel || candidate.aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { return candidate.toolName; } @@ -4732,6 +4819,41 @@ export function fusionWebSearchToolNameForRequest(config: AppConfig, model: stri return undefined; } +function allWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string; provider: string | undefined }> { + const browser = fusionBrowserWebSearchToolCandidates(config).map((c) => ({ ...c, provider: "browser" as const })); + const nonBrowser = fusionWebSearchToolCandidates(config); + return [...browser, ...nonBrowser]; +} + +function fusionWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string; provider: string | undefined }> { + const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; + const profiles = normalizeCoreGatewayVirtualModelProfiles( + withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), + config + ); + const candidates: Array<{ aliases: string[]; toolName: string; provider: string | undefined }> = []; + 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) { + 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))); + candidates.push({ aliases, provider: webSearchConfig.provider, toolName: webSearchConfig.toolName }); + } + return candidates; +} + function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string }> { const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; const profiles = normalizeCoreGatewayVirtualModelProfiles( @@ -4761,6 +4883,149 @@ function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliase return candidates; } +function fusionWebSearchProviderForToolName(config: AppConfig, toolName: string): string | undefined { + const candidate = fusionWebSearchToolCandidates(config).find((c) => c.toolName === toolName); + return candidate && candidate.provider !== "browser" ? candidate.provider : undefined; +} + +async function runWebSearch(query: string, provider: string): Promise { + switch (provider) { + case "tavily": return searchTavily(query); + case "brave": return searchBrave(query); + case "bing": return searchBing(query); + case "google_cse": return searchGoogleCse(query); + case "serper": return searchSerper(query); + case "serpapi": return searchSerpApi(query); + case "exa": return searchExa(query); + default: + console.log(`[gateway] Unknown search provider: ${provider}`); + return []; + } +} + +async function searchTavily(query: string): Promise { + const apiKey = process.env.TAVILY_API_KEY; + if (!apiKey) { console.log(`[gateway] Tavily: API key not set`); return []; } + try { + const response = await fetchWithSystemProxy("https://api.tavily.com/search", { + body: JSON.stringify({ api_key: apiKey, query, max_results: 5, search_depth: "basic" }), + headers: { "content-type": "application/json" }, + method: "POST", signal: AbortSignal.timeout(15000) + }); + if (!response.ok) { console.log(`[gateway] Tavily returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.results) ? data.results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "tavily", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.content), title: stringValue(r.title) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://tavily.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Tavily error: ${formatError(error)}`); return []; } +} + +async function searchBrave(query: string): Promise { + const apiKey = process.env.BRAVE_SEARCH_API_KEY; + if (!apiKey) { console.log(`[gateway] Brave: API key not set`); return []; } + try { + const url = new URL("https://api.search.brave.com/res/v1/web/search"); + url.searchParams.set("q", query); url.searchParams.set("count", "5"); + const response = await fetchWithSystemProxy(url.toString(), { headers: { "x-subscription-token": apiKey }, signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] Brave returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = isRecord(data.web) && Array.isArray(data.web.results) ? data.web.results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "brave", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.description), title: stringValue(r.title) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://search.brave.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Brave error: ${formatError(error)}`); return []; } +} + +async function searchBing(query: string): Promise { + const apiKey = process.env.BING_SEARCH_API_KEY; + if (!apiKey) { console.log(`[gateway] Bing: API key not set`); return []; } + try { + const url = new URL("https://api.bing.microsoft.com/v7.0/search"); + url.searchParams.set("q", query); url.searchParams.set("count", "5"); url.searchParams.set("mkt", "en-US"); + const response = await fetchWithSystemProxy(url.toString(), { headers: { "ocp-apim-subscription-key": apiKey }, signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] Bing returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = isRecord(data.webPages) && Array.isArray(data.webPages.value) ? data.webPages.value : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "bing", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.name) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://www.bing.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Bing error: ${formatError(error)}`); return []; } +} + +async function searchGoogleCse(query: string): Promise { + const apiKey = process.env.GOOGLE_SEARCH_API_KEY; const cx = process.env.GOOGLE_SEARCH_CX; + if (!apiKey || !cx) { console.log(`[gateway] Google CSE: API key or CX not set`); return []; } + try { + const url = new URL("https://www.googleapis.com/customsearch/v1"); + url.searchParams.set("key", apiKey); url.searchParams.set("cx", cx); url.searchParams.set("q", query); url.searchParams.set("num", "5"); + const response = await fetchWithSystemProxy(url.toString(), { signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] Google CSE returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.items) ? data.items : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "google_cse", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.title) || "", url: stringValue(r.link) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://cse.google.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Google CSE error: ${formatError(error)}`); return []; } +} + +async function searchSerper(query: string): Promise { + const apiKey = process.env.SERPER_API_KEY; + if (!apiKey) { console.log(`[gateway] Serper: API key not set`); return []; } + try { + const response = await fetchWithSystemProxy("https://google.serper.dev/search", { + body: JSON.stringify({ q: query, num: 5 }), headers: { "content-type": "application/json", "x-api-key": apiKey }, + method: "POST", signal: AbortSignal.timeout(15000) + }); + if (!response.ok) { console.log(`[gateway] Serper returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.organic) ? data.organic : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "serper", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.title) || "", url: stringValue(r.link) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://serper.dev", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Serper error: ${formatError(error)}`); return []; } +} + +async function searchSerpApi(query: string): Promise { + const apiKey = process.env.SERPAPI_API_KEY; + if (!apiKey) { console.log(`[gateway] SerpAPI: API key not set`); return []; } + try { + const url = new URL("https://serpapi.com/search.json"); + url.searchParams.set("api_key", apiKey); url.searchParams.set("engine", "google"); url.searchParams.set("q", query); url.searchParams.set("num", "5"); + const response = await fetchWithSystemProxy(url.toString(), { signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] SerpAPI returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.organic_results) ? data.organic_results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "serpapi", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.title) || "", url: stringValue(r.link) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://serpapi.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] SerpAPI error: ${formatError(error)}`); return []; } +} + +async function searchExa(query: string): Promise { + const apiKey = process.env.EXA_API_KEY; + if (!apiKey) { console.log(`[gateway] Exa: API key not set`); return []; } + try { + const response = await fetchWithSystemProxy("https://api.exa.ai/search", { + body: JSON.stringify({ query, numResults: 5 }), headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + method: "POST", signal: AbortSignal.timeout(15000) + }); + if (!response.ok) { console.log(`[gateway] Exa returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.results) ? data.results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "exa", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.text), title: stringValue(r.title) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://exa.ai", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Exa error: ${formatError(error)}`); return []; } +} + async function selectHostedWebSearchProtocolRecords( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index df4f1465..fa60136e 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -509,7 +509,7 @@ test("gateway does not route hosted web search through an unrelated Fusion searc assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search"); }); -test("gateway resolves only browser-backed Fusion web search tools for hosted protocol bridging", () => { +test("gateway resolves non-browser Fusion web search tools for hosted protocol bridging", () => { const config = { Providers: [], Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, @@ -538,7 +538,7 @@ test("gateway resolves only browser-backed Fusion web search tools for hosted pr ] }; - assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), undefined); + assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), "research_web_search"); assert.equal(fusionWebSearchToolNameForRequest(config, "gpt-5"), undefined); }); From 1ff88a7a3e36d4e431fe13a713105a5c647e9076 Mon Sep 17 00:00:00 2001 From: musistudio Date: Sun, 12 Jul 2026 18:28:01 +0800 Subject: [PATCH 10/38] Remove the standalone server settings view --- packages/ui/src/pages/home/App.tsx | 154 ++------------- .../ui/src/pages/home/components/index.ts | 1 - .../ui/src/pages/home/components/layout.tsx | 7 +- .../ui/src/pages/home/components/server.tsx | 180 ------------------ .../ui/src/pages/home/components/settings.tsx | 68 ++++++- packages/ui/src/pages/home/shared/options.ts | 2 - 6 files changed, 84 insertions(+), 328 deletions(-) delete mode 100644 packages/ui/src/pages/home/components/server.tsx diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index b7e9623a..983a6cf6 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -10,8 +10,8 @@ import { createVirtualModelDraft, createVirtualModelDraftFromProfile, customProviderPresetId, DEFAULT_TRAY_WIDGETS, detectSystemLanguage, detectSystemTheme, enforceSingleEnabledGlobalProfilePerAgent, ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig, - fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage, - fallbackUpdateStatus, fallbackUsageStats, formatAppError, formatProxyCertificateInstallMessage, GatewayProviderConfig, + fallbackGatewayStatus, fallbackInfo, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage, + fallbackUpdateStatus, fallbackUsageStats, formatAppError, GatewayProviderConfig, fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile, GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid, isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, @@ -26,10 +26,10 @@ import { persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, - providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, + providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, - ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, ServerActionBusy, SettingsPageId, - routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, + ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, SettingsPageId, + routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect, useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, VirtualModelDraft, virtualModelProfileFromDraft @@ -193,10 +193,8 @@ function App() { const [onboardingStatusLoaded, setOnboardingStatusLoaded] = useState(() => !window.ccr); const [providerPresetsLoaded, setProviderPresetsLoaded] = useState(() => !window.ccr); const [gatewayStatus, setGatewayStatus] = useState(fallbackGatewayStatus); - const [proxyCertificateStatus, setProxyCertificateStatus] = useState(fallbackProxyCertificateStatus); const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState(fallbackProxyNetworkSnapshot); const [proxyStatus, setProxyStatus] = useState(fallbackProxyStatus); - const [actionBusy, setActionBusy] = useState(""); const [updateActionBusy, setUpdateActionBusy] = useState<"" | "check" | "download" | "install">(""); const [updateActionError, setUpdateActionError] = useState(""); const [updateDialogOpen, setUpdateDialogOpen] = useState(false); @@ -233,8 +231,6 @@ function App() { const [providerDeepLinkRequest, setProviderDeepLinkRequest] = useState(); const [providerDeepLinkBusy, setProviderDeepLinkBusy] = useState(false); const [providerDeepLinkError, setProviderDeepLinkError] = useState(""); - const [proxyCertificateChecking, setProxyCertificateChecking] = useState(false); - const [proxyEnablePending, setProxyEnablePending] = useState(false); const [providerProbeError, setProviderProbeError] = useState(""); const [extensionInstallOpen, setExtensionInstallOpen] = useState(false); const [extensionInstallDraft, setExtensionInstallDraft] = useState(() => createExtensionInstallDraft()); @@ -351,7 +347,6 @@ function App() { .catch(() => setActiveView("onboarding")) .finally(() => setOnboardingStatusLoaded(true)); void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([])); - void window.ccr.getProxyCertificateStatus().then(setProxyCertificateStatus); const unsubscribeOpenSettings = window.ccr.onOpenSettingsRequest(openSettingsDialog); const unsubscribeOpenUpdate = window.ccr.onOpenUpdateRequest(openUpdateDialog); const refreshRuntimeStatus = () => { @@ -721,7 +716,7 @@ function App() { useEffect(() => { if (!networkCaptureEnabled && activeView === "networking") { - setActiveView("server"); + setActiveView("overview"); } }, [activeView, networkCaptureEnabled]); @@ -2231,6 +2226,11 @@ function App() { setSettingsOpen(true); } + function openGeneralSettingsDialog() { + setSettingsInitialPage("general"); + setSettingsOpen(true); + } + function changeOverviewWidgets(widgets: OverviewWidgetConfig[]) { updateConfig((config) => ({ ...config, @@ -2244,26 +2244,6 @@ function App() { persistLanguagePreference(language); } - async function restartProxy() { - if (!window.ccr) { - setActionError(t("Proxy restart is available in the Electron app.")); - return; - } - - setActionBusy("proxy"); - setActionError(""); - setActionMessage(""); - try { - const status = await window.ccr.restartProxy(); - setProxyStatus(status); - setActionMessage(translateAppErrorMessage(copy, proxyRestartMessage(status))); - } catch (error) { - setActionError(formatError(error)); - } finally { - setActionBusy(""); - } - } - async function completeOnboarding() { if (window.ccr) { try { @@ -2282,60 +2262,6 @@ function App() { setActiveView(id); } - async function refreshProxyCertificateStatus(): Promise { - if (!window.ccr) { - setProxyCertificateStatus(fallbackProxyCertificateStatus); - return undefined; - } - const status = await window.ccr.getProxyCertificateStatus(); - setProxyCertificateStatus(status); - return status; - } - - async function checkProxyCertificateStatus() { - setProxyCertificateChecking(true); - setActionError(""); - setActionMessage(""); - try { - const status = await refreshProxyCertificateStatus(); - setActionMessage(status?.trusted ? t("Proxy CA certificate is trusted.") : translateProxyCertificateMessage(status?.message, t) || t("Proxy CA certificate is not trusted.")); - } catch (error) { - setActionError(formatError(error)); - } finally { - setProxyCertificateChecking(false); - } - } - - async function setProxyEnabled(checked: boolean) { - setActionError(""); - setActionMessage(""); - if (!checked) { - setProxyEnablePending(false); - updateConfig((next) => ({ ...next, proxy: { ...next.proxy, enabled: false } })); - return; - } - if (!window.ccr) { - setActionError(t("Proxy certificate detection is available in the Electron app.")); - return; - } - - setProxyCertificateChecking(true); - try { - const status = await refreshProxyCertificateStatus(); - if (status?.trusted) { - setProxyEnablePending(false); - updateConfig((next) => ({ ...next, proxy: { ...next.proxy, enabled: true } })); - return; - } - setProxyEnablePending(true); - setActionMessage(translateProxyCertificateMessage(status?.message, t) || t("Install and trust the proxy CA certificate before enabling proxy mode.")); - } catch (error) { - setActionError(formatError(error)); - } finally { - setProxyCertificateChecking(false); - } - } - async function toggleGatewayService() { if (!window.ccr) { setActionError(t("Service control is available in the Electron app.")); @@ -2359,33 +2285,6 @@ function App() { } } - async function installProxyCertificate() { - if (!window.ccr) { - setActionError(t("Certificate install is available in the Electron app.")); - return; - } - - setActionBusy("cert"); - setActionError(""); - setActionMessage(""); - try { - const result = await window.ccr.installProxyCertificate(); - setProxyCertificateStatus(result.status); - const status = result.status.trusted ? result.status : await refreshProxyCertificateStatus(); - if (proxyEnablePending && status?.trusted) { - updateConfig((next) => ({ ...next, proxy: { ...next.proxy, enabled: true } })); - setProxyEnablePending(false); - setActionMessage(t("Certificate installed and trusted. Proxy mode enabled.")); - return; - } - setActionMessage(formatProxyCertificateInstallMessage(result, status, t)); - } catch (error) { - setActionError(formatError(error)); - } finally { - setActionBusy(""); - } - } - async function refreshProxyNetworkCaptures() { if (!window.ccr) { setProxyNetworkSnapshot(fallbackProxyNetworkSnapshot); @@ -2475,7 +2374,7 @@ function App() { updateConfig((next) => ({ ...next, proxy: { ...next.proxy, captureNetwork: enabled } })); setProxyNetworkSnapshot((current) => ({ ...current, captureEnabled: enabled })); if (!enabled && activeView === "networking") { - setActiveView("server"); + setActiveView("overview"); } if (!window.ccr) { return; @@ -2488,12 +2387,6 @@ function App() { } } - function setProxySystemProxyEnabled(enabled: boolean) { - setActionError(""); - setActionMessage(""); - updateConfig((next) => ({ ...next, proxy: { ...next.proxy, systemProxy: enabled } })); - } - function openAddProfileDialog(agent: ProfileConfig["agent"] = profileAgentTab) { setProfileAgentTab(agent); setProfileDraft(createProfileDraft(agent)); @@ -2913,6 +2806,7 @@ function App() { activeView={activeView} agentAnalysisEnabled={agentAnalysisEnabled} compactLayout={compactLayout} + config={draftConfig} copy={copy} gatewayActionBusy={gatewayActionBusy} gatewayEndpoint={gatewayEndpoint} @@ -2922,7 +2816,7 @@ function App() { needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} onOpenUpdate={openSidebarUpdateDialog} - onOpenServerSettings={() => setActiveView("server")} + onOpenServerSettings={openGeneralSettingsDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} @@ -3039,22 +2933,6 @@ function App() { }), updateRule: updateRoutingRule }, - server: { - actionBusy, - actionError, - actionMessage, - config: draftConfig, - installProxyCertificate, - onProxyEnabledChange: (checked) => void setProxyEnabled(checked), - onProxyNetworkCaptureChange: (enabled) => void setProxyNetworkCaptureEnabled(enabled), - onProxySystemProxyChange: setProxySystemProxyEnabled, - proxyCertificateChecking, - proxyCertificateStatus, - proxyStatus, - refreshProxyCertificateStatus: () => void checkProxyCertificateStatus(), - restartProxy, - updateConfig - }, virtualModels: { addVirtualModel: openAddVirtualModelDialog, editVirtualModel: openEditVirtualModelDialog, @@ -3245,6 +3123,7 @@ function App() { appInfo, botAddRequestKey: settingsBotAddRequestKey, botConfigs: draftConfig.botConfigs, + config: draftConfig, copy, initialPage: settingsInitialPage, languagePreference, @@ -3272,7 +3151,8 @@ function App() { trayBalanceProgress: normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress), trayIconPreference: draftConfig.trayIcon || "random", traySupported, - trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants) + trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants), + updateConfig } : undefined} update={updateDialogOpen ? { actionBusy: updateActionBusy, diff --git a/packages/ui/src/pages/home/components/index.ts b/packages/ui/src/pages/home/components/index.ts index edd7faa2..4bb5f887 100644 --- a/packages/ui/src/pages/home/components/index.ts +++ b/packages/ui/src/pages/home/components/index.ts @@ -6,7 +6,6 @@ export { AppSettingsDialog } from "./settings"; export { UpdateDialog } from "./update"; export { OverviewView, AgentAnalysisView } from "./dashboard"; export { ApiKeysView, AddApiKeyDialog, EditApiKeyDialog } from "./api-keys"; -export { ServerView } from "./server"; export { ProfileView, AddProfileForm, AddProfileDialog } from "./profiles"; export { NetworkingView, LogsView } from "./network-logs"; export { ProvidersView, ModelsView, DeleteProviderDialog, ProviderDeepLinkDialog, AddProviderForm, AddProviderDialog } from "./providers"; diff --git a/packages/ui/src/pages/home/components/layout.tsx b/packages/ui/src/pages/home/components/layout.tsx index 5affef54..2e2dcc80 100644 --- a/packages/ui/src/pages/home/components/layout.tsx +++ b/packages/ui/src/pages/home/components/layout.tsx @@ -14,7 +14,6 @@ import { OnboardingView } from "./onboarding"; import { ProfileView } from "./profiles"; import { ModelsView, ProvidersView } from "./providers"; import { RoutingView } from "./routing"; -import { ServerView } from "./server"; import { VirtualModelsView } from "./virtual-models"; type MainNavigationItem = { @@ -33,7 +32,6 @@ type MainViewProps = { profile: ComponentProps; providers: ComponentProps; routing: ComponentProps; - server: ComponentProps; virtualModels: ComponentProps; }; @@ -60,6 +58,7 @@ export function OnboardingLayout({ export function MainLayout({ activeView, compactLayout, + config, copy, gatewayActionBusy, gatewayEndpoint, @@ -86,6 +85,7 @@ export function MainLayout({ activeView: ViewId; agentAnalysisEnabled: boolean; compactLayout: boolean; + config: AppConfig; copy: AppCopy; gatewayActionBusy: boolean; gatewayEndpoint: string; @@ -259,7 +259,7 @@ export function MainLayout({
) : null} @@ -352,7 +352,6 @@ function MainViewSwitch({ {activeView === "overview" ? : null} {activeView === "observability" && agentAnalysisEnabled ? : null} {activeView === "api-keys" ? : null} - {activeView === "server" ? : null} {activeView === "profile" ? : null} {activeView === "networking" && networkCaptureEnabled ? : null} {activeView === "logs" && requestLogsEnabled ? : null} diff --git a/packages/ui/src/pages/home/components/server.tsx b/packages/ui/src/pages/home/components/server.tsx deleted file mode 100644 index 378d2548..00000000 --- a/packages/ui/src/pages/home/components/server.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - AnimatedIconSwap, AppConfig, Badge, Button, Card, CardContent, CardHeader, - CardTitle, certificateStatusLabel, certificateStatusVariant, cn, endpointFromHostPort, Field, - Input, LoaderCircle, motion, numberValue, ProxyCertificateStatus, proxyCertificateTrustSteps, - ProxyStatus, RefreshCw, ServerActionBusy, ShieldCheck, StatusBadge, Toggle, - translateProxyCertificateMessage, useAppText -} from "../shared/index"; -export function ServerView({ - actionBusy, - actionError, - actionMessage, - config, - installProxyCertificate, - onProxyEnabledChange, - onProxyNetworkCaptureChange, - onProxySystemProxyChange, - proxyCertificateChecking, - proxyCertificateStatus, - proxyStatus, - refreshProxyCertificateStatus, - restartProxy, - updateConfig -}: { - actionBusy: ServerActionBusy; - actionError: string; - actionMessage: string; - config: AppConfig; - installProxyCertificate: () => void; - onProxyEnabledChange: (checked: boolean) => void; - onProxyNetworkCaptureChange: (enabled: boolean) => void; - onProxySystemProxyChange: (enabled: boolean) => void; - proxyCertificateChecking: boolean; - proxyCertificateStatus: ProxyCertificateStatus; - proxyStatus: ProxyStatus; - refreshProxyCertificateStatus: () => void; - restartProxy: () => void; - updateConfig: (mutator: (config: AppConfig) => AppConfig) => void; -}) { - const t = useAppText(); - const trustSteps = proxyCertificateTrustSteps(proxyCertificateStatus); - const certificateMessage = translateProxyCertificateMessage(proxyCertificateStatus.message, t); - - return ( - - - - {t("Server")} - - -
- - updateConfig((next) => { - const host = event.target.value; - return { - ...next, - HOST: host, - gateway: { ...next.gateway, host }, - routerEndpoint: endpointFromHostPort(host, next.PORT) - }; - })} - /> - - - updateConfig((next) => { - const port = numberValue(event.target.value); - return { - ...next, - PORT: port, - gateway: { ...next.gateway, port }, - routerEndpoint: endpointFromHostPort(next.HOST, port) - }; - })} - /> - - -
- - {proxyCertificateChecking ? t("Checking CA certificate...") : config.proxy.enabled ? t("Enabled") : t("Disabled")} - - -
-
- {config.proxy.enabled ? ( - <> - -
- - {config.proxy.systemProxy ? t("Enabled") : t("Disabled")} - - -
-
- -
- - {config.proxy.captureNetwork ? t("Enabled") : t("Disabled")} - - -
-
- - ) : null} -
- - {config.proxy.enabled || !proxyCertificateStatus.trusted ? ( -
-
- {t("CA certificate")} - - {t(certificateStatusLabel(proxyCertificateStatus))} - -
- {!proxyCertificateStatus.trusted ? ( -
-
{certificateMessage}
-
- {trustSteps.map((step, index) => ( -
- {index + 1}. - {t(step)} -
- ))} -
-
{proxyCertificateStatus.caCertFile}
-
- ) : null} - {config.proxy.enabled ? ( -
- {t("Proxy status")} - -
- ) : null} -
- - - {config.proxy.enabled ? ( - - ) : null} -
-
- ) : null} - - {actionError || actionMessage ? ( -
- {actionError || actionMessage} -
- ) : null} -
-
-
- ); -} diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index b37605ab..6a8cb22e 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -3,10 +3,10 @@ import { botGatewayDefaultAuthType, botGatewayFieldsForAuth, botGatewayPickAuthFields, botGatewayPlatformLabel, botGatewayPlatformOptions, botGatewaySavedConfigFromDraft, botGatewaySavedConfigLabel, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent, - DialogFooter, DialogHeader, DialogTitle, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge, + DialogFooter, DialogHeader, DialogTitle, endpointFromHostPort, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge, Globe, createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, createRouteModelOptions, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle, - mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeProxyUpstreamConfig, normalizeToolHubConfig, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel, + mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeProxyUpstreamConfig, normalizeToolHubConfig, numberValue, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel, PanelLeftOpen, Power, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl, PointerSensor, rectSortingStrategy, Settings, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName, translateOptions, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, @@ -20,6 +20,7 @@ export function AppSettingsDialog({ appInfo, botAddRequestKey, botConfigs, + config, copy, initialPage = "appearance", languagePreference, @@ -47,11 +48,13 @@ export function AppSettingsDialog({ traySupported, trayBalanceProgress, trayIconPreference, - trayWidgets + trayWidgets, + updateConfig }: { appInfo: AppInfo; botAddRequestKey?: number; botConfigs: BotGatewaySavedConfig[]; + config: AppConfig; copy: AppCopy; initialPage?: SettingsPageId; languagePreference: AppLanguagePreference; @@ -80,6 +83,7 @@ export function AppSettingsDialog({ trayBalanceProgress?: TrayBalanceProgressConfig; trayIconPreference: AppConfig["trayIcon"]; trayWidgets: TrayWidgetConfig[]; + updateConfig: (mutator: (config: AppConfig) => AppConfig) => void; }) { return ( ); } @@ -334,24 +340,29 @@ function AppearanceSettingsPage({ function GeneralSettingsPage({ appInfo, + config, copy, launchAtLogin, launchAtLoginSupported, onChangeLaunchAtLogin, onChangeProxy, - proxy + proxy, + updateConfig }: { appInfo: AppInfo; + config: AppConfig; copy: AppCopy; launchAtLogin: boolean; launchAtLoginSupported: boolean; onChangeLaunchAtLogin: (checked: boolean) => void; onChangeProxy: (patch: Partial) => void; proxy: AppConfig["proxy"]; + updateConfig: (mutator: (config: AppConfig) => AppConfig) => void; }) { return (

{copy.settings.general}

+ {launchAtLoginSupported ? ( AppConfig) => void; +}) { + const t = (value: string) => copy.text[value] ?? value; + + return ( +
+

{t("Server")}

+
+ + updateConfig((next) => { + const host = event.target.value; + return { + ...next, + HOST: host, + gateway: { ...next.gateway, host }, + routerEndpoint: endpointFromHostPort(host, next.PORT) + }; + })} + /> + + + updateConfig((next) => { + const port = numberValue(event.target.value); + return { + ...next, + PORT: port, + gateway: { ...next.gateway, port }, + routerEndpoint: endpointFromHostPort(next.HOST, port) + }; + })} + /> + +
+
+ ); +} + function ProxySettingsSection({ copy, onChange, diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index 3f47852d..5fa352ec 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -9,7 +9,6 @@ import { Layers3, Network, Route, - Server, UserRound, type LucideIcon } from "lucide-react"; @@ -362,7 +361,6 @@ export const navigation: Array<{ icon: LucideIcon; id: NavigationId }> = [ { icon: Box, id: "models" }, { icon: Activity, id: "observability" }, { icon: Database, id: "logs" }, - { icon: Server, id: "server" }, { icon: Network, id: "networking" }, { icon: Braces, id: "extensions" } ]; From 0196fdebeb67b698c4affa699025bb279e96a6b1 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 08:48:39 +0800 Subject: [PATCH 11/38] Add Grok CLI local provider support --- .../core/src/agents/local-providers/grok.ts | 764 ++++++++++++++++++ .../src/agents/local-providers/service.ts | 6 + packages/core/src/config/config.ts | 3 +- packages/core/src/contracts/app.ts | 4 +- packages/core/src/gateway/service.ts | 40 +- .../core/src/providers/account-service.ts | 141 ++++ packages/ui/src/assets/agent-logos/grok.ico | Bin 0 -> 15406 bytes .../src/pages/home/components/providers.tsx | 5 +- .../ui/src/pages/home/shared/extensions.ts | 1 + packages/ui/src/pages/home/shared/i18n.tsx | 10 +- .../ui/src/pages/home/shared/providers.ts | 16 + tests/main/local-agent-provider-grok.test.mjs | 320 ++++++++ tests/main/provider-account-service.test.mjs | 91 ++- tests/renderer/providers.test.ts | 12 + 14 files changed, 1402 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/agents/local-providers/grok.ts create mode 100644 packages/ui/src/assets/agent-logos/grok.ico create mode 100644 tests/main/local-agent-provider-grok.test.mjs diff --git a/packages/core/src/agents/local-providers/grok.ts b/packages/core/src/agents/local-providers/grok.ts new file mode 100644 index 00000000..04ab7bf0 --- /dev/null +++ b/packages/core/src/agents/local-providers/grok.ts @@ -0,0 +1,764 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + GatewayProviderConfig, + LocalAgentProviderCandidate, + LocalAgentProviderImportResult, + ProviderAccountConfig, + ProviderAccountConnectorConfig, + ProviderAccountMappingConfig, + ProviderModelMetadata +} from "@ccr/core/contracts/app"; +import { + bearerAuthPlugin, + firstString, + isRecord, + localAgentProviderApiKey, + missingCandidate, + modelDisplayNamesForModels, + modelMetadataForModels, + providerInternalNamePlaceholder, + providerPayload, + readBoolean, + readJsonRecord, + readString, + uniqueProviderName, + uniqueStrings, + type OAuthTokenSet +} from "@ccr/core/agents/local-providers/shared"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; +import { normalizeProviderBaseUrl } from "@ccr/core/providers/url"; + +export const grokDefaultBaseUrl = "https://cli-chat-proxy.grok.com/v1"; +export const grokDefaultBillingEndpoint = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +export const grokDefaultSubscriptionEndpoint = "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; + +const grokDefaultModels = ["grok-4.5"]; +const grokProviderId = "grok-cli-api"; +const grokProviderName = "Grok CLI API"; +const grokDefaultOidcIssuer = "https://auth.x.ai"; +const grokOauthDefaultTimeoutMs = 8_000; + +const grokBillingResetPaths = [ + "$.billingPeriodEnd", + "$.currentPeriod.end", + "$.currentPeriod.billingPeriodEnd", + "$.config.billingPeriodEnd", + "$.config.currentPeriod.end", + "$.end" +]; + +const grokBillingMapping: ProviderAccountMappingConfig = { + meters: [ + { + id: "grok_credit_usage_percent", + kind: "quota", + label: "Credit usage", + limit: 100, + remaining: [ + "100 - $.creditUsagePercent", + "100 - $.config.creditUsagePercent", + "100 - $.config.creditUsagePercent.val" + ], + resetAt: grokBillingResetPaths, + unit: "%", + used: [ + "$.creditUsagePercent", + "$.config.creditUsagePercent", + "$.config.creditUsagePercent.val" + ], + window: "monthly" + }, + { + id: "grok_included_credits", + kind: "quota", + label: "Included credits", + limit: [ + "$.monthlyLimit", + "$.monthlyLimit.val", + "$.currentPeriod.monthlyLimit", + "$.currentPeriod.monthlyLimit.val", + "$.config.monthlyLimit", + "$.config.monthlyLimit.val", + "$.config.currentPeriod.monthlyLimit", + "$.config.currentPeriod.monthlyLimit.val" + ], + resetAt: grokBillingResetPaths, + unit: "credits", + used: [ + "$.includedUsed", + "$.includedUsed.val", + "$.currentPeriod.includedUsed", + "$.currentPeriod.includedUsed.val", + "$.config.includedUsed", + "$.config.includedUsed.val", + "$.config.currentPeriod.includedUsed", + "$.config.currentPeriod.includedUsed.val" + ], + window: "monthly" + }, + { + id: "grok_total_credits", + kind: "quota", + label: "Total credits", + limit: [ + "$.monthlyLimit", + "$.monthlyLimit.val", + "$.currentPeriod.monthlyLimit", + "$.currentPeriod.monthlyLimit.val", + "$.config.monthlyLimit", + "$.config.monthlyLimit.val", + "$.config.currentPeriod.monthlyLimit", + "$.config.currentPeriod.monthlyLimit.val" + ], + resetAt: grokBillingResetPaths, + unit: "credits", + used: [ + "$.totalUsed", + "$.totalUsed.val", + "$.currentPeriod.totalUsed", + "$.currentPeriod.totalUsed.val", + "$.config.totalUsed", + "$.config.totalUsed.val", + "$.config.currentPeriod.totalUsed", + "$.config.currentPeriod.totalUsed.val" + ], + window: "monthly" + }, + { + id: "grok_pay_as_you_go_cap", + kind: "quota", + label: "Pay-as-you-go cap", + limit: [ + "$.onDemandCap", + "$.onDemandCap.val", + "$.currentPeriod.onDemandCap", + "$.currentPeriod.onDemandCap.val", + "$.config.onDemandCap", + "$.config.onDemandCap.val", + "$.config.currentPeriod.onDemandCap", + "$.config.currentPeriod.onDemandCap.val" + ], + resetAt: grokBillingResetPaths, + unit: "credits", + used: [ + "$.onDemandUsed", + "$.onDemandUsed.val", + "$.currentPeriod.onDemandUsed", + "$.currentPeriod.onDemandUsed.val", + "$.config.onDemandUsed", + "$.config.onDemandUsed.val", + "$.config.currentPeriod.onDemandUsed", + "$.config.currentPeriod.onDemandUsed.val" + ], + window: "monthly" + }, + { + id: "grok_prepaid_balance", + kind: "balance", + label: "Prepaid balance", + remaining: [ + "$.prepaidBalance", + "$.prepaidBalance.val", + "$.currentPeriod.prepaidBalance", + "$.currentPeriod.prepaidBalance.val", + "$.config.prepaidBalance", + "$.config.prepaidBalance.val", + "$.config.currentPeriod.prepaidBalance", + "$.config.currentPeriod.prepaidBalance.val" + ], + resetAt: grokBillingResetPaths, + unit: "credits", + window: "monthly" + } + ] +}; + +export type GrokTokenSet = OAuthTokenSet & { + authRecordKey?: string; + oidcClientId?: string; + oidcIssuer?: string; + expiresAt?: string; +}; + +type GrokModelCatalog = { + baseUrl: string; + modelDisplayNames?: Record; + modelMetadata?: Record; + models: string[]; +}; + +export function grokCandidate(): LocalAgentProviderCandidate { + const auth = readGrokAuth(); + const catalog = readGrokLocalModelCatalog(); + if ((auth?.accessToken && !grokAccessTokenExpired(auth)) || auth?.refreshToken) { + return { + detail: "Grok CLI login detected. Click Import to add it as a gateway provider.", + id: grokProviderId, + importable: true, + kind: "grok", + modelDisplayNames: catalog.modelDisplayNames, + modelMetadata: catalog.modelMetadata, + models: catalog.models, + name: grokProviderName, + protocol: "openai_responses", + sourceFile: auth.sourceFile, + status: "available" + }; + } + if (auth?.accessToken || auth?.refreshToken) { + return { + detail: auth.accessToken && grokAccessTokenExpired(auth) + ? "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan." + : "Grok CLI login was detected, but no usable access token was found.", + id: grokProviderId, + importable: false, + kind: "grok", + modelDisplayNames: catalog.modelDisplayNames, + modelMetadata: catalog.modelMetadata, + models: catalog.models, + name: grokProviderName, + protocol: "openai_responses", + sourceFile: auth.sourceFile, + status: "locked" + }; + } + return missingCandidate("grok", grokProviderId, grokProviderName, "openai_responses", catalog.models, catalog.modelDisplayNames); +} + +export async function importGrokProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): Promise { + const auth = await resolveGrokAuth(); + if (!auth?.accessToken || grokAccessTokenExpired(auth)) { + throw new Error("Grok CLI access token was not found or is expired."); + } + return importGrokProviderWithAuth(candidate, providerNames, auth); +} + +export function readGrokAuth(): GrokTokenSet | undefined { + const candidates = grokCredentialFiles() + .flatMap((sourceFile) => readGrokAuthRecords(sourceFile)); + return candidates.find((item) => item.accessToken && !grokAccessTokenExpired(item)) ?? + candidates.find((item) => item.refreshToken) ?? + candidates.find((item) => item.accessToken); +} + +export async function resolveGrokAuth(): Promise { + const auth = readGrokAuth(); + if (!auth?.refreshToken || (auth.accessToken && !grokAccessTokenExpired(auth))) { + return auth; + } + return refreshGrokAuth(auth); +} + +export function readGrokLocalModelCatalog(): GrokModelCatalog { + const preferredModel = readGrokDefaultModel(); + const catalog = grokModelCatalogFromPayload(readJsonRecord(grokModelsCacheFile()), preferredModel); + const models = uniqueStrings([ + preferredModel, + ...catalog.models, + ...grokDefaultModels + ]); + return { + baseUrl: catalog.baseUrl || grokRuntimeDefaultBaseUrl(), + modelDisplayNames: modelDisplayNamesForModels(catalog.modelDisplayNames, models), + modelMetadata: modelMetadataForModels(catalog.modelMetadata, models), + models + }; +} + +function readGrokAuthRecords(sourceFile: string): GrokTokenSet[] { + const record = readJsonRecord(sourceFile); + if (!record) { + return []; + } + return [ + record, + ...Object.entries(record) + .filter((entry): entry is [string, Record] => isRecord(entry[1])) + .map(([key, value]) => ({ ...value, __ccr_auth_record_key: key })) + ] + .map((item) => grokAuthFromRecord(item, sourceFile)) + .filter((item): item is GrokTokenSet => Boolean(item)); +} + +function grokAuthFromRecord(record: Record, sourceFile: string): GrokTokenSet | undefined { + const accessToken = + readString(record.key) || + readString(record.access_token) || + readString(record.accessToken) || + readString(record.token) || + readString(record.id_token) || + readString(record.idToken); + const refreshToken = + readString(record.refresh_token) || + readString(record.refreshToken); + if (!accessToken && !refreshToken) { + return undefined; + } + return { + accessToken, + authRecordKey: readString(record.__ccr_auth_record_key), + expiresAt: readString(record.expires_at) || readString(record.expiresAt), + oidcClientId: readString(record.oidc_client_id) || readString(record.oidcClientId) || readString(process.env.GROK_OIDC_CLIENT_ID), + oidcIssuer: readString(record.oidc_issuer) || readString(record.oidcIssuer) || readString(process.env.GROK_OIDC_ISSUER), + refreshToken, + sourceFile + }; +} + +export function grokAccessTokenExpired(auth: GrokTokenSet): boolean { + const expiresAtMs = dateMs(auth.expiresAt) ?? jwtExpiresAtMs(auth.accessToken); + return expiresAtMs !== undefined && expiresAtMs <= Date.now() + 60_000; +} + +function grokModelCatalogFromPayload(payload: unknown, preferredModel?: string): GrokModelCatalog { + const models: string[] = []; + const modelDisplayNames: Record = {}; + const modelMetadata: Record = {}; + const baseUrlsByModel: Record = {}; + + for (const item of grokModelCatalogItems(payload)) { + const info = isRecord(item.value) && isRecord(item.value.info) ? item.value.info : isRecord(item.value) ? item.value : {}; + if (readBoolean(info.hidden) || readBoolean(info.supported_in_api) === false || readBoolean(info.supportedInApi) === false) { + continue; + } + const apiBackend = readString(info.api_backend) || readString(info.apiBackend); + if (apiBackend && !apiBackend.toLowerCase().includes("responses")) { + continue; + } + const model = readString(info.model) || readString(info.id) || readString(info.name) || item.key; + if (!model) { + continue; + } + models.push(model); + const displayName = readString(info.display_name) || readString(info.displayName) || readString(info.label) || readString(info.title) || readString(info.name); + if (displayName && displayName !== model) { + modelDisplayNames[model] = displayName; + } + const baseUrl = readString(info.base_url) || readString(info.baseUrl); + if (baseUrl) { + baseUrlsByModel[model] = baseUrl; + } + const metadata = grokModelMetadataFromInfo(info); + if (metadata) { + modelMetadata[model] = metadata; + } + } + + const uniqueModels = uniqueStrings(models); + const preferredBaseUrl = preferredModel ? baseUrlsByModel[preferredModel] : undefined; + const baseUrl = preferredBaseUrl || firstString(uniqueModels.map((model) => baseUrlsByModel[model])) || grokRuntimeDefaultBaseUrl(); + const filteredModels = uniqueModels.filter((model) => !baseUrlsByModel[model] || baseUrlsByModel[model] === baseUrl); + return { + baseUrl, + modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, filteredModels), + modelMetadata: modelMetadataForModels(modelMetadata, filteredModels), + models: filteredModels + }; +} + +function grokModelMetadataFromInfo(info: Record): ProviderModelMetadata | undefined { + const defaultReasoningLevel = readNullableString(info.reasoning_effort) ?? readNullableString(info.reasoningEffort); + const metadata: ProviderModelMetadata = { + ...(defaultReasoningLevel !== undefined ? { defaultReasoningLevel } : {}) + }; + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +function grokModelCatalogItems(payload: unknown): Array<{ key?: string; value: unknown }> { + if (Array.isArray(payload)) { + return payload.map((value) => ({ value })); + } + if (!isRecord(payload)) { + return []; + } + const models = payload.models; + if (Array.isArray(models)) { + return models.map((value) => ({ value })); + } + if (isRecord(models)) { + return Object.entries(models).map(([key, value]) => ({ key, value })); + } + return []; +} + +function readGrokDefaultModel(): string | undefined { + for (const sourceFile of grokConfigFiles()) { + if (!existsSync(sourceFile)) { + continue; + } + try { + const text = readFileSync(sourceFile, "utf8"); + const match = text.match(/^\s*default\s*=\s*"([^"]+)"\s*$/m) ?? text.match(/^\s*default\s*=\s*'([^']+)'\s*$/m); + const model = match?.[1]?.trim(); + if (model) { + return model; + } + } catch { + continue; + } + } + return undefined; +} + +function importGrokProviderWithAuth( + candidate: LocalAgentProviderCandidate, + providerNames: string[], + auth: GrokTokenSet +): LocalAgentProviderImportResult { + const catalog = readGrokLocalModelCatalog(); + const provider = providerPayload( + { + ...candidate, + modelDisplayNames: catalog.modelDisplayNames, + modelMetadata: catalog.modelMetadata, + models: catalog.models + }, + uniqueProviderName(providerNames, grokProviderName), + catalog.baseUrl, + grokProviderAccountConfig() + ); + return { + candidate: { + ...candidate, + modelDisplayNames: catalog.modelDisplayNames, + modelMetadata: catalog.modelMetadata, + models: catalog.models + }, + provider, + providerPlugins: [ + grokOauthPlugin("grok-cli-oauth", auth.accessToken ?? ""), + grokOauthPlugin("grok-cli-oauth-internal", auth.accessToken ?? "", providerInternalNamePlaceholder) + ] + }; +} + +export function grokProviderAccountConfig(): ProviderAccountConfig { + return { + connectors: [ + { + auth: "provider-api-key", + endpoint: grokBillingEndpoint(), + headers: { + "x-grok-client-identifier": "xai-grok-cli", + "x-grok-client-version": "0.2.93" + }, + mapping: grokBillingMapping, + type: "http-json" + }, + { + auth: "provider-api-key", + endpoint: grokSubscriptionEndpoint(), + headers: { + "x-grok-client-identifier": "xai-grok-cli", + "x-grok-client-version": "0.2.93" + }, + mapping: { meters: [] }, + parser: "grok-subscription", + type: "http-json" + } + ], + enabled: true + }; +} + +export function normalizeGrokProviderAccountConfig(provider: GatewayProviderConfig): GatewayProviderConfig { + if (!isLocalGrokProvider(provider) || !shouldUseCurrentGrokAccountConfig(provider.account)) { + return provider; + } + const account = grokProviderAccountConfig(); + return { + ...provider, + account: { + ...account, + refreshIntervalMs: provider.account?.refreshIntervalMs ?? account.refreshIntervalMs + } + }; +} + +function isLocalGrokProvider(provider: GatewayProviderConfig): boolean { + if (providerApiKey(provider) !== localAgentProviderApiKey) { + return false; + } + const baseUrl = normalizeProviderBaseUrl(providerBaseUrl(provider)).toLowerCase(); + const name = provider.name?.toLowerCase() ?? ""; + return baseUrl.includes("cli-chat-proxy.grok.com") || name.includes("grok"); +} + +function shouldUseCurrentGrokAccountConfig(account: ProviderAccountConfig | undefined): boolean { + if (account?.enabled === false) { + return false; + } + const connectors = account?.connectors ?? []; + if (connectors.length === 0) { + return true; + } + return connectors.every(isGrokAccountConnector); +} + +function isGrokAccountConnector(connector: ProviderAccountConnectorConfig): boolean { + if (connector.type === "standard") { + return !connector.endpoint?.trim() && !connector.endpoints?.length && !connector.headers && !connector.id; + } + if (connector.type !== "http-json") { + return false; + } + return /^https:\/\/grok\.com\/(?:billing|user)(?:$|[?#/])/i.test(connector.endpoint.trim()) || + /^https:\/\/cli-chat-proxy\.grok\.com\/v1\/(?:billing|user)(?:$|[?#/])/i.test(connector.endpoint.trim()); +} + +function providerBaseUrl(provider: GatewayProviderConfig): string { + return provider.api_base_url || provider.baseurl || provider.baseUrl || ""; +} + +function providerApiKey(provider: GatewayProviderConfig): string { + return provider.api_key || provider.apiKey || provider.apikey || ""; +} + +function grokOauthPlugin(suffix: string, token: string, providerName?: string): Record { + return { + ...bearerAuthPlugin(suffix, token, {}, providerName), + request: { + headers: { + "x-grok-model-override": "{{ model }}" + }, + strict: true + } + }; +} + +async function refreshGrokAuth(auth: GrokTokenSet): Promise { + const refreshToken = auth.refreshToken; + if (!refreshToken) { + throw new Error("Grok CLI refresh token was not found."); + } + const clientId = auth.oidcClientId || readString(process.env.GROK_OIDC_CLIENT_ID); + if (!clientId) { + throw new Error("Grok CLI OAuth client id was not found."); + } + + const tokenEndpoint = await grokTokenEndpoint(auth); + const timeoutMs = normalizeGrokOauthTimeout(process.env.GROK_OIDC_REFRESH_TIMEOUT_MS); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchWithSystemProxy(tokenEndpoint, { + body: new URLSearchParams({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: refreshToken + }).toString(), + headers: { + "content-type": "application/x-www-form-urlencoded" + }, + method: "POST", + signal: controller.signal + }); + const text = await response.text(); + const payload = parseJsonRecord(text); + if (!response.ok) { + throw new Error(`Grok CLI OAuth token refresh returned HTTP ${response.status}${tokenRefreshErrorMessage(payload, text)}`); + } + const accessToken = readString(payload?.access_token) || readString(payload?.accessToken); + if (!accessToken) { + throw new Error("Grok CLI OAuth token refresh did not return an access token."); + } + const refreshed: GrokTokenSet = { + ...auth, + accessToken, + expiresAt: refreshedGrokExpiresAt(accessToken, payload), + refreshToken: readString(payload?.refresh_token) || readString(payload?.refreshToken) || refreshToken + }; + persistRefreshedGrokAuth(refreshed); + return refreshed; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Grok CLI OAuth token refresh timed out after ${timeoutMs}ms.`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +async function grokTokenEndpoint(auth: GrokTokenSet): Promise { + const configured = readString(process.env.GROK_OIDC_TOKEN_ENDPOINT); + if (configured) { + return configured; + } + const issuer = (auth.oidcIssuer || readString(process.env.GROK_OIDC_ISSUER) || grokDefaultOidcIssuer).replace(/\/+$/, ""); + const metadataUrl = `${issuer}/.well-known/openid-configuration`; + const timeoutMs = normalizeGrokOauthTimeout(process.env.GROK_OIDC_REFRESH_TIMEOUT_MS); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchWithSystemProxy(metadataUrl, { + headers: { accept: "application/json" }, + signal: controller.signal + }); + const text = await response.text(); + const payload = parseJsonRecord(text); + if (!response.ok) { + throw new Error(`Grok CLI OIDC discovery returned HTTP ${response.status}${tokenRefreshErrorMessage(payload, text)}`); + } + const tokenEndpoint = readString(payload?.token_endpoint) || readString(payload?.tokenEndpoint); + if (!tokenEndpoint) { + throw new Error("Grok CLI OIDC discovery did not return a token endpoint."); + } + return tokenEndpoint; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Grok CLI OIDC discovery timed out after ${timeoutMs}ms.`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +function grokCredentialFiles(): string[] { + const explicitFile = process.env.GROK_AUTH_FILE?.trim(); + return uniqueStrings([ + explicitFile, + path.join(grokStorageRoot(), "auth.json"), + path.join(grokStorageRoot(), "credentials.json") + ]); +} + +function grokConfigFiles(): string[] { + const explicitFile = process.env.GROK_CONFIG_FILE?.trim(); + return uniqueStrings([ + explicitFile, + path.join(grokStorageRoot(), "config.toml") + ]); +} + +function grokModelsCacheFile(): string { + return process.env.GROK_MODELS_CACHE_FILE?.trim() || path.join(grokStorageRoot(), "models_cache.json"); +} + +function grokStorageRoot(): string { + const explicitRoot = process.env.GROK_HOME?.trim() || process.env.GROK_STORAGE_DIR?.trim() || process.env.GROK_CONFIG_DIR?.trim(); + if (explicitRoot) { + return explicitRoot; + } + const homeDir = process.env.CCR_INTERNAL_HOME_DIR?.trim() || process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir(); + return path.join(homeDir, ".grok"); +} + +function grokRuntimeDefaultBaseUrl(): string { + return process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim() || grokDefaultBaseUrl; +} + +function grokBillingEndpoint(): string { + return process.env.GROK_BILLING_ENDPOINT?.trim() || grokDefaultBillingEndpoint; +} + +function grokSubscriptionEndpoint(): string { + return process.env.GROK_SUBSCRIPTION_ENDPOINT?.trim() || grokDefaultSubscriptionEndpoint; +} + +function readNullableString(value: unknown): string | null | undefined { + if (value === null) { + return null; + } + return readString(value) || undefined; +} + +function dateMs(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function jwtExpiresAtMs(token: string | undefined): number | undefined { + const encoded = token?.split(".")[1]; + if (!encoded) { + return undefined; + } + try { + const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "="); + const payload = JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as unknown; + const exp = isRecord(payload) && typeof payload.exp === "number" ? payload.exp : undefined; + return exp ? exp * 1000 : undefined; + } catch { + return undefined; + } +} + +function refreshedGrokExpiresAt(accessToken: string, payload: Record | undefined): string | undefined { + const expiresAtMs = jwtExpiresAtMs(accessToken) ?? expiresInMs(payload?.expires_in) ?? expiresInMs(payload?.expiresIn); + return expiresAtMs ? new Date(expiresAtMs).toISOString() : undefined; +} + +function expiresInMs(value: unknown): number | undefined { + const seconds = typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number(value) + : undefined; + return seconds && Number.isFinite(seconds) ? Date.now() + seconds * 1000 : undefined; +} + +function persistRefreshedGrokAuth(auth: GrokTokenSet): void { + if (!auth.sourceFile || !auth.accessToken) { + return; + } + try { + const parsed = JSON.parse(readFileSync(auth.sourceFile, "utf8")) as unknown; + if (!isRecord(parsed)) { + return; + } + let target: Record = parsed; + if (auth.authRecordKey) { + const authRecord = parsed[auth.authRecordKey]; + if (isRecord(authRecord)) { + target = authRecord; + } + } + target.key = auth.accessToken; + if (auth.refreshToken) { + target.refresh_token = auth.refreshToken; + } + if (auth.expiresAt) { + target.expires_at = auth.expiresAt; + } + writeFileSync(auth.sourceFile, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); + } catch { + // Best effort. The refreshed token is still used for this CCR run. + } +} + +function parseJsonRecord(text: string): Record | undefined { + try { + const payload = JSON.parse(text) as unknown; + return isRecord(payload) ? payload : undefined; + } catch { + return undefined; + } +} + +function tokenRefreshErrorMessage(payload: Record | undefined, text: string): string { + const message = + readString(payload?.error_description) || + readString(payload?.error) || + readString(payload?.message) || + readableResponseSnippet(text); + return message ? `: ${message}` : ""; +} + +function readableResponseSnippet(text: string): string { + return text.replace(/\s+/g, " ").trim().slice(0, 200); +} + +function normalizeGrokOauthTimeout(value: unknown): number { + const numeric = Number(value); + return Math.max(1, Number.isFinite(numeric) ? numeric : grokOauthDefaultTimeoutMs); +} + +export function grokModelCatalogFromPayloadForTest(payload: unknown, preferredModel?: string): GrokModelCatalog { + return grokModelCatalogFromPayload(payload, preferredModel); +} diff --git a/packages/core/src/agents/local-providers/service.ts b/packages/core/src/agents/local-providers/service.ts index 4ecec87a..e485cb55 100644 --- a/packages/core/src/agents/local-providers/service.ts +++ b/packages/core/src/agents/local-providers/service.ts @@ -7,9 +7,11 @@ import type { } from "@ccr/core/contracts/app"; import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code"; import { codexCandidate, importCodexProvider, probeCodexProvider } from "@ccr/core/agents/local-providers/codex"; +import { grokCandidate, importGrokProvider } from "@ccr/core/agents/local-providers/grok"; import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode"; export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex"; +export { grokDefaultBaseUrl, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/grok"; export { readZcodeLocalProviderCredential, zcodeDefaultBaseUrl } from "@ccr/core/agents/local-providers/zcode"; export { localAgentProviderApiKey, type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; @@ -17,6 +19,7 @@ export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[] return [ codexCandidate(), claudeCodeCandidate(), + grokCandidate(), zcodeCandidate() ].filter((candidate) => candidate.status !== "missing"); } @@ -36,6 +39,9 @@ export async function importLocalAgentProvider(request: LocalAgentProviderImport if (candidate.kind === "claude-code") { return importClaudeCodeProvider(candidate, request.providerNames ?? []); } + if (candidate.kind === "grok") { + return importGrokProvider(candidate, request.providerNames ?? []); + } return importZcodeProvider(candidate, request.providerNames ?? []); } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5e28eb01..60d901d5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4,6 +4,7 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/con import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store"; import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants"; import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex"; +import { normalizeGrokProviderAccountConfig } from "@ccr/core/agents/local-providers/grok"; import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app"; import { createDefaultAppConfig } from "@ccr/core/config/default-config"; import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; @@ -1067,7 +1068,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { transformer: item.transformer, type: readString(item.type) }; - return normalizeCodexProviderAccountConfig(provider); + return normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider)); }) .filter((item): item is GatewayProviderConfig => Boolean(item)); diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index d0c6d5a6..a6babcc3 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -175,7 +175,7 @@ export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "u export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests"; export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string; export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | string; -export type ProviderAccountHttpJsonParser = "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self"; +export type ProviderAccountHttpJsonParser = "grok-subscription" | "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self"; export type ProviderAccountConfig = { connectors?: ProviderAccountConnectorConfig[]; @@ -329,7 +329,7 @@ export type ProviderManifestFetchResult = { url: string; }; -export type LocalAgentProviderKind = "claude-code" | "codex" | "zcode"; +export type LocalAgentProviderKind = "claude-code" | "codex" | "grok" | "zcode"; export type LocalAgentProviderStatus = "available" | "locked" | "missing"; diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 9ee87789..c1da85c8 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -42,7 +42,8 @@ import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "@ccr/ import { backendService } from "@ccr/core/plugins/backend-service"; import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants"; import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store"; -import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/service"; +import { codexDefaultBaseUrl, readCodexAuth, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/service"; +import { grokAccessTokenExpired } from "@ccr/core/agents/local-providers/grok"; import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch"; import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp"; import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config"; @@ -1176,10 +1177,10 @@ async function writeCoreGatewayConfig( assertLoopbackCoreHost(config.gateway.coreHost); mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); - const providerPlugins = withCodexOauthRuntimeDefaults([ + const providerPlugins = await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults([ ...(config.providerPlugins ?? []).filter(providerPluginEnabled), ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) - ]); + ])); const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ ...(config.virtualModelProfiles ?? []), @@ -1398,6 +1399,31 @@ function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { }); } +async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise { + const grokAuth = await resolveGrokAuth().catch(() => readGrokAuth()); + if (!grokAuth?.accessToken || grokAccessTokenExpired(grokAuth)) { + return providerPlugins; + } + + return providerPlugins.map((plugin) => { + if (!isLocalGrokOauthProviderPlugin(plugin)) { + return plugin; + } + const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; + const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; + return { + ...plugin, + auth: { + ...currentAuth, + headers: { + ...currentHeaders, + authorization: `Bearer ${grokAuth.accessToken}` + } + } + }; + }); +} + function codexOauthLocalProviderNames(providerPlugins: unknown[]): Set { const names = new Set(); for (const plugin of providerPlugins) { @@ -1455,6 +1481,14 @@ function isLocalCodexOauthProviderPlugin(value: unknown): value is Record { + if (!isRecord(value)) { + return false; + } + const key = stringValue(value.key)?.toLowerCase() ?? ""; + return key.startsWith("ccr-local-agent-") && key.includes("grok-cli-oauth"); +} + function withCodexBackendRequestTransform(request: unknown): Record { const currentRequest = isRecord(request) ? request : {}; const bodyRemove = Array.isArray(currentRequest.bodyRemove) diff --git a/packages/core/src/providers/account-service.ts b/packages/core/src/providers/account-service.ts index c1f70cd7..4fc40b23 100644 --- a/packages/core/src/providers/account-service.ts +++ b/packages/core/src/providers/account-service.ts @@ -5,9 +5,12 @@ import { codexDefaultBaseUrl, localAgentProviderApiKey, readCodexAuth, + readGrokAuth, + resolveGrokAuth, readZcodeLocalProviderCredential, zcodeDefaultBaseUrl } from "@ccr/core/agents/local-providers/service"; +import { grokAccessTokenExpired } from "@ccr/core/agents/local-providers/grok"; import { pluginService } from "@ccr/core/plugins/service"; import { getUsageTotalsSince } from "@ccr/core/usage/store"; import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; @@ -163,6 +166,16 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR type: "http-json" }; const payload = await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body); + if (connector.parser === "grok-subscription") { + const meters = grokSubscriptionMeters(payload); + return { + meters, + message: grokSubscriptionMessage(payload), + paths: flattenJsonPaths(payload), + payload, + status: grokSubscriptionStatus(payload) ?? statusFromMeters(meters, [], 1) + }; + } if (connector.parser === "kimi-code-usages") { const meters = kimiCodeUsageMeters(payload); return { @@ -647,6 +660,15 @@ async function resolveHttpJsonConnector( ...(connector.headers ?? {}), ...(request.headers ?? {}) }, connector.method, connector.body); + if (connector.parser === "grok-subscription") { + return { + errors: [], + message: grokSubscriptionMessage(payload), + meters: grokSubscriptionMeters(payload), + source: "http-json", + status: grokSubscriptionStatus(payload) + }; + } if (connector.parser === "kimi-code-usages") { const meters = kimiCodeUsageMeters(payload); return { @@ -846,6 +868,110 @@ function normalizeRemoteSnapshot( }; } +function grokSubscriptionMeters(payload: unknown): ProviderAccountMeter[] { + const allowAccess = grokSubscriptionBoolean(payload, [ + "allow_access", + "allowAccess", + "has_grok_code_access", + "hasGrokCodeAccess" + ]); + if (allowAccess === undefined) { + return []; + } + return [ + { + id: "grok_subscription_access", + kind: "subscription", + label: "Subscription access", + limit: 100, + remaining: allowAccess ? 100 : 0, + source: "http-json", + unit: "%", + used: allowAccess ? 0 : 100, + window: "subscription" + } + ]; +} + +function grokSubscriptionMessage(payload: unknown): string | undefined { + return grokSubscriptionString(payload, [ + "gate_message", + "gateMessage", + "subscription_tier_display", + "subscriptionTierDisplay", + "subscription_tier", + "subscriptionTier", + "tier_display", + "tierDisplay", + "tier", + "user_blocked_reason", + "userBlockedReason", + "team_blocked_reason", + "teamBlockedReason" + ]); +} + +function grokSubscriptionStatus(payload: unknown): ProviderAccountStatus | undefined { + const allowAccess = grokSubscriptionBoolean(payload, [ + "allow_access", + "allowAccess", + "has_grok_code_access", + "hasGrokCodeAccess" + ]); + if (allowAccess === false) { + return "critical"; + } + if (grokSubscriptionString(payload, ["gate_message", "gateMessage", "gate_label", "gateLabel"])) { + return "warning"; + } + return undefined; +} + +function grokSubscriptionBoolean(payload: unknown, keys: string[]): boolean | undefined { + for (const record of grokSubscriptionRecords(payload)) { + for (const key of keys) { + const value = readBoolean(readJsonRecordValue(record, key)); + if (value !== undefined) { + return value; + } + } + } + return undefined; +} + +function grokSubscriptionString(payload: unknown, keys: string[]): string | undefined { + for (const record of grokSubscriptionRecords(payload)) { + for (const key of keys) { + const value = readString(readJsonRecordValue(record, key)); + if (value) { + return value; + } + } + } + return undefined; +} + +function grokSubscriptionRecords(payload: unknown): Record[] { + if (!isRecord(payload)) { + return []; + } + const records: Record[] = []; + const queue = [payload]; + for (const record of queue) { + if (records.includes(record)) { + continue; + } + records.push(record); + for (const key of ["account", "data", "meta", "subscription", "user", "viewer_context", "viewerContext"]) { + const nested = readJsonRecordValue(record, key); + if (isRecord(nested)) { + queue.push(nested); + } + } + } + return records; +} + function newApiKeyUsageMeters(payload: unknown): ProviderAccountMeter[] { const meter = newApiKeyUsageMeter(payload); return meter ? [meter] : []; @@ -1252,6 +1378,9 @@ async function localAgentProviderAccountCredential( if (key.includes("claude-code-oauth")) { return localBearerAccountCredential(plugin); } + if (key.includes("grok-cli-oauth")) { + return await localGrokAccountCredential(plugin); + } if (key.includes("zcode-api-key")) { return localApiKeyHeaderAccountCredential(plugin); } @@ -1573,6 +1702,18 @@ function localBearerAccountCredential(plugin: Record): { apiKey }; } +async function localGrokAccountCredential(plugin: Record): Promise<{ apiKey?: string; headers?: Record }> { + const headers = localProviderPluginAuthHeaders(plugin); + const auth = await resolveGrokAuth().catch(() => readGrokAuth()); + const apiKey = auth?.accessToken && !grokAccessTokenExpired(auth) + ? auth.accessToken + : readBearerToken(headers.authorization || headers.Authorization); + return { + apiKey, + headers: withoutHeader(headers, "authorization") + }; +} + function localApiKeyHeaderAccountCredential(plugin: Record): { apiKey?: string; headers?: Record } { const headers = localProviderPluginAuthHeaders(plugin); const apiKey = headers["x-api-key"] || headers["X-API-Key"]; diff --git a/packages/ui/src/assets/agent-logos/grok.ico b/packages/ui/src/assets/agent-logos/grok.ico new file mode 100644 index 0000000000000000000000000000000000000000..7c49f3291416fe9b718cdfabc56ea1f188e104a5 GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 2cf7b329..640f9e2d 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -1189,7 +1189,7 @@ function LocalAgentProviderImportPanel({ apiKey: result.provider.apiKey ?? "", baseUrl: result.provider.baseUrl, credentials: [], - icon: result.provider.icon ?? "", + icon: result.provider.icon?.trim() || localAgentProviderIconUrls[candidate.kind] || "", modelDescriptions: result.provider.modelDescriptions, modelDisplayNames: result.provider.modelDisplayNames, modelMetadata: result.provider.modelMetadata, @@ -1214,7 +1214,7 @@ function LocalAgentProviderImportPanel({
{t("Import local agent login")}
-
{t("CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.")}
+
{t("CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.")}
{loading ? : null}
@@ -1281,6 +1281,7 @@ const localAgentProviderApiKey = "ccr-local-agent-login"; const localAgentProviderPluginSuffixes: Record = { "claude-code": ["-claude-code-oauth", "-claude-code-oauth-internal"], codex: ["-codex-oauth", "-codex-oauth-internal"], + grok: ["-grok-cli-oauth", "-grok-cli-oauth-internal"], zcode: ["-zcode-api-key", "-zcode-api-key-internal"] }; diff --git a/packages/ui/src/pages/home/shared/extensions.ts b/packages/ui/src/pages/home/shared/extensions.ts index 877fd311..587571c6 100644 --- a/packages/ui/src/pages/home/shared/extensions.ts +++ b/packages/ui/src/pages/home/shared/extensions.ts @@ -729,6 +729,7 @@ export function providerPluginCapability(item: Record): string const capabilities: string[] = ["Provider middleware"]; if (item.deepseekThinking || item.deepSeekThinking) capabilities.push("DeepSeek thinking"); if (item.codexOauth) capabilities.push("Codex OAuth"); + if (typeof item.key === "string" && item.key.includes("grok-cli-oauth")) capabilities.push("Grok OAuth"); if (item.auth) capabilities.push("Auth mutation"); if (item.request) capabilities.push("Request mutation"); if (item.response) capabilities.push("Response mutation"); diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 132b7688..b5c299de 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -259,7 +259,7 @@ export const appCopy: Record = { "Checking connection": "Checking connection", "Click Check Connection to verify connectivity with a real model request.": "Click Check Connection to verify connectivity with a real model request.", "Connection verified": "Connection verified", - "CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.": "CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.", + "CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.", "Detected": "Detected", "Detecting protocols": "Detecting protocols", "Enter API endpoint, API key, and at least one model to enable connectivity check.": "Enter API endpoint, API key, and at least one model to enable connectivity check.", @@ -270,6 +270,9 @@ export const appCopy: Record = { "Claude Code login was detected, but no usable access token was found.": "Claude Code login was detected, but no usable access token was found.", "Codex auth file was found, but no usable token was detected.": "Codex auth file was found, but no usable token was detected.", "Claude Code credential file was found, but no usable OAuth token was detected.": "Claude Code credential file was found, but no usable OAuth token was detected.", + "Grok CLI login detected. Click Import to add it as a gateway provider.": "Grok CLI login detected. Click Import to add it as a gateway provider.", + "Grok CLI login was detected, but no usable access token was found.": "Grok CLI login was detected, but no usable access token was found.", + "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.": "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.", "Locked": "Locked", "Local agent login will be connected after saving this provider.": "Local agent login will be connected after saving this provider.", "Models to check": "Models to check", @@ -770,7 +773,7 @@ export const appCopy: Record = { "Default failure handling": "默认故障处理", "Default on failure": "默认失败处理", "Description": "描述", - "CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex 和 ZCode 登录态。点击导入即可添加为网关供应商。", + "CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex、Grok CLI 和 ZCode 登录态。点击导入即可添加为网关供应商。", "Detected": "已检测", "Detecting protocols": "正在探测协议", "Enter API endpoint, API key, and at least one model to enable connectivity check.": "填写 API 地址、API Key 和至少一个模型后,才可检测连通性。", @@ -780,6 +783,9 @@ export const appCopy: Record = { "Claude Code login was detected, but no usable access token was found.": "已检测到 Claude Code 登录态,但没有找到可用的 access token。", "Codex auth file was found, but no usable token was detected.": "已找到 Codex 认证文件,但没有检测到可用 token。", "Claude Code credential file was found, but no usable OAuth token was detected.": "已找到 Claude Code 凭据文件,但没有检测到可用 OAuth token。", + "Grok CLI login detected. Click Import to add it as a gateway provider.": "已检测到 Grok CLI 登录态。点击导入即可添加为网关供应商。", + "Grok CLI login was detected, but no usable access token was found.": "已检测到 Grok CLI 登录态,但没有找到可用的 access token。", + "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.": "已检测到 Grok CLI 登录态,但 access token 已过期。请重新运行 grok login,然后重新扫描。", "Locked": "已加密", "Local agent login will be connected after saving this provider.": "保存这个供应商后会接入本机 Agent 登录态。", "Display name": "显示名称", diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index ac1d6b9b..56f5dd61 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -102,6 +102,7 @@ import { cn } from "@/lib/utils"; import appLogoUrl from "@/assets/logo.png"; import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; import codexLogoUrl from "@/assets/agent-logos/codex.png"; +import grokLogoUrl from "@/assets/agent-logos/grok.ico"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; @@ -388,9 +389,12 @@ import type { AddProviderDraft, AddRoutingRuleDraft, ModelCatalogItem, ProviderC export const localAgentProviderIconUrls: Record = { "claude-code": claudeCodeLogoUrl, codex: codexLogoUrl, + grok: grokLogoUrl, zcode: zcodeLogoUrl }; +const localAgentProviderApiKeyValue = "ccr-local-agent-login"; + export function createModelCatalogItems(config: AppConfig): ModelCatalogItem[] { const rows: ModelCatalogItem[] = []; config.Providers.forEach((provider, providerIndex) => { @@ -774,6 +778,9 @@ export function providerDeepLinkDisplayIcon(payload: ProviderDeepLinkPayload): s } export function providerDisplayIcon(provider: GatewayProviderConfig): string { + if (isLocalGrokProvider(provider)) { + return grokLogoUrl; + } const icon = provider.icon?.trim(); if (icon) { return icon; @@ -783,6 +790,15 @@ export function providerDisplayIcon(provider: GatewayProviderConfig): string { return preset ? providerPresetIconUrls[preset.id] ?? "" : ""; } +function isLocalGrokProvider(provider: GatewayProviderConfig): boolean { + if (providerApiKey(provider) !== localAgentProviderApiKeyValue) { + return false; + } + const baseUrl = normalizeProviderBaseUrl(providerBaseUrl(provider)).toLowerCase(); + const name = provider.name?.toLowerCase() ?? ""; + return baseUrl.includes("cli-chat-proxy.grok.com") || name.includes("grok"); +} + export type ProviderDeepLinkCatalogModelsResolution = { modelDisplayNames?: Record; modelMetadata?: Record; diff --git a/tests/main/local-agent-provider-grok.test.mjs b/tests/main/local-agent-provider-grok.test.mjs new file mode 100644 index 00000000..2ab93407 --- /dev/null +++ b/tests/main/local-agent-provider-grok.test.mjs @@ -0,0 +1,320 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + grokCandidate, + grokDefaultBillingEndpoint, + grokDefaultBaseUrl, + grokDefaultSubscriptionEndpoint, + grokModelCatalogFromPayloadForTest, + importGrokProvider, + normalizeGrokProviderAccountConfig +} from "../../packages/core/src/agents/local-providers/grok.ts"; +import { localAgentProviderApiKey } from "../../packages/core/src/agents/local-providers/shared.ts"; + +test("Grok local provider imports bearer token and model override plugin", async () => { + await withGrokHome(async (grokHome) => { + writeGrokAuth(grokHome, { + key: "grok-access-token", + refresh_token: "grok-refresh-token", + expires_at: "2999-01-01T00:00:00Z" + }); + writeFileSync(path.join(grokHome, "config.toml"), "[models]\ndefault = \"grok-4.5\"\n"); + writeGrokModels(grokHome); + + const candidate = grokCandidate(); + assert.equal(candidate.kind, "grok"); + assert.equal(candidate.importable, true); + assert.equal(candidate.protocol, "openai_responses"); + assert.deepEqual(candidate.models, ["grok-4.5", "grok-composer-2.5-fast"]); + assert.deepEqual(candidate.modelDisplayNames, { + "grok-4.5": "Grok 4.5", + "grok-composer-2.5-fast": "Composer 2.5" + }); + + const result = await importGrokProvider(candidate, []); + assert.equal(result.provider.name, "Grok CLI API"); + assert.equal(result.provider.baseUrl, grokDefaultBaseUrl); + assert.equal(result.provider.protocol, "openai_responses"); + assert.equal(result.provider.apiKey, "ccr-local-agent-login"); + assert.equal(result.provider.account?.enabled, true); + assert.equal(result.provider.account?.connectors?.length, 2); + assert.equal(result.provider.account?.connectors?.[0]?.type, "http-json"); + assert.equal(result.provider.account?.connectors?.[0]?.auth, "provider-api-key"); + assert.equal(result.provider.account?.connectors?.[0]?.endpoint, grokDefaultBillingEndpoint); + assert.equal(result.provider.account?.connectors?.[0]?.headers?.["x-grok-client-identifier"], "xai-grok-cli"); + assert.equal(result.provider.account?.connectors?.[0]?.headers?.["x-grok-client-version"], "0.2.93"); + assert.deepEqual( + result.provider.account?.connectors?.[0]?.mapping.meters.map((meter) => meter.id), + [ + "grok_credit_usage_percent", + "grok_included_credits", + "grok_total_credits", + "grok_pay_as_you_go_cap", + "grok_prepaid_balance" + ] + ); + assert.equal(result.provider.account?.connectors?.[1]?.type, "http-json"); + assert.equal(result.provider.account?.connectors?.[1]?.auth, "provider-api-key"); + assert.equal(result.provider.account?.connectors?.[1]?.endpoint, grokDefaultSubscriptionEndpoint); + assert.equal(result.provider.account?.connectors?.[1]?.headers?.["x-grok-client-identifier"], "xai-grok-cli"); + assert.equal(result.provider.account?.connectors?.[1]?.parser, "grok-subscription"); + assert.equal(result.providerPlugins.length, 2); + assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer grok-access-token"); + assert.equal(result.providerPlugins[0].request.headers["x-grok-model-override"], "{{ model }}"); + assert.equal(result.providerPlugins[1].providerName, "__CCR_PROVIDER_INTERNAL_NAME__"); + }); +}); + +test("Grok local provider refreshes expired token during import", async (t) => { + await withGrokHome(async (grokHome) => { + writeGrokAuth(grokHome, { + key: "expired-token", + refresh_token: "grok-refresh-token", + expires_at: "2000-01-01T00:00:00Z", + oidc_client_id: "grok-client-id", + oidc_issuer: "https://auth.x.ai" + }); + writeGrokModels(grokHome); + + const previousFetch = globalThis.fetch; + const previousTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT; + process.env.GROK_OIDC_TOKEN_ENDPOINT = "http://127.0.0.1/grok/oauth/token"; + let requestBody = ""; + globalThis.fetch = async (input, init) => { + assert.equal(String(input), "http://127.0.0.1/grok/oauth/token"); + requestBody = String(init?.body ?? ""); + return new Response(JSON.stringify({ + access_token: "refreshed-grok-access-token", + expires_in: 3600, + refresh_token: "refreshed-grok-refresh-token" + }), { headers: { "content-type": "application/json" }, status: 200 }); + }; + t.after(() => { + globalThis.fetch = previousFetch; + restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousTokenEndpoint); + }); + + const candidate = grokCandidate(); + assert.equal(candidate.kind, "grok"); + assert.equal(candidate.importable, true); + assert.equal(candidate.status, "available"); + + const result = await importGrokProvider(candidate, []); + assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer refreshed-grok-access-token"); + assert.equal(requestBody, "client_id=grok-client-id&grant_type=refresh_token&refresh_token=grok-refresh-token"); + + const persisted = JSON.parse(readFileSync(path.join(grokHome, "auth.json"), "utf8")); + assert.equal(persisted["https://auth.x.ai::test-account"].key, "refreshed-grok-access-token"); + assert.equal(persisted["https://auth.x.ai::test-account"].refresh_token, "refreshed-grok-refresh-token"); + }); +}); + +test("Grok model catalog parser keeps responses models from the selected base URL", () => { + const catalog = grokModelCatalogFromPayloadForTest({ + models: { + "grok-4.5": { + info: { + api_backend: "responses", + base_url: "https://cli-chat-proxy.grok.com/v1", + context_window: 500000, + model: "grok-4.5", + name: "Grok 4.5", + reasoning_effort: "high", + supported_in_api: true + } + }, + "grok-hidden": { + info: { + api_backend: "responses", + base_url: "https://cli-chat-proxy.grok.com/v1", + hidden: true, + model: "grok-hidden", + name: "Hidden" + } + }, + "grok-chat": { + info: { + api_backend: "chat_completions", + base_url: "https://cli-chat-proxy.grok.com/v1", + model: "grok-chat", + name: "Chat" + } + }, + "other-responses": { + info: { + api_backend: "responses", + base_url: "https://example.com/v1", + model: "other-responses", + name: "Other" + } + } + } + }, "grok-4.5"); + + assert.deepEqual(catalog.models, ["grok-4.5"]); + assert.deepEqual(catalog.modelDisplayNames, { "grok-4.5": "Grok 4.5" }); + assert.deepEqual(catalog.modelMetadata, { "grok-4.5": { defaultReasoningLevel: "high" } }); + assert.equal(catalog.baseUrl, grokDefaultBaseUrl); +}); + +test("Grok local provider account config upgrades persisted usage mapping", () => { + const provider = normalizeGrokProviderAccountConfig({ + account: { + connectors: [], + refreshIntervalMs: 45000 + }, + api_base_url: grokDefaultBaseUrl, + api_key: localAgentProviderApiKey, + models: ["grok-4.5"], + name: "Grok CLI API", + protocol: "openai_responses" + }); + + const connector = provider.account?.connectors?.[0]; + const subscriptionConnector = provider.account?.connectors?.[1]; + assert.equal(provider.account?.refreshIntervalMs, 45000); + assert.equal(connector?.type, "http-json"); + assert.equal(connector?.endpoint, grokDefaultBillingEndpoint); + assert.equal(connector?.mapping.meters.find((meter) => meter.id === "grok_credit_usage_percent")?.unit, "%"); + assert.equal(subscriptionConnector?.type, "http-json"); + assert.equal(subscriptionConnector?.endpoint, grokDefaultSubscriptionEndpoint); + assert.equal(subscriptionConnector?.parser, "grok-subscription"); +}); + +test("Grok local provider account config upgrades old web usage endpoints", () => { + const provider = normalizeGrokProviderAccountConfig({ + account: { + connectors: [ + { + endpoint: "https://grok.com/billing?format=credits", + mapping: { meters: [] }, + type: "http-json" + }, + { + endpoint: "https://grok.com/user?include=subscription", + mapping: { meters: [] }, + parser: "grok-subscription", + type: "http-json" + } + ] + }, + api_base_url: grokDefaultBaseUrl, + api_key: localAgentProviderApiKey, + models: ["grok-4.5"], + name: "Grok CLI API", + protocol: "openai_responses" + }); + + assert.equal(provider.account?.connectors?.[0]?.type, "http-json"); + assert.equal(provider.account?.connectors?.[0]?.endpoint, grokDefaultBillingEndpoint); + assert.equal(provider.account?.connectors?.[1]?.type, "http-json"); + assert.equal(provider.account?.connectors?.[1]?.endpoint, grokDefaultSubscriptionEndpoint); +}); + +test("Grok local provider account config keeps custom connectors", () => { + const account = { + connectors: [ + { + endpoint: "https://example.com/usage", + mapping: { + meters: [ + { + id: "custom", + kind: "balance", + label: "Custom", + remaining: "$.balance", + unit: "credits" + } + ] + }, + type: "http-json" + } + ], + enabled: true + }; + + const provider = normalizeGrokProviderAccountConfig({ + account, + api_base_url: grokDefaultBaseUrl, + api_key: localAgentProviderApiKey, + models: ["grok-4.5"], + name: "Grok CLI API", + protocol: "openai_responses" + }); + + assert.equal(provider.account, account); +}); + +async function withGrokHome(run) { + const previousGrokHome = process.env.GROK_HOME; + const previousGrokAuthFile = process.env.GROK_AUTH_FILE; + const previousGrokConfigFile = process.env.GROK_CONFIG_FILE; + const previousGrokModelsCacheFile = process.env.GROK_MODELS_CACHE_FILE; + const previousGrokTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT; + const grokHome = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-test-")); + process.env.GROK_HOME = grokHome; + delete process.env.GROK_AUTH_FILE; + delete process.env.GROK_CONFIG_FILE; + delete process.env.GROK_MODELS_CACHE_FILE; + delete process.env.GROK_OIDC_TOKEN_ENDPOINT; + try { + await run(grokHome); + } finally { + restoreEnv("GROK_HOME", previousGrokHome); + restoreEnv("GROK_AUTH_FILE", previousGrokAuthFile); + restoreEnv("GROK_CONFIG_FILE", previousGrokConfigFile); + restoreEnv("GROK_MODELS_CACHE_FILE", previousGrokModelsCacheFile); + restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousGrokTokenEndpoint); + rmSync(grokHome, { force: true, recursive: true }); + } +} + +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function writeGrokAuth(grokHome, auth) { + writeFileSync(path.join(grokHome, "auth.json"), JSON.stringify({ + "https://auth.x.ai::test-account": auth + }, null, 2)); +} + +function writeGrokModels(grokHome) { + writeFileSync(path.join(grokHome, "models_cache.json"), JSON.stringify({ + models: { + "grok-4.5": { + info: { + api_backend: "responses", + auth_scheme: "bearer", + base_url: grokDefaultBaseUrl, + context_window: 500000, + hidden: false, + model: "grok-4.5", + name: "Grok 4.5", + reasoning_effort: "high", + supported_in_api: true + } + }, + "grok-composer-2.5-fast": { + info: { + api_backend: "responses", + auth_scheme: "bearer", + base_url: grokDefaultBaseUrl, + context_window: 200000, + hidden: false, + model: "grok-composer-2.5-fast", + name: "Composer 2.5", + reasoning_effort: null, + supported_in_api: true + } + } + } + }, null, 2)); +} diff --git a/tests/main/provider-account-service.test.mjs b/tests/main/provider-account-service.test.mjs index 33fcfdb3..2f49b3d4 100644 --- a/tests/main/provider-account-service.test.mjs +++ b/tests/main/provider-account-service.test.mjs @@ -5,13 +5,102 @@ import path from "node:path"; import test from "node:test"; import { localAgentProviderAccountCredentialForTest, - localCodexAccountCredentialForTest + localCodexAccountCredentialForTest, + testProviderAccountConnector } from "../../packages/core/src/providers/account-service.ts"; +import { + grokDefaultBillingEndpoint, + grokDefaultBaseUrl, + grokDefaultSubscriptionEndpoint, + grokProviderAccountConfig +} from "../../packages/core/src/agents/local-providers/grok.ts"; const localAgentProviderApiKey = "ccr-local-agent-login"; const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex"; const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic"; +test("Grok billing connector maps credit usage payload", async (t) => { + const previousFetch = globalThis.fetch; + let authorization = ""; + let clientIdentifier = ""; + let clientVersion = ""; + globalThis.fetch = async (input, init) => { + assert.equal(String(input), grokDefaultBillingEndpoint); + authorization = init?.headers?.authorization ?? ""; + clientIdentifier = init?.headers?.["x-grok-client-identifier"] ?? ""; + clientVersion = init?.headers?.["x-grok-client-version"] ?? ""; + return new Response(JSON.stringify({ + config: { + billingPeriodEnd: "2026-08-01T00:00:00Z", + creditUsagePercent: { val: 25 }, + includedUsed: { val: 10 }, + monthlyLimit: { val: 40 }, + onDemandCap: { val: 100 }, + onDemandUsed: { val: 5 }, + prepaidBalance: { val: 12 }, + totalUsed: { val: 15 } + } + }), { headers: { "content-type": "application/json" }, status: 200 }); + }; + t.after(() => { + globalThis.fetch = previousFetch; + }); + + const connector = grokProviderAccountConfig().connectors?.[0]; + assert.equal(connector?.type, "http-json"); + const result = await testProviderAccountConnector({ + apiKey: "grok-access-token", + baseUrl: grokDefaultBaseUrl, + connector, + providerName: "Grok CLI API" + }); + + assert.equal(authorization, "Bearer grok-access-token"); + assert.equal(clientIdentifier, "xai-grok-cli"); + assert.equal(clientVersion, "0.2.93"); + assert.equal(result.meters.find((meter) => meter.id === "grok_credit_usage_percent")?.remaining, 75); + assert.equal(result.meters.find((meter) => meter.id === "grok_included_credits")?.remaining, 30); + assert.equal(result.meters.find((meter) => meter.id === "grok_total_credits")?.used, 15); + assert.equal(result.meters.find((meter) => meter.id === "grok_pay_as_you_go_cap")?.remaining, 95); + assert.equal(result.meters.find((meter) => meter.id === "grok_prepaid_balance")?.remaining, 12); +}); + +test("Grok subscription connector maps access status payload", async (t) => { + const previousFetch = globalThis.fetch; + let authorization = ""; + let clientIdentifier = ""; + let clientVersion = ""; + globalThis.fetch = async (input, init) => { + assert.equal(String(input), grokDefaultSubscriptionEndpoint); + authorization = init?.headers?.authorization ?? ""; + clientIdentifier = init?.headers?.["x-grok-client-identifier"] ?? ""; + clientVersion = init?.headers?.["x-grok-client-version"] ?? ""; + return new Response(JSON.stringify({ + hasGrokCodeAccess: true, + subscriptionTier: "SuperGrok Heavy" + }), { headers: { "content-type": "application/json" }, status: 200 }); + }; + t.after(() => { + globalThis.fetch = previousFetch; + }); + + const connector = grokProviderAccountConfig().connectors?.[1]; + assert.equal(connector?.type, "http-json"); + const result = await testProviderAccountConnector({ + apiKey: "grok-access-token", + baseUrl: grokDefaultBaseUrl, + connector, + providerName: "Grok CLI API" + }); + + assert.equal(authorization, "Bearer grok-access-token"); + assert.equal(clientIdentifier, "xai-grok-cli"); + assert.equal(clientVersion, "0.2.93"); + assert.equal(result.status, "ok"); + assert.equal(result.message, "SuperGrok Heavy"); + assert.equal(result.meters.find((meter) => meter.id === "grok_subscription_access")?.remaining, 100); +}); + test("Codex local account credential refreshes when only a refresh token is available", async (t) => { const previousHome = process.env.CCR_INTERNAL_HOME_DIR; const home = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-account-refresh-")); diff --git a/tests/renderer/providers.test.ts b/tests/renderer/providers.test.ts index 6f0656cc..d76e3fb1 100644 --- a/tests/renderer/providers.test.ts +++ b/tests/renderer/providers.test.ts @@ -12,6 +12,7 @@ import { createProviderConfigFromDeepLink, createProviderDraft, createProviderInstallLinkFromDraft, + localAgentProviderIconUrls, providerCapabilitiesForProtocols, providerCapabilityBaseUrlForProtocol, providerDisplayIcon, @@ -467,6 +468,17 @@ test("provider display icon prefers custom icons and falls back to preset icons" }), providerPresetIconUrls.gemini ); + assert.equal( + providerDisplayIcon({ + api_base_url: "https://cli-chat-proxy.grok.com/v1", + api_key: "ccr-local-agent-login", + icon: "/assets/grok-old.svg", + models: [], + name: "Grok CLI API", + type: "openai_responses" + }), + localAgentProviderIconUrls.grok + ); }); test("ProvidersView renders configured provider icons in the list", () => { From 71e46f910410ffc275bd1879c172aac0416fd962 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 08:58:02 +0800 Subject: [PATCH 12/38] Fix table wrappers to preserve horizontal scrolling --- packages/ui/src/pages/home/components/api-keys.tsx | 2 +- packages/ui/src/pages/home/components/extensions.tsx | 2 +- packages/ui/src/pages/home/components/providers.tsx | 4 ++-- packages/ui/src/pages/home/components/routing.tsx | 2 +- packages/ui/src/pages/home/components/virtual-models.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/pages/home/components/api-keys.tsx b/packages/ui/src/pages/home/components/api-keys.tsx index d1b6d445..7a06b95b 100644 --- a/packages/ui/src/pages/home/components/api-keys.tsx +++ b/packages/ui/src/pages/home/components/api-keys.tsx @@ -73,7 +73,7 @@ export function ApiKeysView({
{t("No matching API keys")}
) : null} {visibleApiKeys.length > 0 ? ( -
+
{t("Name")}
diff --git a/packages/ui/src/pages/home/components/extensions.tsx b/packages/ui/src/pages/home/components/extensions.tsx index 05e8b009..307c9af2 100644 --- a/packages/ui/src/pages/home/components/extensions.tsx +++ b/packages/ui/src/pages/home/components/extensions.tsx @@ -66,7 +66,7 @@ export function ExtensionsView({
{t("No matching extensions")}
) : null} {visibleExtensions.length > 0 ? ( -
+
{t("Name")}
diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 640f9e2d..a5e7c011 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -99,7 +99,7 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
{t("No matching providers")}
) : null} {visibleProviders.length > 0 ? ( -
+
{t("Name")}
@@ -340,7 +340,7 @@ export function ModelsView({
{t("No matching models")}
) : null} {visibleRows.length > 0 ? ( -
+
{t("Model")}
diff --git a/packages/ui/src/pages/home/components/routing.tsx b/packages/ui/src/pages/home/components/routing.tsx index a35b8e04..de477ae3 100644 --- a/packages/ui/src/pages/home/components/routing.tsx +++ b/packages/ui/src/pages/home/components/routing.tsx @@ -86,7 +86,7 @@ export function RoutingView({
{t("No matching routing rules")}
) : null} {visibleRules.length > 0 ? ( -
+
{t("Name")}
diff --git a/packages/ui/src/pages/home/components/virtual-models.tsx b/packages/ui/src/pages/home/components/virtual-models.tsx index bbfcc357..ca8a0918 100644 --- a/packages/ui/src/pages/home/components/virtual-models.tsx +++ b/packages/ui/src/pages/home/components/virtual-models.tsx @@ -118,7 +118,7 @@ export function VirtualModelsView({
{t("No matching virtual models")}
) : null} {visibleProfiles.length > 0 ? ( -
+
{t("Name")}
From b893ce8058a9ee4dc0131482ed84eaec3bc419d7 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 10:18:43 +0800 Subject: [PATCH 13/38] Extract shared ModelSelector component for profile settings --- .../pages/home/components/model-selector.tsx | 305 ++++++++++++++++ .../ui/src/pages/home/components/profiles.tsx | 335 ++---------------- .../ui/src/pages/home/components/settings.tsx | 31 +- packages/ui/src/pages/home/shared/common.ts | 14 + .../ui/src/pages/home/shared/controls.tsx | 9 +- packages/ui/src/pages/home/shared/profiles.ts | 18 +- .../ui/src/pages/home/shared/providers.ts | 46 ++- packages/ui/src/pages/home/shared/routing.ts | 23 +- .../src/pages/home/shared/virtual-models.ts | 11 +- tests/main/router-builtins.test.mjs | 76 ++++ tests/renderer/model-selector-format.test.ts | 74 ++++ 11 files changed, 575 insertions(+), 367 deletions(-) create mode 100644 packages/ui/src/pages/home/components/model-selector.tsx create mode 100644 tests/renderer/model-selector-format.test.ts diff --git a/packages/ui/src/pages/home/components/model-selector.tsx b/packages/ui/src/pages/home/components/model-selector.tsx new file mode 100644 index 00000000..104de9a7 --- /dev/null +++ b/packages/ui/src/pages/home/components/model-selector.tsx @@ -0,0 +1,305 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { + AnimatedPopover, + AnimatePresence, + Badge, + Button, + Check, + ChevronDown, + cn, + GatewayProviderConfig, + Input, + parseProfileModelValue, + PopoverContent, + profileModelDisplayValue, + profileModelMatchesQuery, + profileModelOptionDisplayName, + profileModelProviderMatchesQuery, + profileModelProviderOptions, + Search, + useAppText, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + X, + type VirtualModelProfileConfig +} from "../shared/index"; + +export function ModelSelector({ + onChange, + placeholder, + providers, + value, + virtualModelProfiles = [] +}: { + onChange: (value: string) => void; + placeholder?: string; + providers: GatewayProviderConfig[]; + value: string; + virtualModelProfiles?: VirtualModelProfileConfig[]; +}) { + const t = useAppText(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [popoverLayout, setPopoverLayout] = useState<{ + gridHeight: number; + left: number; + maxHeight: number; + offset: number; + placement: "above" | "below"; + width: number; + }>(); + const parsedValue = useMemo(() => parseProfileModelValue(value, providers, virtualModelProfiles), [providers, value, virtualModelProfiles]); + const providerOptions = useMemo(() => profileModelProviderOptions(providers, virtualModelProfiles), [providers, virtualModelProfiles]); + const filteredProviders = useMemo( + () => providerOptions.filter((provider) => profileModelProviderMatchesQuery(provider, query)), + [providerOptions, query] + ); + const [activeProviderName, setActiveProviderName] = useState(""); + const rootRef = useRef(null); + const activeProvider = + filteredProviders.find((provider) => provider.name === activeProviderName) ?? + filteredProviders.find((provider) => provider.name === parsedValue.provider) ?? + filteredProviders[0]; + const filteredModels = activeProvider + ? activeProvider.models.filter((model) => profileModelMatchesQuery(activeProvider.name, model, query, profileModelOptionDisplayName(activeProvider, model))) + : []; + const displayValue = profileModelDisplayValue(value, parsedValue, providers, placeholder, virtualModelProfiles); + + useLayoutEffect(() => { + if (!open) { + setPopoverLayout(undefined); + return; + } + + function updatePopoverLayout() { + const root = rootRef.current; + if (!root) { + return; + } + const anchor = root.getBoundingClientRect(); + const margin = 12; + const gap = 6; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const availableWidth = Math.max(240, viewportWidth - margin * 2); + const width = Math.min(560, availableWidth); + const left = Math.min(Math.max(margin, anchor.left), viewportWidth - margin - width); + const below = Math.max(0, viewportHeight - anchor.bottom - margin - gap); + const above = Math.max(0, anchor.top - margin - gap); + const placement = below < 240 && above > below ? "above" : "below"; + const availableHeight = Math.max(144, placement === "above" ? above : below); + const maxHeight = Math.min(360, availableHeight); + const gridHeight = Math.max(128, Math.min(280, maxHeight - 58)); + setPopoverLayout({ + gridHeight, + left, + maxHeight, + offset: placement === "above" ? viewportHeight - anchor.top + gap : anchor.bottom + gap, + placement, + width + }); + } + + updatePopoverLayout(); + window.addEventListener("resize", updatePopoverLayout); + window.addEventListener("scroll", updatePopoverLayout, true); + return () => { + window.removeEventListener("resize", updatePopoverLayout); + window.removeEventListener("scroll", updatePopoverLayout, true); + }; + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + + function handlePointerDown(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + } + } + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + if (activeProviderName && filteredProviders.some((provider) => provider.name === activeProviderName)) { + return; + } + setActiveProviderName(parsedValue.provider || filteredProviders[0]?.name || ""); + }, [activeProviderName, filteredProviders, open, parsedValue.provider]); + + function chooseModel(providerName: string, model: string) { + onChange(`${providerName}/${model}`); + setOpen(false); + setQuery(""); + setActiveProviderName(providerName); + } + + function openSelector() { + setOpen(true); + setQuery(""); + setActiveProviderName(parsedValue.provider || providerOptions[0]?.name || ""); + } + + function clearValue(event: ReactMouseEvent) { + event.preventDefault(); + event.stopPropagation(); + onChange(""); + setOpen(false); + setQuery(""); + } + + return ( +
+
+ + {value.trim() ? ( + + ) : null} + +
+ + + {open ? ( + + +
+ + setQuery(event.target.value)} + placeholder={t("Search providers or models")} + value={query} + /> +
+ + {providerOptions.length === 0 ? ( +
+ {t("No models configured")} +
+ ) : ( +
+
+ {filteredProviders.length === 0 ? ( +
{t("No matching providers")}
+ ) : null} + {filteredProviders.map((provider) => { + const active = provider.name === activeProvider?.name; + return ( + + ); + })} +
+
+ {!activeProvider ? ( +
{t("No matching models")}
+ ) : null} + {activeProvider && filteredModels.length === 0 ? ( +
{t("No matching models")}
+ ) : null} + {activeProvider && filteredModels.map((model) => { + const selected = parsedValue.provider === activeProvider.name && parsedValue.model === model; + const displayName = profileModelOptionDisplayName(activeProvider, model); + return ( + + ); + })} +
+
+ )} +
+
+ ) : null} +
+
+ ); +} diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 62287498..424429ac 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -1,15 +1,15 @@ import { AddProfileDraft, AgentLogo, AnimatedIconSwap, AnimatedPopover, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, BotHandoffScanTarget, Button, - Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy, - cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, - DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion, - normalizeProfileScope, normalizeProfileSurface, parseProfileModelValue, Pencil, Plus, PopoverContent, - profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelDisplayValue, profileModelMatchesQuery, profileModelProviderMatchesQuery, - profileModelOptionDisplayName, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions, - Play, Power, RefreshCw, Search, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig, + Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy, + cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, + DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion, + normalizeProfileScope, normalizeProfileSurface, Pencil, Plus, PopoverContent, + profileAgentLabel, profileAgentOptions, ProfileConfig, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions, + Play, Power, RefreshCw, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig, copyTextToClipboard, - useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, X + useCallback, useEffect, useMemo, useRef, useState, X } from "../shared/index"; +import { ModelSelector } from "./model-selector"; type ProfileActionBusy = { profileId: string; @@ -479,283 +479,6 @@ function AgentSelectControl({ ); } -function ProfileModelSelector({ - onChange, - placeholder, - providers, - value, - virtualModelProfiles = [] -}: { - onChange: (value: string) => void; - placeholder?: string; - providers: GatewayProviderConfig[]; - value: string; - virtualModelProfiles?: VirtualModelProfileConfig[]; -}) { - const t = useAppText(); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const [popoverLayout, setPopoverLayout] = useState<{ - gridHeight: number; - left: number; - maxHeight: number; - offset: number; - placement: "above" | "below"; - width: number; - }>(); - const parsedValue = useMemo(() => parseProfileModelValue(value, providers, virtualModelProfiles), [providers, value, virtualModelProfiles]); - const providerOptions = useMemo(() => profileModelProviderOptions(providers, virtualModelProfiles), [providers, virtualModelProfiles]); - const filteredProviders = useMemo( - () => providerOptions.filter((provider) => profileModelProviderMatchesQuery(provider, query)), - [providerOptions, query] - ); - const [activeProviderName, setActiveProviderName] = useState(""); - const rootRef = useRef(null); - const activeProvider = - filteredProviders.find((provider) => provider.name === activeProviderName) ?? - filteredProviders.find((provider) => provider.name === parsedValue.provider) ?? - filteredProviders[0]; - const filteredModels = activeProvider - ? activeProvider.models.filter((model) => profileModelMatchesQuery(activeProvider.name, model, query, profileModelOptionDisplayName(activeProvider, model))) - : []; - const displayValue = profileModelDisplayValue(value, parsedValue, providers, placeholder, virtualModelProfiles); - - useLayoutEffect(() => { - if (!open) { - setPopoverLayout(undefined); - return; - } - - function updatePopoverLayout() { - const root = rootRef.current; - if (!root) { - return; - } - const anchor = root.getBoundingClientRect(); - const margin = 12; - const gap = 6; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const availableWidth = Math.max(240, viewportWidth - margin * 2); - const width = Math.min(560, availableWidth); - const left = Math.min(Math.max(margin, anchor.left), viewportWidth - margin - width); - const below = Math.max(0, viewportHeight - anchor.bottom - margin - gap); - const above = Math.max(0, anchor.top - margin - gap); - const placement = below < 240 && above > below ? "above" : "below"; - const availableHeight = Math.max(144, placement === "above" ? above : below); - const maxHeight = Math.min(360, availableHeight); - const gridHeight = Math.max(128, Math.min(280, maxHeight - 58)); - setPopoverLayout({ - gridHeight, - left, - maxHeight, - offset: placement === "above" ? viewportHeight - anchor.top + gap : anchor.bottom + gap, - placement, - width - }); - } - - updatePopoverLayout(); - window.addEventListener("resize", updatePopoverLayout); - window.addEventListener("scroll", updatePopoverLayout, true); - return () => { - window.removeEventListener("resize", updatePopoverLayout); - window.removeEventListener("scroll", updatePopoverLayout, true); - }; - }, [open]); - - useEffect(() => { - if (!open) { - return; - } - - function handlePointerDown(event: MouseEvent) { - if (!rootRef.current?.contains(event.target as Node)) { - setOpen(false); - } - } - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - setOpen(false); - } - } - - document.addEventListener("mousedown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("mousedown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [open]); - - useEffect(() => { - if (!open) { - return; - } - if (activeProviderName && filteredProviders.some((provider) => provider.name === activeProviderName)) { - return; - } - setActiveProviderName(parsedValue.provider || filteredProviders[0]?.name || ""); - }, [activeProviderName, filteredProviders, open, parsedValue.provider]); - - function chooseModel(providerName: string, model: string) { - onChange(`${providerName}/${model}`); - setOpen(false); - setQuery(""); - setActiveProviderName(providerName); - } - - function openSelector() { - setOpen(true); - setQuery(""); - setActiveProviderName(parsedValue.provider || providerOptions[0]?.name || ""); - } - - function clearValue(event: React.MouseEvent) { - event.preventDefault(); - event.stopPropagation(); - onChange(""); - setOpen(false); - setQuery(""); - } - - return ( -
-
- - {value.trim() ? ( - - ) : null} - -
- - - {open ? ( - - -
- - setQuery(event.target.value)} - placeholder={t("Search providers or models")} - value={query} - /> -
- - {providerOptions.length === 0 ? ( -
- {t("No models configured")} -
- ) : ( -
-
- {filteredProviders.length === 0 ? ( -
{t("No matching providers")}
- ) : null} - {filteredProviders.map((provider) => { - const active = provider.name === activeProvider?.name; - return ( - - ); - })} -
-
- {!activeProvider ? ( -
{t("No matching models")}
- ) : null} - {activeProvider && filteredModels.length === 0 ? ( -
{t("No matching models")}
- ) : null} - {activeProvider && filteredModels.map((model) => { - const selected = parsedValue.provider === activeProvider.name && parsedValue.model === model; - const displayName = profileModelOptionDisplayName(activeProvider, model); - return ( - - ); - })} -
-
- )} -
-
- ) : null} -
-
- ); -} - export function AddProfileForm({ botConfigs, draft, @@ -864,22 +587,22 @@ export function AddProfileForm({ {draft.agent === "claude-code" ? ( <> - onChange({ model })} - /> + onChange({ model })} + /> - onChange({ smallFastModel })} - /> + onChange({ smallFastModel })} + /> ) : ( @@ -897,13 +620,13 @@ export function AddProfileForm({
) : null} - onChange({ model })} - /> + onChange({ model })} + /> )} diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index 6a8cb22e..de6aebce 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -5,14 +5,15 @@ import { CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, endpointFromHostPort, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge, Globe, - createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, createRouteModelOptions, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle, - mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeProxyUpstreamConfig, normalizeToolHubConfig, numberValue, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel, + createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle, + mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeProviderModelSelector, normalizeProxyUpstreamConfig, normalizeToolHubConfig, numberValue, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel, PanelLeftOpen, Power, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl, PointerSensor, rectSortingStrategy, Settings, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName, translateOptions, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, appLogoUrl, trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, Textarea, Trash2, trayWidgetVariantOptions, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, validateMcpServerDraft, X } from "../shared/index"; +import { ModelSelector } from "./model-selector"; const settingsPageContentWidthClassName = "mx-auto w-full max-w-[900px]"; @@ -566,7 +567,6 @@ function ToolHubSettingsPage({ toolHub: AppConfig["toolHub"]; }) { const t = useAppText(); - const modelOptions = useMemo(() => createRouteModelOptions(providers), [providers]); const selectedProviderModel = useMemo(() => selectedToolHubProviderModelValue(toolHub, providers), [providers, toolHub]); const [mcpDialogDraft, setMcpDialogDraft] = useState(() => createMcpServerDraft(toolHub.mcpServers)); const [mcpDialogError, setMcpDialogError] = useState(""); @@ -578,6 +578,12 @@ function ToolHubSettingsPage({ const selectProviderModel = (value: string) => { if (!value) { + onChange({ + llm: { + ...toolHub.llm, + model: "" + } + }); return; } const parsed = parseProviderModelSelectValue(value); @@ -697,12 +703,10 @@ function ToolHubSettingsPage({ />
- @@ -1054,16 +1058,17 @@ function selectedToolHubProviderModelValue(toolHub: AppConfig["toolHub"], provid provider.models?.includes(model) && (!baseUrl || !providerBaseUrl(provider) || providerBaseUrl(provider) === baseUrl) ) ?? providers.find((provider) => provider.models?.includes(model)); - return matchedProvider ? `${matchedProvider.name},${model}` : ""; + return matchedProvider ? `${matchedProvider.name}/${model}` : normalizeProviderModelSelector(model); } function parseProviderModelSelectValue(value: string): { model: string; provider: string } | undefined { - const commaIndex = value.indexOf(","); - if (commaIndex <= 0 || commaIndex >= value.length - 1) { + const normalized = normalizeProviderModelSelector(value); + const slashIndex = normalized.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= normalized.length - 1) { return undefined; } - const provider = value.slice(0, commaIndex).trim(); - const model = value.slice(commaIndex + 1).trim(); + const provider = normalized.slice(0, slashIndex).trim(); + const model = normalized.slice(slashIndex + 1).trim(); return provider && model ? { model, provider } : undefined; } diff --git a/packages/ui/src/pages/home/shared/common.ts b/packages/ui/src/pages/home/shared/common.ts index d3864246..f510fbab 100644 --- a/packages/ui/src/pages/home/shared/common.ts +++ b/packages/ui/src/pages/home/shared/common.ts @@ -392,6 +392,20 @@ export function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +export function normalizeProviderModelSelector(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}` : trimmed; + } + return trimmed; +} + export function uniqueStrings(values: string[]): string[] { const seen = new Set(); const result: string[] = []; diff --git a/packages/ui/src/pages/home/shared/controls.tsx b/packages/ui/src/pages/home/shared/controls.tsx index fdda6043..442fc694 100644 --- a/packages/ui/src/pages/home/shared/controls.tsx +++ b/packages/ui/src/pages/home/shared/controls.tsx @@ -372,7 +372,7 @@ import type { AgentFilterValue, RouterConditionSource } from "./options"; import type { MotionSafeDivAttributes } from "./motion"; -import { metricToneBar } from "./common"; +import { metricToneBar, normalizeProviderModelSelector } from "./common"; import { profileAgentLabel, profileAgentLogoUrl } from "./profiles"; import { routeTargetOptions } from "./providers"; import { createKeyValueDraftRow } from "./virtual-models"; @@ -424,13 +424,14 @@ export function RouteTargetControl({ value: string; }) { const t = useAppText(); + const normalizedValue = normalizeProviderModelSelector(value); if (modelOptions.length === 0) { - return onChange(event.target.value)} value={value} />; + return onChange(event.target.value)} value={normalizedValue} />; } - const options = routeTargetOptions(modelOptions, value); - return ; + const options = routeTargetOptions(modelOptions, normalizedValue); + return ; } export function TextAreaControl({ diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index cb593e57..346d1c6c 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -374,7 +374,7 @@ import type { AgentFilterValue, RouterConditionSource } from "./options"; import type { MotionSafeDivAttributes } from "./motion"; -import { isPlainRecord, stringValue, uniqueStrings } from "./common"; +import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; import { virtualModelProfileModelNames } from "./providers"; import { endpointFromHostPort } from "./services"; import { keyValueRowsFromRecord, recordFromKeyValueRows, stringRecordValue, validateProfileEnvRows } from "./virtual-models"; @@ -397,17 +397,7 @@ export function defaultProfileClientModel(config: AppConfig): string { } export function normalizeProfileClientModel(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; + return normalizeProviderModelSelector(value); } export type ProfileModelProviderOption = { @@ -453,13 +443,9 @@ export function parseProfileModelValue( const providerOptions = profileModelProviderOptions(providers, virtualModelProfiles); for (const provider of providerOptions) { const slashPrefix = `${provider.name}/`; - const commaPrefix = `${provider.name},`; if (trimmed.startsWith(slashPrefix)) { return { model: trimmed.slice(slashPrefix.length).trim(), provider: provider.name }; } - if (trimmed.startsWith(commaPrefix)) { - return { model: trimmed.slice(commaPrefix.length).trim(), provider: provider.name }; - } } const slashIndex = trimmed.indexOf("/"); if (slashIndex > 0 && slashIndex < trimmed.length - 1) { diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index 56f5dd61..1b3b295f 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -378,7 +378,7 @@ import type { MotionSafeDivAttributes } from "./motion"; import { normalizeApiKeyLimits, positiveInteger } from "./api-keys"; -import { isPlainRecord, stringValue, uniqueStrings } from "./common"; +import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; import { formatEditableJson } from "./extensions"; import { findProviderPreset, findProviderPresetByBaseUrl, findProviderPresetByIdentity, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey, providerIdentitySafetyIssue } from "./external"; import { fusionModelProviderName } from "./profiles"; @@ -492,16 +492,17 @@ export function createRouteModelOptions(providers: GatewayProviderConfig[]): Arr return provider.models .filter(Boolean) .map((model) => ({ - label: `${provider.name}, ${providerModelDisplayName(provider, model)}`, - value: `${provider.name},${model}` + label: `${provider.name}/${providerModelDisplayName(provider, model)}`, + value: `${provider.name}/${model}` })); }); } export function routeTargetOptions(modelOptions: Array<{ label: string; value: string }>, value: string): Array<{ label: string; value: string }> { + const normalizedValue = normalizeProviderModelSelector(value); const options = [{ label: "Unset", value: "" }, ...modelOptions]; - if (value && !options.some((option) => option.value === value)) { - return [{ label: value, value }, ...options]; + if (normalizedValue && !options.some((option) => option.value === normalizedValue)) { + return [{ label: normalizedValue, value: normalizedValue }, ...options]; } return options; } @@ -549,13 +550,13 @@ export function routerRuleRewriteFromRule(rule: RouterRule): RouterRuleRewrite | export function routerRuleRewritesFromRule(rule: RouterRule): RouterRuleRewrite[] { if (rule.rewrites?.length) { - return rule.rewrites; + return rule.rewrites.map(normalizeRouterModelRewrite); } if (rule.rewrite) { - return [rule.rewrite]; + return [normalizeRouterModelRewrite(rule.rewrite)]; } return rule.target - ? [{ key: "request.body.model", operation: "set", value: rule.target }] + ? [{ key: "request.body.model", operation: "set", value: normalizeProviderModelSelector(rule.target) }] : []; } @@ -580,7 +581,8 @@ export function formatRouterFallbackSummary(fallback: RouterFallbackConfig): str if (fallback.mode === "retry") { return `retry ${fallback.retryCount}x`; } - return fallback.models.length ? `on failure ${fallback.models.join(" > ")}` : "fallback targets unset"; + const models = fallback.models.map(normalizeProviderModelSelector); + return models.length ? `on failure ${models.join(" > ")}` : "fallback targets unset"; } export function routerRuleMatchesQuery(rule: RouterRule, query: string): boolean { @@ -686,12 +688,13 @@ export function createRoutingRewriteDraftRow(): RoutingRewriteDraftRow { } export function createRoutingRewriteDraftRowFromRewrite(rewrite: RouterRuleRewrite): RoutingRewriteDraftRow { + const key = rewrite.key; return { id: `rewrite-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - key: rewrite.key, + key, match: rewrite.match ?? "", operation: rewrite.operation ?? "set", - value: rewrite.value ?? "" + value: normalizeRouterModelRewriteValue(key, rewrite.value) }; } @@ -709,14 +712,31 @@ export function isRoutingRewriteDraftRowValid(row: RoutingRewriteDraftRow): bool } export function routingRewriteFromDraftRow(row: RoutingRewriteDraftRow): RouterRuleRewrite { + const key = row.key.trim(); + const value = normalizeRouterModelRewriteValue(key, row.value); return { - key: row.key.trim(), + key, ...(row.operation !== "set" ? { operation: row.operation } : { operation: "set" as const }), ...(row.operation === "array-replace" ? { match: row.match.trim() } : {}), - ...(row.operation !== "delete" ? { value: row.value.trim() } : {}) + ...(row.operation !== "delete" ? { value } : {}) }; } +function normalizeRouterModelRewrite(rewrite: RouterRuleRewrite): RouterRuleRewrite { + return isModelRewriteKey(rewrite.key) && rewrite.value + ? { ...rewrite, value: normalizeProviderModelSelector(rewrite.value) } + : rewrite; +} + +function normalizeRouterModelRewriteValue(key: string, value: string | undefined): string { + const trimmed = value?.trim() ?? ""; + return isModelRewriteKey(key) ? normalizeProviderModelSelector(trimmed) : trimmed; +} + +function isModelRewriteKey(key: string | undefined): boolean { + return key?.trim() === "request.body.model"; +} + export function uniqueRoutingRuleId(rules: RouterRule[]): string { let index = rules.length + 1; let id = `rule-${index}`; diff --git a/packages/ui/src/pages/home/shared/routing.ts b/packages/ui/src/pages/home/shared/routing.ts index bcc952a6..69364b84 100644 --- a/packages/ui/src/pages/home/shared/routing.ts +++ b/packages/ui/src/pages/home/shared/routing.ts @@ -376,7 +376,7 @@ import type { MotionSafeDivAttributes } from "./motion"; import { positiveInteger } from "./api-keys"; -import { isPlainRecord, stringValue, uniqueStrings } from "./common"; +import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; import { sanitizeConfigId } from "./extensions"; import { formatRouterRuleCondition, formatRouterRuleTarget, routerRuleTypeLabel } from "./providers"; import { clampNumber } from "./services"; @@ -420,7 +420,11 @@ export function normalizeRouterFallbackConfig(value: Partial stringValue(model)).filter((model): model is string => Boolean(model))) + ? uniqueStrings( + record.models + .map((model) => normalizeProviderModelSelector(stringValue(model))) + .filter(Boolean) + ) : []; return { @@ -460,7 +464,7 @@ export function normalizeRouterRules(value: unknown): RouterRule[] | undefined { return undefined; } const pattern = stringValue(item.pattern); - const target = stringValue(item.target); + const target = normalizeProviderModelSelector(stringValue(item.target)); const threshold = Number(item.threshold); const condition = normalizeRouterRuleCondition(item.condition ?? item) ?? routerRuleConditionFromLegacy(type, { pattern @@ -542,7 +546,7 @@ export function normalizeRouterRuleRewrites(rule: Record): Rout .filter((item): item is RouterRuleRewrite => Boolean(item)); } const rewrite = normalizeRouterRuleRewrite(rule.rewrite ?? rule.action); - const target = stringValue(rule.target); + const target = normalizeProviderModelSelector(stringValue(rule.target)); return [ ...(rewrite ? [rewrite] : []), ...(target ? [{ key: "request.body.model", operation: "set" as const, value: target }] : []) @@ -560,7 +564,7 @@ export function normalizeRouterRuleRewrite(value: unknown): RouterRuleRewrite | stringValue(value.field) ?? stringValue(value.parameter); const operation = parseRouterRewriteOperation(value.operation ?? value.op ?? value.type) ?? "set"; - const rewriteValue = stringifyRewriteValue(value.value); + const rewriteValue = normalizeRouterRewriteValue(key, stringifyRewriteValue(value.value)); const match = stringifyRewriteValue(value.match); if (!key) { @@ -596,6 +600,13 @@ function stringifyRewriteValue(value: unknown): string | undefined { return value !== undefined ? String(value) : undefined; } +function normalizeRouterRewriteValue(key: string | undefined, value: string | undefined): string | undefined { + if (key?.trim() !== "request.body.model" || value === undefined) { + return value; + } + return normalizeProviderModelSelector(value); +} + export function parseRouterRuleType(value: unknown): RouterRuleType | undefined { if (typeof value !== "string") { return undefined; @@ -982,7 +993,7 @@ export function composeRouteTargetValue(providerValue: unknown, modelValue: unkn const provider = stringValue(providerValue); const model = stringValue(modelValue); if (provider && model) { - return `${provider},${model}`; + return `${provider}/${model}`; } return model || provider; } diff --git a/packages/ui/src/pages/home/shared/virtual-models.ts b/packages/ui/src/pages/home/shared/virtual-models.ts index 7bfe4466..674aed59 100644 --- a/packages/ui/src/pages/home/shared/virtual-models.ts +++ b/packages/ui/src/pages/home/shared/virtual-models.ts @@ -372,7 +372,7 @@ import type { AgentFilterValue, RouterConditionSource } from "./options"; import type { MotionSafeDivAttributes } from "./motion"; -import { isPlainRecord, stringValue, uniqueStrings } from "./common"; +import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; import { sanitizeConfigId } from "./extensions"; import { createRouteModelOptions, numberValue } from "./providers"; import { clampNumber } from "./services"; @@ -863,14 +863,7 @@ export function parseVirtualModelTextList(value: string): string[] { } export function normalizeCoreModelSelector(value: string): string { - const trimmed = value.trim(); - 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}` : trimmed; - } - return trimmed; + return normalizeProviderModelSelector(value); } export function uniqueVirtualModelKey(profiles: VirtualModelProfileConfig[]): string { diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index b461f525..5e4bfdcb 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -432,6 +432,82 @@ test("router rules override the built-in Claude Code profile route", async () => assert.equal(result.decision.reason, "rule:default"); }); +test("router rules normalize legacy comma provider selectors before gateway provider routing", async () => { + const config = { + CUSTOM_ROUTER_PATH: "", + Providers: [ + { + capabilities: [ + { + baseUrl: "https://provider.example/v1", + type: "openai_chat_completions" + } + ], + credentials: [{ apiKey: "provider-key", id: "provider-main" }], + models: ["gpt-5-codex"], + name: "Provider" + } + ], + Router: { + builtInRules: { + "claude-code": { enabled: false }, + codex: { enabled: false } + }, + fallback: { mode: "off", models: [], retryCount: 1 }, + rules: [ + { + condition: { + left: "request.url", + operator: "contains", + right: "/v1" + }, + enabled: true, + id: "comma-selector", + name: "Legacy comma selector", + rewrites: [ + { key: "request.body.model", operation: "set", value: "Provider,gpt-5-codex" } + ], + type: "condition" + } + ] + }, + profile: { + enabled: false, + profiles: [] + }, + virtualModelProfiles: [] + }; + const plugin = new ClaudeCodeRouterPlugin(config); + const headers = {}; + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "claude-default" + }, + headers, + method: "POST", + url: "/v1/messages" + }); + + assert.equal(result.body.model, "Provider/gpt-5-codex"); + assert.equal(result.decision.model, "Provider/gpt-5-codex"); + assert.equal(result.decision.reason, "rule:comma-selector"); + + const upstreamAttempt = prepareGatewayUpstreamAttemptForTest({ + body: result.body, + config, + fallback: result.decision.fallback, + headers, + method: "POST", + path: "/v1/messages", + routedModel: result.decision.model + }); + + assert.equal(upstreamAttempt.logicalProvider, "Provider"); + assert.equal(upstreamAttempt.credentialProtocol, "openai_chat_completions"); + assert.equal(upstreamAttempt.body.model, "gpt-5-codex"); +}); + test("router rules can add headers after the built-in Claude Code profile route", async () => { const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet", diff --git a/tests/renderer/model-selector-format.test.ts b/tests/renderer/model-selector-format.test.ts new file mode 100644 index 00000000..551e9ebd --- /dev/null +++ b/tests/renderer/model-selector-format.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeProviderModelSelector } from "../../packages/ui/src/pages/home/shared/common.ts"; +import { normalizeProfileClientModel } from "../../packages/ui/src/pages/home/shared/profiles.ts"; +import { + createRouteModelOptions, + createRoutingRewriteDraftRowFromRewrite, + routingRewriteFromDraftRow +} from "../../packages/ui/src/pages/home/shared/providers.ts"; +import { + composeRouteTargetValue, + normalizeRouterFallbackConfig, + normalizeRouterRules +} from "../../packages/ui/src/pages/home/shared/routing.ts"; +import { normalizeCoreModelSelector } from "../../packages/ui/src/pages/home/shared/virtual-models.ts"; + +test("route model options emit slash-form provider selectors", () => { + const options = createRouteModelOptions([ + { + models: ["glm-5"], + name: "Zhipu" + } as any + ]); + + assert.deepEqual(options, [ + { + label: "Zhipu/glm-5", + value: "Zhipu/glm-5" + } + ]); +}); + +test("UI model selector helpers normalize legacy comma selectors", () => { + assert.equal(normalizeProviderModelSelector("Zhipu,glm-5"), "Zhipu/glm-5"); + assert.equal(normalizeProfileClientModel("Zhipu,glm-5"), "Zhipu/glm-5"); + assert.equal(normalizeCoreModelSelector("Zhipu,glm-5"), "Zhipu/glm-5"); + assert.equal(composeRouteTargetValue("Zhipu", "glm-5"), "Zhipu/glm-5"); +}); + +test("router UI drafts read and write model selectors in slash form", () => { + const row = createRoutingRewriteDraftRowFromRewrite({ + key: "request.body.model", + operation: "set", + value: "Zhipu,glm-5" + }); + + assert.equal(row.value, "Zhipu/glm-5"); + assert.equal(routingRewriteFromDraftRow({ ...row, value: "Zhipu,glm-5" }).value, "Zhipu/glm-5"); + + const fallback = normalizeRouterFallbackConfig({ + mode: "model-chain", + models: ["Zhipu,glm-5"], + retryCount: 1 + }); + assert.deepEqual(fallback.models, ["Zhipu/glm-5"]); + + const rules = normalizeRouterRules([ + { + condition: { + left: "request.body.model", + operator: "==", + right: "claude" + }, + id: "legacy-comma", + rewrite: { + key: "request.body.model", + value: "Zhipu,glm-5" + }, + type: "condition" + } + ]); + + assert.equal(rules?.[0]?.rewrite?.value, "Zhipu/glm-5"); +}); From 645fde18788234aea132af71007102c51f16beb8 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 15:16:07 +0800 Subject: [PATCH 14/38] Refactor router execution flow and update related tests --- packages/core/src/agents/request-enricher.ts | 20 + .../src/gateway/claude-code-router-plugin.ts | 302 +++++++------ packages/core/src/gateway/service.ts | 396 ++++++++---------- packages/core/src/routing/config-compiler.ts | 199 +++++++++ packages/core/src/routing/contracts.ts | 67 +++ packages/core/src/routing/execution-plan.ts | 73 ++++ .../core/src/routing/failure-classifier.ts | 31 ++ packages/core/src/routing/model-registry.ts | 241 +++++++++++ packages/core/src/routing/policy-engine.ts | 23 + packages/core/src/routing/protocol-adapter.ts | 53 +++ tests/main/gateway-virtual-models.test.mjs | 4 +- tests/main/router-builtins.test.mjs | 360 +++++++++++++++- tests/main/routing-architecture.test.mjs | 288 +++++++++++++ 13 files changed, 1682 insertions(+), 375 deletions(-) create mode 100644 packages/core/src/agents/request-enricher.ts create mode 100644 packages/core/src/routing/config-compiler.ts create mode 100644 packages/core/src/routing/contracts.ts create mode 100644 packages/core/src/routing/execution-plan.ts create mode 100644 packages/core/src/routing/failure-classifier.ts create mode 100644 packages/core/src/routing/model-registry.ts create mode 100644 packages/core/src/routing/policy-engine.ts create mode 100644 packages/core/src/routing/protocol-adapter.ts create mode 100644 tests/main/routing-architecture.test.mjs diff --git a/packages/core/src/agents/request-enricher.ts b/packages/core/src/agents/request-enricher.ts new file mode 100644 index 00000000..9686d3f9 --- /dev/null +++ b/packages/core/src/agents/request-enricher.ts @@ -0,0 +1,20 @@ +export type AgentRequestEnricher = { + enrich: (request: TRequest) => void; + id: string; + matches: (request: TRequest) => boolean; +}; + +export function applyAgentRequestEnrichers( + request: TRequest, + enrichers: AgentRequestEnricher[] +): string[] { + const applied: string[] = []; + for (const enricher of enrichers) { + if (!enricher.matches(request)) { + continue; + } + enricher.enrich(request); + applied.push(enricher.id); + } + return applied; +} diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts index 579ea5d8..63d97088 100644 --- a/packages/core/src/gateway/claude-code-router-plugin.ts +++ b/packages/core/src/gateway/claude-code-router-plugin.ts @@ -2,44 +2,41 @@ import { createRequire } from "node:module"; import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; -import { availableGatewayModelIds, type AppConfig, type RouterBuiltInAgentRuleId, type RouterConfig, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "@ccr/core/contracts/app"; +import { type AppConfig, type RouterBuiltInAgentRuleId, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "@ccr/core/contracts/app"; import { CONFIGDIR } from "@ccr/core/config/constants"; +import { applyAgentRequestEnrichers } from "@ccr/core/agents/request-enricher"; +import { compileRouterConfig, type CompiledRouterConfig, type CompiledRouterRule } from "@ccr/core/routing/config-compiler"; +import type { RouteDecision, RouteDiagnostic, RouteModelRef, RouteRequest, RouteSource } from "@ccr/core/routing/contracts"; +import { ModelRegistry, normalizeRouteSelector } from "@ccr/core/routing/model-registry"; +import { RoutePolicyEngine, type RoutePolicy } from "@ccr/core/routing/policy-engine"; + +export { normalizeRouteSelector } from "@ccr/core/routing/model-registry"; type HeaderValue = string | string[] | undefined; -export type MutableRequestLike = { - builtInSubagentModel?: string; - body: Record; - headers: Record; - log: Pick; - method: string; - sessionId?: string; - tokenCount?: number; - url: string; -}; +export type MutableRequestLike = RouteRequest; export type ClaudeCodeRouteDecision = { - fallback?: RouterFallbackConfig; + diagnostics: RouteDiagnostic[]; + fallback: RouterFallbackConfig; model?: string; reason: string; sessionId?: string; + source: RouteSource; tokenCount: number; }; -type ConfiguredRouteDecision = { - fallback?: RouterFallbackConfig; - model?: string; - reason: string; - rewrite?: RouterRuleRewrite; - rewrites?: RouterRuleRewrite[]; -}; +type ConfiguredRouteDecision = Omit; const requireFromHere = createRequire(__filename); export class ClaudeCodeRouterPlugin { + private readonly compiled: CompiledRouterConfig; private readonly event = new EventEmitter(); - constructor(private readonly config: AppConfig) {} + constructor(private readonly config: AppConfig) { + this.compiled = compileRouterConfig(config); + } async routeRequest(input: { body: Record; @@ -55,42 +52,51 @@ export class ClaudeCodeRouterPlugin { method: input.method, url: input.url }; - if (builtInAgentRouteMatches(request, this.config, "claude-code")) { - injectClaudeCodeAgentToolDescription(body, this.config); - injectClaudeCodeToolHubInstructions(body, this.config); - removeClaudeCodeBillingSystemHeader(body); - request.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(body); - } + applyAgentRequestEnrichers(request, [{ + enrich: (matchedRequest) => { + injectClaudeCodeAgentToolDescription(matchedRequest.body, this.config); + injectClaudeCodeToolHubInstructions(matchedRequest.body, this.config); + removeClaudeCodeBillingSystemHeader(matchedRequest.body); + matchedRequest.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(matchedRequest.body); + }, + id: "claude-code", + matches: (candidate) => builtInAgentRouteMatches(candidate, this.config, "claude-code") + }]); const sessionId = resolveSessionId(body, input.headers); const tokenCount = calculateTokenCount(body.messages, body.system, body.tools); request.sessionId = sessionId; request.tokenCount = tokenCount; - const customModel = await this.resolveCustomRoute(request); - const configuredDecision = resolveConfiguredRouteDecision(request, this.config); - if (customModel) { - body.model = customModel; - } else { - if (configuredDecision.rewrites?.length) { - for (const rewrite of configuredDecision.rewrites) { - applyRouterRewrite(rewrite, request); - } - } else if (configuredDecision.rewrite) { - applyRouterRewrite(configuredDecision.rewrite, request); - } - if (configuredDecision.model) { - body.model = configuredDecision.model; + const requestedCustomModel = await this.resolveCustomRoute(request); + const customModel = this.compiled.modelRegistry.resolve(requestedCustomModel); + const customDiagnostic: RouteDiagnostic[] = requestedCustomModel && !customModel + ? [{ + code: "custom-model-not-configured", + message: `Custom router returned unconfigured model "${requestedCustomModel}".`, + model: requestedCustomModel, + source: "custom" + }] + : []; + const configuredDecision = resolveConfiguredRouteDecision(request, this.config, this.compiled, customModel); + if (configuredDecision.rewrites.length) { + for (const rewrite of configuredDecision.rewrites) { + applyRouterRewrite(rewrite, request); } } - const routedModel = customModel ?? configuredDecision.model ?? readString(body.model); + if (configuredDecision.model) { + body.model = configuredDecision.model.selector; + } + const routedModel = configuredDecision.model?.selector ?? readString(body.model); return { body, decision: { - fallback: customModel ? this.config.Router.fallback : configuredDecision.fallback, + diagnostics: [...this.compiled.diagnostics, ...customDiagnostic], + fallback: configuredDecision.fallback, model: routedModel, - reason: customModel ? "custom-router" : configuredDecision.reason, + reason: configuredDecision.reason, sessionId, + source: configuredDecision.source, tokenCount } }; @@ -102,6 +108,10 @@ export class ClaudeCodeRouterPlugin { }; } + getRouteDiagnostics(): RouteDiagnostic[] { + return [...this.compiled.diagnostics]; + } + private async resolveCustomRoute(request: MutableRequestLike): Promise { const routerPath = this.config.CUSTOM_ROUTER_PATH; if (!routerPath) { @@ -184,104 +194,134 @@ function isPathInside(file: string, root: string): boolean { function resolveConfiguredRouteDecision( request: MutableRequestLike, - config: AppConfig + config: AppConfig, + compiled: CompiledRouterConfig, + customModel?: RouteModelRef ): ConfiguredRouteDecision { - const builtInSubagentDecision = resolveBuiltInClaudeCodeSubagentRouteDecision(request, config); - if (builtInSubagentDecision) { - return builtInSubagentDecision; - } - const requestedModel = readString(request.body.model); const explicitModel = normalizeRouteSelector(requestedModel); - if (explicitModel && isKnownInlineRoute(explicitModel, config)) { - return { fallback: config.Router.fallback, model: explicitModel, reason: "inline-model" }; - } - - const router = config.Router; - const builtInDecision = resolveBuiltInAgentRouteDecision(request, config); - const rules = router.rules ?? []; - for (const rule of rules) { - const decision = resolveRouterRule(rule, request, router); - if (decision) { - return builtInDecision ? mergeConfiguredRouteDecisions(builtInDecision, decision) : decision; + const builtInDecision = resolveBuiltInAgentRouteDecision(request, config, compiled.modelRegistry, compiled.fallback); + const policies: Array> = [ + { + evaluate: () => customModel + ? { + fallback: compiled.fallback, + model: customModel, + reason: "custom-router", + rewrites: [], + source: "custom" + } + : undefined, + id: "custom" + }, + { + evaluate: (context) => resolveBuiltInClaudeCodeSubagentRouteDecision( + context, + config, + compiled.modelRegistry, + compiled.fallback + ), + id: "subagent" + }, + ...compiled.rules.map((rule): RoutePolicy => ({ + evaluate: (context) => { + const decision = resolveRouterRule(rule, context, compiled.fallback); + return decision && builtInDecision + ? mergeConfiguredRouteDecisions(builtInDecision, decision) + : decision; + }, + id: `rule:${rule.rule.id}` + })), + { + evaluate: () => builtInDecision, + id: "builtin" + }, + { + evaluate: () => ({ + fallback: compiled.fallback, + model: compiled.modelRegistry.resolve(explicitModel), + reason: "default", + rewrites: [], + source: "default" + }), + id: "default" } - } - - if (builtInDecision) { - return builtInDecision; - } - - return { fallback: router.fallback, model: explicitModel, reason: "default" }; + ]; + const match = new RoutePolicyEngine(policies).evaluate(request); + return match?.decision ?? { + fallback: compiled.fallback, + model: compiled.modelRegistry.resolve(explicitModel), + reason: "default", + rewrites: [], + source: "default" + }; } function mergeConfiguredRouteDecisions( base: ConfiguredRouteDecision, override: ConfiguredRouteDecision ): ConfiguredRouteDecision { - const rewrites = [ - ...configuredRouteDecisionRewrites(base), - ...configuredRouteDecisionRewrites(override) - ]; + const rewrites = [...base.rewrites, ...override.rewrites]; return { fallback: override.fallback ?? base.fallback, model: override.model ?? base.model, reason: override.reason, - ...(rewrites.length === 1 ? { rewrite: rewrites[0] } : {}), - ...(rewrites.length > 0 ? { rewrites } : {}) + source: override.source, + rewrites }; } -function configuredRouteDecisionRewrites(decision: ConfiguredRouteDecision): RouterRuleRewrite[] { - if (decision.rewrites?.length) { - return decision.rewrites; - } - return decision.rewrite ? [decision.rewrite] : []; -} - function resolveBuiltInClaudeCodeSubagentRouteDecision( request: MutableRequestLike, - config: AppConfig + config: AppConfig, + modelRegistry: ModelRegistry, + fallback: RouterFallbackConfig ): ConfiguredRouteDecision | undefined { if (!builtInAgentRouteMatches(request, config, "claude-code")) { return undefined; } const target = normalizeRouteSelector(request.builtInSubagentModel); - if (!target || isSubagentModelPlaceholder(target) || !isKnownInlineRoute(target, config)) { + const configuredTarget = modelRegistry.resolve(target); + if (!target || isSubagentModelPlaceholder(target) || !configuredTarget) { return undefined; } return { - fallback: config.Router.fallback, - model: target, + fallback, + model: configuredTarget, reason: "builtin:claude-code-subagent", - rewrite: { + rewrites: [{ key: "request.body.model", operation: "set", - value: target - } + value: configuredTarget.selector + }], + source: "subagent", }; } function resolveBuiltInAgentRouteDecision( request: MutableRequestLike, - config: AppConfig + config: AppConfig, + modelRegistry: ModelRegistry, + fallback: RouterFallbackConfig ): ConfiguredRouteDecision | undefined { for (const agent of builtInAgentRuleIds) { if (!builtInAgentRouteMatches(request, config, agent)) { continue; } - const target = resolveBuiltInAgentRouteTarget(config, agent); + const target = modelRegistry.resolve(resolveBuiltInAgentRouteTarget(config, agent)); if (!target) { continue; } return { - fallback: config.Router.fallback, + fallback, model: target, reason: `builtin:${agent}`, - rewrite: { + rewrites: [{ key: "request.body.model", operation: "set", - value: target - } + value: target.selector + }], + source: "builtin", }; } return undefined; @@ -731,23 +771,21 @@ function extractAndRemoveSubagentModelTagFromText( } function resolveRouterRule( - rule: RouterRule, + compiledRule: CompiledRouterRule, request: MutableRequestLike, - router: RouterConfig + defaultFallback: RouterFallbackConfig ): ConfiguredRouteDecision | undefined { - if (!rule.enabled) { + if (!compiledRule.active) { return undefined; } - const fallback = rule.fallback ?? router.fallback; + const rule = compiledRule.rule; + const fallback = rule.fallback ?? defaultFallback; - const rewrites = routerRuleRewritesFromRule(rule); - if (rewrites.length === 0) { - return undefined; - } + const rewrites = compiledRule.rewrites; if (rule.type === "condition") { return rule.condition && routerRuleConditionMatches(rule.condition, request) - ? routerRuleRewriteDecision(rule, rewrites, fallback) + ? routerRuleRewriteDecision(rule, rewrites, fallback, compiledRule.model) : undefined; } @@ -755,7 +793,7 @@ function resolveRouterRule( const pattern = readString(rule.pattern); const requestedModel = readString(request.body.model); return pattern && requestedModel?.startsWith(pattern) - ? routerRuleRewriteDecision(rule, rewrites, fallback) + ? routerRuleRewriteDecision(rule, rewrites, fallback, compiledRule.model) : undefined; } @@ -765,30 +803,18 @@ function resolveRouterRule( function routerRuleRewriteDecision( rule: RouterRule, rewrites: RouterRuleRewrite[], - fallback: RouterFallbackConfig + fallback: RouterFallbackConfig, + model: RouteModelRef | undefined ): ConfiguredRouteDecision { - const modelRewrite = rewrites.find((rewrite) => (rewrite.operation ?? "set") === "set" && rewrite.key === "request.body.model"); return { fallback, - model: modelRewrite?.value ? normalizeRouteSelector(modelRewrite.value) : undefined, + model, reason: routerRuleReason(rule), - rewrite: rewrites[0], - rewrites + rewrites, + source: "rule" }; } -function routerRuleRewritesFromRule(rule: RouterRule): RouterRuleRewrite[] { - if (rule.rewrites?.length) { - return rule.rewrites; - } - if (rule.rewrite) { - return [rule.rewrite]; - } - return rule.target - ? [{ key: "request.body.model", operation: "set", value: rule.target }] - : []; -} - function applyRouterRewrite(rewrite: RouterRuleRewrite, request: MutableRequestLike): void { const parts = rewrite.key .split(".") @@ -1165,42 +1191,6 @@ function routerRuleReason(rule: RouterRule): string { return `rule:${rule.id}`; } -export function normalizeRouteSelector(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - - 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}` : undefined; - } - - return trimmed; -} - -function isKnownInlineRoute(model: string | undefined, config: AppConfig): boolean { - const normalizedModel = normalizeRouteSelector(model); - if (!normalizedModel) { - return false; - } - - 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 = normalizedModel.slice(0, separator).trim().toLowerCase(); - return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName); -} - function isSubagentModelPlaceholder(model: string): boolean { return model.trim().toLowerCase() === ccrSubagentModelPlaceholder; } diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index c1da85c8..7ba0a2d2 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { createRequire } from "node:module"; import { networkInterfaces } from "node:os"; @@ -34,7 +34,6 @@ import { BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, - ROUTER_FALLBACK_MAX_RETRY_COUNT, hasAvailableGatewayModels } from "@ccr/core/contracts/app"; import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index"; @@ -51,7 +50,21 @@ import { pluginService } from "@ccr/core/plugins/service"; import { proxyService } from "@ccr/core/proxy/service"; import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store"; import { recordGatewayUsageCapture } from "@ccr/core/usage/store"; -import { ClaudeCodeRouterPlugin, normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin"; +import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; +import { createRouteExecutionPlan } from "@ccr/core/routing/execution-plan"; +import type { RouteModelRef } from "@ccr/core/routing/contracts"; +import { classifyRouteFailure } from "@ccr/core/routing/failure-classifier"; +import { + adaptRouteRequestBody, + restoreRouteRequestBody, + rewriteRouteModelInUrl +} from "@ccr/core/routing/protocol-adapter"; +import { + modelRegistryForConfig, + normalizeRouteSelector, + parseProviderModelSelector, + providerRuntimeId +} from "@ccr/core/routing/model-registry"; import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service"; import { claudeCodeEffectiveMaxInputTokens, @@ -211,6 +224,7 @@ type UpstreamAttempt = { index: number; logicalProvider?: string; model?: string; + target?: RouteModelRef; }; type UpstreamFailedAttempt = { @@ -228,6 +242,14 @@ type UpstreamFetchResult = { response: Response; }; +type ProviderCredentialRoutingTarget = { + body?: Buffer; + model?: string; + provider: GatewayProviderConfig; + protocol: GatewayProviderProtocol; + source: "header" | "model" | "plan"; +}; + class UpstreamRequestError extends Error { readonly attempt?: UpstreamAttempt; readonly failedAttempts: UpstreamFailedAttempt[]; @@ -786,35 +808,21 @@ class GatewayService { return; } - if (method === "POST" && path === "/v1/messages") { - const body = parseJsonObject(bodyToForward ?? requestBody); + if (shouldApplyGatewayRouting(method, path)) { + const adaptation = adaptRouteRequestBody(path, parseJsonObject(bodyToForward ?? requestBody)); const routed = await this.plugin.routeRequest({ - body, + body: adaptation.body, headers: headers as Record, method, url: request.url ?? path }); - const serialized = Buffer.from(`${JSON.stringify(routed.body)}\n`, "utf8"); + const serialized = Buffer.from(`${JSON.stringify(restoreRouteRequestBody(routed.body, adaptation))}\n`, "utf8"); headers["content-type"] = "application/json"; headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason); - routeFallback = routed.decision.fallback ?? routeFallback; - if (routed.decision.model) { - headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model); - routedModel = routed.decision.model; + headers["x-ccr-route-source"] = routed.decision.source; + if (routed.decision.diagnostics.length > 0) { + headers["x-ccr-route-diagnostics"] = String(routed.decision.diagnostics.length); } - bodyToForward = serialized; - } - if (method === "POST" && requestProtocolForPath(path) === "openai_responses" && isCodexUserAgent(request.headers)) { - const body = parseJsonObject(bodyToForward ?? requestBody); - const routed = await this.plugin.routeRequest({ - body, - headers: headers as Record, - method, - url: request.url ?? path - }); - const serialized = Buffer.from(`${JSON.stringify(routed.body)}\n`, "utf8"); - headers["content-type"] = "application/json"; - headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason); routeFallback = routed.decision.fallback ?? routeFallback; if (routed.decision.model) { headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model); @@ -4782,7 +4790,7 @@ 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)) { + for (const candidate of fusionWebSearchToolCandidates(config)) { if (!normalizedModel || candidate.aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { return candidate.toolName; } @@ -4790,7 +4798,7 @@ export function fusionWebSearchToolNameForRequest(config: AppConfig, model: stri return undefined; } -function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string }> { +function fusionWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string }> { const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; const profiles = normalizeCoreGatewayVirtualModelProfiles( withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), @@ -4804,7 +4812,7 @@ function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliase 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") { + if (!webSearchConfig?.toolName) { continue; } const match = isRecord(profile.match) ? profile.match : undefined; @@ -5487,6 +5495,17 @@ function requestProtocolForPath(path: string): GatewayProviderProtocol | undefin return undefined; } +export function shouldApplyGatewayRouting(method: string, path: string): boolean { + if (method.toUpperCase() !== "POST") { + return false; + } + const protocol = requestProtocolForPath(path); + if (protocol === "gemini_interactions") { + return /\/v1(?:beta)?\/interactions$/i.test(path); + } + return Boolean(protocol); +} + function rewriteProviderHeader( headers: Record, headerName: string, @@ -5649,15 +5668,7 @@ function findProviderByPublicOrInternalName(config: AppConfig, name: string): Ga providerRuntimeId(provider).toLowerCase() === internalProviderId ); } - return config.Providers.find((provider) => - provider.name.trim().toLowerCase() === normalized || - provider.id?.trim().toLowerCase() === normalized || - provider.provider?.trim().toLowerCase() === normalized || - providerRuntimeId(provider).toLowerCase() === normalized || - normalizedProviderCapabilities(provider).some((capability) => - providerCapabilityNameMatches(provider, capability.type, normalized) - ) - ); + return modelRegistryForConfig(config).findProvider(normalized); } function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): Headers { @@ -5707,7 +5718,14 @@ async function fetchUpstreamWithFallback(input: { upstreamUrl: string; }): Promise { const fallbackMode = input.fallback.mode; - const attempts = buildUpstreamAttempts(input.fallback, input.method, input.body, input.routedModel); + const attempts = buildUpstreamAttempts( + input.config, + input.fallback, + input.method, + input.path, + input.body, + input.routedModel + ); const failedAttempts: UpstreamFailedAttempt[] = []; for (let index = 0; index < attempts.length; index += 1) { @@ -5727,7 +5745,7 @@ async function fetchUpstreamWithFallback(input: { const hasNextAttempt = index < attempts.length - 1; try { - const response = await fetchWithSystemProxy(input.upstreamUrl, { + const response = await fetchWithSystemProxy(rewriteRouteModelInUrl(input.upstreamUrl, attempt.model), { body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined, headers: withCoreGatewayAuthHeader(omitLocalObservabilityHeaders(attempt.headers ?? input.headers), input.coreAuthToken), method: input.method, @@ -5802,7 +5820,8 @@ function prepareUpstreamCredentialAttempt(input: { path: string; }): UpstreamAttempt { const normalizedBody = normalizeConfiguredProviderModelBody(input.attempt.body, input.config); - const target = resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body); + const target = resolvePlannedProviderCredentialRoutingTarget(input.attempt, input.path) ?? + resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body); const attemptBody = (body: Buffer | undefined) => usageAwareOpenAiChatAttemptBody({ body, config: input.config, @@ -5822,20 +5841,26 @@ function prepareUpstreamCredentialAttempt(input: { const credentials = activeProviderCredentials(target.provider); if (credentials.length === 0) { + const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); return { ...input.attempt, - body: attemptBody(target.body ?? normalizedBody?.body ?? input.attempt.body), - headers: input.headers + body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), + headers: preserveModelSelector + ? clearTargetProviderHeaders(input.headers) + : targetProviderFallbackHeaders(input.headers, target.provider, target.protocol) }; } const usage = estimateLimitUsage(input.method, input.attempt.body ?? Buffer.alloc(0)); const selection = selectProviderCredentials(target.provider, target.protocol, credentials, usage); if (selection.credentials.length === 0) { + const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); return { ...input.attempt, - body: attemptBody(target.body ?? normalizedBody?.body ?? input.attempt.body), - headers: input.headers + body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), + headers: preserveModelSelector + ? clearTargetProviderHeaders(input.headers) + : targetProviderFallbackHeaders(input.headers, target.provider, target.protocol) }; } @@ -5861,29 +5886,96 @@ function prepareUpstreamCredentialAttempt(input: { }; } +function targetProviderFallbackHeaders( + headers: Record, + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol +): Record { + const next = { ...headers }; + next["x-target-provider"] = targetProviderHeaderValue(provider, protocol); + delete next["x-target-providers"]; + delete next["x-gateway-target-provider"]; + return next; +} + +function clearTargetProviderHeaders(headers: Record): Record { + const next = { ...headers }; + delete next["x-target-provider"]; + delete next["x-target-providers"]; + delete next["x-gateway-target-provider"]; + return next; +} + +function shouldPreserveCapabilityModelSelector(body: Buffer | undefined, target: ProviderCredentialRoutingTarget): boolean { + if (target.source === "header" || target.protocol !== "gemini_interactions") { + return false; + } + return Boolean(parseProviderModelSelector(stringValue(parseJsonObjectSafe(body)?.model))); +} + +function resolvePlannedProviderCredentialRoutingTarget( + attempt: UpstreamAttempt, + path: string +): ProviderCredentialRoutingTarget | undefined { + if (attempt.target?.kind !== "provider") { + return undefined; + } + const clientProtocol = requestProtocolForPath(path); + const protocol = clientProtocol + ? providerProtocolForClientProtocol(attempt.target.provider, clientProtocol) + : undefined; + if (!protocol) { + return undefined; + } + const parsedBody = parseJsonObjectSafe(attempt.body); + return { + body: parsedBody && clientProtocol !== "gemini_generate_content" + ? serializeJsonBodyWithModel(parsedBody, attempt.target.model) + : attempt.body, + model: attempt.target.model, + provider: attempt.target.provider, + protocol, + source: "plan" + }; +} + +function targetProviderHeaderValue(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string { + const capability = normalizedProviderCapabilities(provider).find((item) => item.type === protocol); + return capability ? providerCapabilityInternalName(provider, capability.type) : provider.name || providerRuntimeId(provider); +} + function usageAwareOpenAiChatAttemptBody(input: { body: Buffer | undefined; config: AppConfig; path: string; target?: { protocol: GatewayProviderProtocol }; }): Buffer | undefined { - if (input.target?.protocol === "openai_chat_completions") { - return usageAwareOpenAiChatBody(input.body); - } - - const protocol = requestProtocolForPath(input.path); - if (!protocol) { - return input.body; - } - + const clientProtocol = requestProtocolForPath(input.path); const parsedBody = parseJsonObjectSafe(input.body); const modelSelector = resolveConfiguredProviderModelSelector(stringValue(parsedBody?.model), input.config); - const providerProtocol = modelSelector - ? providerProtocolForClientProtocol(modelSelector.provider, protocol) - : undefined; + const providerProtocol = input.target?.protocol ?? ( + modelSelector && clientProtocol + ? providerProtocolForClientProtocol(modelSelector.provider, clientProtocol) + : undefined + ); + if (providerProtocol !== "openai_chat_completions" && providerProtocol !== "openai_responses") { + return input.body; + } + const sanitizedBody = stripUnsupportedOpenAiRequestParameters(input.body); return providerProtocol === "openai_chat_completions" - ? usageAwareOpenAiChatBody(input.body) - : input.body; + ? usageAwareOpenAiChatBody(sanitizedBody) + : sanitizedBody; +} + +function stripUnsupportedOpenAiRequestParameters(body: Buffer | undefined): Buffer | undefined { + const parsedBody = parseJsonObjectSafe(body); + if (!parsedBody || (!("thinking" in parsedBody) && !("reasoning_split" in parsedBody))) { + return body; + } + const next = { ...parsedBody }; + delete next.thinking; + delete next.reasoning_split; + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); } function usageAwareOpenAiChatBody(body: Buffer | undefined): Buffer | undefined { @@ -5935,7 +6027,7 @@ function resolveProviderCredentialRoutingTarget( headers: Record, path: string, body: Buffer | undefined -): { body?: Buffer; model?: string; provider: GatewayProviderConfig; protocol: GatewayProviderProtocol } | undefined { +): ProviderCredentialRoutingTarget | undefined { const protocol = requestProtocolForPath(path); if (!protocol) { return undefined; @@ -5943,16 +6035,18 @@ function resolveProviderCredentialRoutingTarget( const parsedBody = parseJsonObjectSafe(body); const bodyModel = stringValue(parsedBody?.model); - const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config); + const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(bodyModel, config); if (modelSelector) { const provider = modelSelector.provider; const providerProtocol = provider ? providerProtocolForClientProtocol(provider, protocol) : undefined; - if (provider && providerProtocol && activeProviderCredentials(provider).length > 0) { + if (provider && providerProtocol) { return { body: parsedBody ? serializeJsonBodyWithModel(parsedBody, modelSelector.model) : body, model: modelSelector.model, provider, - protocol: providerProtocol + protocol: providerProtocol, + source: "model" }; } } @@ -5963,7 +6057,7 @@ function resolveProviderCredentialRoutingTarget( } const provider = findProviderByPublicOrInternalName(config, targetProviderName); - if (!provider || activeProviderCredentials(provider).length === 0) { + if (!provider) { return undefined; } const providerProtocol = providerProtocolForClientProtocol(provider, protocol); @@ -5978,7 +6072,8 @@ function resolveProviderCredentialRoutingTarget( : body, model: providerModel ?? bodyModel, provider, - protocol: providerProtocol + protocol: providerProtocol, + source: "header" }; } @@ -6002,99 +6097,18 @@ function providerHasModel(provider: GatewayProviderConfig, model: string): boole return Boolean(normalized) && provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized); } -function parseProviderModelSelector(value: string | undefined): { model: string; provider: string } | undefined { - const normalized = normalizeRouteSelector(value); - if (!normalized) { - return undefined; - } - const separator = normalized.indexOf("/"); - if (separator <= 0 || separator >= normalized.length - 1) { - return undefined; - } - const provider = normalized.slice(0, separator).trim(); - const model = normalized.slice(separator + 1).trim(); - return provider && model ? { model, provider } : undefined; -} - function resolveConfiguredProviderModelSelector( value: string | undefined, config: AppConfig ): { model: string; provider: GatewayProviderConfig } | undefined { - let current = normalizeRouteSelector(value); - if (!current) { - return undefined; - } - - let selectedProvider: GatewayProviderConfig | undefined; - for (let depth = 0; depth < 4; depth += 1) { - const parsed = parseProviderModelSelector(current); - if (!parsed) { - break; - } - - const provider = findProviderByPublicOrInternalName(config, parsed.provider); - if (!provider) { - break; - } - - selectedProvider = provider; - current = parsed.model; - - const nested = parseProviderModelSelector(current); - if (!nested) { - return current ? { model: current, provider } : undefined; - } - - const nestedProvider = findProviderByPublicOrInternalName(config, nested.provider); - if (!nestedProvider || providerRuntimeId(nestedProvider) !== providerRuntimeId(provider)) { - return current ? { model: current, provider } : undefined; - } - } - - return selectedProvider && current ? { model: current, provider: selectedProvider } : undefined; + return modelRegistryForConfig(config).resolveProviderModel(value); } function resolveUniqueConfiguredProviderModelSelector( value: string | undefined, config: AppConfig ): { model: string; provider: GatewayProviderConfig } | undefined { - const model = normalizeRouteSelector(value); - if (!model) { - return undefined; - } - - const exactMatches = configuredProviderModelMatches(model, config, false); - if (exactMatches.length === 1) { - return exactMatches[0]; - } - if (exactMatches.length > 1) { - return undefined; - } - - const caseInsensitiveMatches = configuredProviderModelMatches(model, config, true); - return caseInsensitiveMatches.length === 1 ? caseInsensitiveMatches[0] : undefined; -} - -function configuredProviderModelMatches( - model: string, - config: AppConfig, - caseInsensitive: boolean -): Array<{ model: string; provider: GatewayProviderConfig }> { - const normalized = caseInsensitive ? model.toLowerCase() : model; - const matches: Array<{ model: string; provider: GatewayProviderConfig }> = []; - for (const provider of config.Providers) { - for (const candidate of provider.models) { - const configuredModel = candidate.trim(); - if (!configuredModel) { - continue; - } - const comparable = caseInsensitive ? configuredModel.toLowerCase() : configuredModel; - if (comparable === normalized) { - matches.push({ model: configuredModel, provider }); - } - } - } - return matches; + return modelRegistryForConfig(config).resolveUniqueProviderModel(value); } function firstTargetProviderHeader(headers: Record): string | undefined { @@ -6182,52 +6196,35 @@ function providerCredentialPriority(credential: ProviderCredentialConfig, index: return Number.isFinite(credential.priority) ? Number(credential.priority) : index + 1; } -function buildUpstreamAttempts(fallback: RouterFallbackConfig, method: string, body: Buffer | undefined, routedModel: string | undefined): UpstreamAttempt[] { - const initialAttempt: UpstreamAttempt = { - body, - index: 0, - model: normalizeRouteSelector(routedModel) - }; - if (fallback.mode === "off" || !shouldSendBody(method)) { - return [initialAttempt]; - } - - if (fallback.mode === "retry") { - const retryCount = clampNumber(fallback.retryCount, 0, ROUTER_FALLBACK_MAX_RETRY_COUNT); - return Array.from({ length: retryCount + 1 }, (_unused, index) => ({ - body, - index, - model: initialAttempt.model - })); - } - +function buildUpstreamAttempts( + config: AppConfig, + fallback: RouterFallbackConfig, + method: string, + path: string, + body: Buffer | undefined, + routedModel: string | undefined +): UpstreamAttempt[] { const parsedBody = parseJsonObjectSafe(body); - const currentModel = normalizeRouteSelector(stringValue(parsedBody?.model)) ?? initialAttempt.model; - const configuredModels = uniqueStrings( - fallback.models - .map((model) => normalizeRouteSelector(model)) - .filter((model): model is string => Boolean(model)) - ); - const modelChain = uniqueStrings([currentModel, ...configuredModels].filter((model): model is string => Boolean(model))); - if (modelChain.length === 0 || !parsedBody) { - return [initialAttempt]; - } - - return modelChain.map((model, index) => ({ - body: serializeJsonBodyWithModel(parsedBody, model), - index, - model + const modelInPath = requestProtocolForPath(path) === "gemini_generate_content"; + const plan = createRouteExecutionPlan({ + bodyModel: modelInPath ? undefined : stringValue(parsedBody?.model), + fallback, + hasRequestBody: shouldSendBody(method) && (fallback.mode !== "model-chain" || Boolean(parsedBody)), + modelRegistry: modelRegistryForConfig(config), + primaryModel: routedModel + }); + return plan.attempts.map((attempt) => ({ + body: parsedBody && !modelInPath && fallback.mode === "model-chain" && attempt.model + ? serializeJsonBodyWithModel(parsedBody, attempt.model) + : body, + index: attempt.index, + model: attempt.model, + target: attempt.target })); } function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode): boolean { - if (mode === "model-chain" && statusCode >= 400) { - return true; - } - if (statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500) { - return true; - } - return false; + return classifyRouteFailure(statusCode, mode).shouldFallback; } function retryDelayAfterStatus(_statusCode: number, headers: Headers, failedAttemptIndex: number): number { @@ -6731,31 +6728,6 @@ function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized; } -function providerRuntimeId(provider: GatewayProviderConfig): string { - const explicit = sanitizeProviderHeaderId(provider.id); - if (explicit) { - return explicit; - } - const normalized = provider.name - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9_.-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48); - const hash = createHash("sha256").update(`${provider.name}\n${readBaseUrl(provider) ?? ""}`).digest("hex").slice(0, 10); - return `provider-${normalized || "provider"}-${hash}`; -} - -function sanitizeProviderHeaderId(value: string | undefined): string | undefined { - const normalized = value - ?.trim() - .toLowerCase() - .replace(/[^a-z0-9_.-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return normalized || undefined; -} - function sanitizeHeaderValue(value: unknown): string { // HTTP header values must be ByteString (code point <= 255). Values derived // from user-facing names — model selectors like "小米mimo/...", provider diff --git a/packages/core/src/routing/config-compiler.ts b/packages/core/src/routing/config-compiler.ts new file mode 100644 index 00000000..d9bb8114 --- /dev/null +++ b/packages/core/src/routing/config-compiler.ts @@ -0,0 +1,199 @@ +import type { + AppConfig, + RouterFallbackConfig, + RouterRule, + RouterRuleRewrite +} from "@ccr/core/contracts/app"; +import type { RouteDiagnostic, RouteModelRef } from "@ccr/core/routing/contracts"; +import { ModelRegistry } from "@ccr/core/routing/model-registry"; + +export type CompiledRouterRule = { + active: boolean; + diagnostics: RouteDiagnostic[]; + model?: RouteModelRef; + rewrites: RouterRuleRewrite[]; + rule: RouterRule; +}; + +export type CompiledRouterConfig = { + diagnostics: RouteDiagnostic[]; + fallback: RouterFallbackConfig; + modelRegistry: ModelRegistry; + rules: CompiledRouterRule[]; +}; + +export function compileRouterConfig(config: AppConfig): CompiledRouterConfig { + const modelRegistry = new ModelRegistry(config); + const rules = (config.Router.rules ?? []).map((rule) => compileRouterRule(rule, modelRegistry)); + const fallbackDiagnostics = fallbackModelDiagnostics(config.Router.fallback, modelRegistry, "default"); + const profileDiagnostics = configuredProfileDiagnostics(config, modelRegistry); + const validFallbackModels = config.Router.fallback.models.filter((model) => modelRegistry.isConfigured(model)); + return { + diagnostics: [...rules.flatMap((rule) => rule.diagnostics), ...fallbackDiagnostics, ...profileDiagnostics], + fallback: fallbackDiagnostics.length === 0 + ? config.Router.fallback + : { ...config.Router.fallback, models: validFallbackModels }, + modelRegistry, + rules + }; +} + +function configuredProfileDiagnostics(config: AppConfig, modelRegistry: ModelRegistry): RouteDiagnostic[] { + if (config.profile?.enabled === false) { + return []; + } + return (config.profile?.profiles ?? []) + .filter((profile) => profile.enabled && profile.model && !modelRegistry.isConfigured(profile.model)) + .map((profile) => ({ + code: "profile-model-not-configured" as const, + message: `Agent profile "${profile.name}" references unconfigured model "${profile.model}".`, + model: profile.model, + source: "builtin" as const + })); +} + +function compileRouterRule(rule: RouterRule, modelRegistry: ModelRegistry): CompiledRouterRule { + const rewrites = routerRuleRewrites(rule); + if (!rule.enabled) { + return { + active: false, + diagnostics: [], + rewrites, + rule + }; + } + const diagnostics: RouteDiagnostic[] = []; + const providerName = effectiveTargetProviderName(rewrites); + const targetProvider = providerName ? modelRegistry.findProvider(providerName) : undefined; + let model: RouteModelRef | undefined; + const modelRewriteValue = effectiveBodyModelRewriteValue(rewrites); + if (modelRewriteValue !== undefined) { + const resolved = modelRegistry.resolve(modelRewriteValue, { providerName }); + if (!resolved) { + diagnostics.push({ + code: "rule-model-not-configured", + message: `Router rule "${rule.name}" references unconfigured model "${modelRewriteValue}".`, + model: modelRewriteValue, + ruleId: rule.id, + source: "rule" + }); + } else { + model = resolved; + if (targetProvider && resolved.kind === "provider" && resolved.provider !== targetProvider) { + diagnostics.push({ + code: "rule-provider-model-conflict", + message: `Router rule "${rule.name}" targets provider "${providerName}" but model "${modelRewriteValue}" belongs to "${resolved.provider.name}".`, + model: modelRewriteValue, + ruleId: rule.id, + source: "rule" + }); + } + } + } + diagnostics.push(...fallbackModelDiagnostics(rule.fallback, modelRegistry, "rule", rule)); + return { + active: rewrites.length > 0 && diagnostics.length === 0, + diagnostics, + model, + rewrites, + rule + }; +} + +function fallbackModelDiagnostics( + fallback: RouterFallbackConfig | undefined, + modelRegistry: ModelRegistry, + source: "default" | "rule", + rule?: RouterRule +): RouteDiagnostic[] { + if (fallback?.mode !== "model-chain") { + return []; + } + return fallback.models + .filter((model) => !modelRegistry.isConfigured(model)) + .map((model) => ({ + code: "fallback-model-not-configured" as const, + message: rule + ? `Router rule "${rule.name}" references unconfigured fallback model "${model}".` + : `Router fallback references unconfigured model "${model}".`, + model, + ...(rule ? { ruleId: rule.id } : {}), + source + })); +} + +function routerRuleRewrites(rule: RouterRule): RouterRuleRewrite[] { + if (rule.rewrites?.length) { + return rule.rewrites; + } + if (rule.rewrite) { + return [rule.rewrite]; + } + return rule.target + ? [{ key: "request.body.model", operation: "set", value: rule.target }] + : []; +} + +function effectiveBodyModelRewriteValue(rewrites: RouterRuleRewrite[]): string | undefined { + let value: string | undefined; + for (const rewrite of rewrites) { + const path = rewritePath(rewrite.key); + if (path.scope !== "body" || path.name !== "model") { + continue; + } + const operation = rewrite.operation ?? "set"; + if (operation === "delete") { + value = undefined; + } else if (operation === "set") { + value = rewrite.value; + } + } + return value; +} + +function effectiveTargetProviderName(rewrites: RouterRuleRewrite[]): string | undefined { + const headers: Record = {}; + for (const rewrite of rewrites) { + const path = rewritePath(rewrite.key); + if (path.scope !== "header" || !isTargetProviderHeader(path.name)) { + continue; + } + if ((rewrite.operation ?? "set") === "delete") { + delete headers[path.name]; + } else if (rewrite.value !== undefined) { + headers[path.name] = rewrite.value; + } + } + const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; + if (provider?.trim()) { + return provider.trim(); + } + return headers["x-target-providers"] + ?.split(",") + .map((item) => item.trim()) + .find(Boolean); +} + +function isTargetProviderHeader(name: string): boolean { + return name === "x-target-provider" || + name === "x-gateway-target-provider" || + name === "x-target-providers"; +} + +function rewritePath(key: string): { name: string; scope?: "body" | "header" } { + const parts = key + .split(".") + .map((part) => part.trim()) + .filter(Boolean); + const [scope, section, ...rest] = parts; + if (scope !== "request") { + return { name: "" }; + } + if (section === "header" || section === "headers") { + return { name: rest.join(".").trim().toLowerCase(), scope: "header" }; + } + if (section === "body") { + return { name: rest.join("."), scope: "body" }; + } + return { name: "" }; +} diff --git a/packages/core/src/routing/contracts.ts b/packages/core/src/routing/contracts.ts new file mode 100644 index 00000000..72fb711f --- /dev/null +++ b/packages/core/src/routing/contracts.ts @@ -0,0 +1,67 @@ +import type { GatewayProviderConfig, RouterFallbackConfig, RouterRuleRewrite } from "@ccr/core/contracts/app"; + +export type RouteSource = "builtin" | "custom" | "default" | "rule" | "subagent"; + +export type ProviderModelRef = { + canonicalSelector: string; + kind: "provider"; + model: string; + provider: GatewayProviderConfig; + selector: string; +}; + +export type GatewayModelRef = { + canonicalSelector: string; + kind: "gateway"; + model: string; + selector: string; +}; + +export type RouteModelRef = GatewayModelRef | ProviderModelRef; + +export type RouteDiagnosticCode = + | "custom-model-not-configured" + | "fallback-model-not-configured" + | "profile-model-not-configured" + | "rule-model-not-configured" + | "rule-provider-model-conflict"; + +export type RouteDiagnostic = { + code: RouteDiagnosticCode; + message: string; + model?: string; + ruleId?: string; + source: RouteSource; +}; + +export type RouteDecision = { + diagnostics: RouteDiagnostic[]; + fallback: RouterFallbackConfig; + model?: RouteModelRef; + reason: string; + rewrites: RouterRuleRewrite[]; + source: RouteSource; +}; + +export type RouteRequest = { + builtInSubagentModel?: string; + body: Record; + headers: Record; + log: Pick; + method: string; + sessionId?: string; + tokenCount?: number; + url: string; +}; + +export type RouteAttemptPlan = { + index: number; + model?: string; + target?: RouteModelRef; +}; + +export type RouteExecutionPlan = { + attempts: RouteAttemptPlan[]; + fallback: RouterFallbackConfig; + primaryModel?: string; +}; diff --git a/packages/core/src/routing/execution-plan.ts b/packages/core/src/routing/execution-plan.ts new file mode 100644 index 00000000..2cb43b7e --- /dev/null +++ b/packages/core/src/routing/execution-plan.ts @@ -0,0 +1,73 @@ +import { + ROUTER_FALLBACK_MAX_RETRY_COUNT, + type RouterFallbackConfig +} from "@ccr/core/contracts/app"; +import type { RouteExecutionPlan } from "@ccr/core/routing/contracts"; +import { type ModelRegistry, normalizeRouteSelector } from "@ccr/core/routing/model-registry"; + +export function createRouteExecutionPlan(input: { + bodyModel?: string; + fallback: RouterFallbackConfig; + hasRequestBody: boolean; + modelRegistry?: ModelRegistry; + primaryModel?: string; +}): RouteExecutionPlan { + const primaryModel = normalizeRouteSelector(input.bodyModel) ?? normalizeRouteSelector(input.primaryModel); + if (input.fallback.mode === "off" || !input.hasRequestBody) { + return { + attempts: [routeAttempt(0, primaryModel, input.modelRegistry)], + fallback: input.fallback, + primaryModel + }; + } + + if (input.fallback.mode === "retry") { + const retryCount = clamp(input.fallback.retryCount, 0, ROUTER_FALLBACK_MAX_RETRY_COUNT); + return { + attempts: Array.from( + { length: retryCount + 1 }, + (_unused, index) => routeAttempt(index, primaryModel, input.modelRegistry) + ), + fallback: input.fallback, + primaryModel + }; + } + + const models = uniqueStrings([ + primaryModel, + ...input.fallback.models.map((model) => normalizeRouteSelector(model)) + ]); + return { + attempts: (models.length ? models : [undefined]) + .map((model, index) => routeAttempt(index, model, input.modelRegistry)), + fallback: input.fallback, + primaryModel + }; +} + +function routeAttempt(index: number, model: string | undefined, modelRegistry: ModelRegistry | undefined) { + const target = modelRegistry?.resolve(model); + return { + index, + model, + ...(target ? { target } : {}) + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(Number.isFinite(value) ? value : min))); +} + +function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const output: string[] = []; + for (const value of values) { + const normalized = value?.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + output.push(normalized); + } + return output; +} diff --git a/packages/core/src/routing/failure-classifier.ts b/packages/core/src/routing/failure-classifier.ts new file mode 100644 index 00000000..c33c8abe --- /dev/null +++ b/packages/core/src/routing/failure-classifier.ts @@ -0,0 +1,31 @@ +import type { RouterFallbackMode } from "@ccr/core/contracts/app"; + +export type RouteFailureClass = "client" | "rate-limit" | "retryable" | "server"; + +export type RouteFailureDecision = { + failureClass: RouteFailureClass; + shouldFallback: boolean; +}; + +export function classifyRouteFailure(statusCode: number, mode: RouterFallbackMode): RouteFailureDecision { + const failureClass = classifyStatus(statusCode); + return { + failureClass, + shouldFallback: mode === "model-chain" + ? statusCode >= 400 + : failureClass === "retryable" || failureClass === "rate-limit" || failureClass === "server" + }; +} + +function classifyStatus(statusCode: number): RouteFailureClass { + if (statusCode === 429) { + return "rate-limit"; + } + if (statusCode === 408 || statusCode === 409) { + return "retryable"; + } + if (statusCode >= 500) { + return "server"; + } + return "client"; +} diff --git a/packages/core/src/routing/model-registry.ts b/packages/core/src/routing/model-registry.ts new file mode 100644 index 00000000..d61d217d --- /dev/null +++ b/packages/core/src/routing/model-registry.ts @@ -0,0 +1,241 @@ +import { createHash } from "node:crypto"; +import { + availableGatewayModelIds, + type AppConfig, + type GatewayProviderConfig +} from "@ccr/core/contracts/app"; +import type { RouteModelRef } from "@ccr/core/routing/contracts"; + +export type ResolveRouteModelOptions = { + providerName?: string; +}; + +export class ModelRegistry { + private readonly gatewayModels: Map; + + constructor(private readonly config: Pick) { + this.gatewayModels = new Map( + availableGatewayModelIds(config).map((model) => [model.toLowerCase(), model]) + ); + } + + resolve(value: string | undefined, options: ResolveRouteModelOptions = {}): RouteModelRef | undefined { + const normalized = normalizeRouteSelector(value); + if (!normalized) { + return undefined; + } + + const parsed = parseProviderModelSelector(normalized); + if (parsed) { + const provider = this.findProvider(parsed.provider); + const model = provider ? configuredProviderModel(provider, parsed.model) : undefined; + if (provider && model) { + return providerModelRef(provider, model, normalized); + } + } + + const gatewayModel = this.gatewayModels.get(normalized.toLowerCase()); + if (gatewayModel) { + return { + canonicalSelector: gatewayModel, + kind: "gateway", + model: gatewayModel, + selector: gatewayModel + }; + } + + if (options.providerName) { + const provider = this.findProvider(options.providerName); + const model = provider ? configuredProviderModel(provider, normalized) : undefined; + if (provider && model) { + return providerModelRef(provider, model, normalized); + } + } + + const exactMatches = this.providerModelMatches(normalized, false); + if (exactMatches.length === 1) { + return providerModelRef(exactMatches[0].provider, exactMatches[0].model, normalized); + } + if (exactMatches.length > 1) { + return undefined; + } + + const caseInsensitiveMatches = this.providerModelMatches(normalized, true); + return caseInsensitiveMatches.length === 1 + ? providerModelRef(caseInsensitiveMatches[0].provider, caseInsensitiveMatches[0].model, normalized) + : undefined; + } + + isConfigured(value: string | undefined, options: ResolveRouteModelOptions = {}): boolean { + return Boolean(this.resolve(value, options)); + } + + findProvider(value: string | undefined): GatewayProviderConfig | undefined { + const normalized = providerSelectorBase(value).toLowerCase(); + if (!normalized) { + return undefined; + } + return this.config.Providers.find((provider) => providerAliases(provider).has(normalized)); + } + + resolveProviderModel(value: string | undefined): { model: string; provider: GatewayProviderConfig } | undefined { + const resolved = this.resolve(value); + return resolved?.kind === "provider" + ? { model: resolved.model, provider: resolved.provider } + : undefined; + } + + resolveUniqueProviderModel(value: string | undefined): { model: string; provider: GatewayProviderConfig } | undefined { + const normalized = normalizeRouteSelector(value); + if (!normalized || parseProviderModelSelector(normalized)) { + return undefined; + } + const resolved = this.resolve(normalized); + return resolved?.kind === "provider" + ? { model: resolved.model, provider: resolved.provider } + : undefined; + } + + private providerModelMatches(model: string, caseInsensitive: boolean) { + const normalized = caseInsensitive ? model.toLowerCase() : model; + const matches: Array<{ model: string; provider: GatewayProviderConfig }> = []; + for (const provider of this.config.Providers) { + for (const candidate of provider.models) { + const configured = candidate.trim(); + const comparable = caseInsensitive ? configured.toLowerCase() : configured; + if (configured && comparable === normalized) { + matches.push({ model: configured, provider }); + } + } + } + return matches; + } +} + +const registryCache = new WeakMap(); + +export function modelRegistryForConfig( + config: Pick +): ModelRegistry { + const key = config as object; + const cached = registryCache.get(key); + if (cached) { + return cached; + } + const registry = new ModelRegistry(config); + registryCache.set(key, registry); + return registry; +} + +export function normalizeRouteSelector(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + 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}` : undefined; + } + return trimmed; +} + +export function parseProviderModelSelector(value: string | undefined): { model: string; provider: string } | undefined { + const normalized = normalizeRouteSelector(value); + if (!normalized) { + return undefined; + } + const separator = normalized.indexOf("/"); + if (separator <= 0 || separator >= normalized.length - 1) { + return undefined; + } + const provider = normalized.slice(0, separator).trim(); + const model = normalized.slice(separator + 1).trim(); + return provider && model ? { model, provider } : undefined; +} + +export function providerRuntimeId(provider: GatewayProviderConfig): string { + const explicit = sanitizeProviderHeaderId(provider.id); + if (explicit) { + return explicit; + } + const normalized = provider.name + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + const hash = createHash("sha256") + .update(`${provider.name}\n${providerBaseUrl(provider) ?? ""}`) + .digest("hex") + .slice(0, 10); + return `provider-${normalized || "provider"}-${hash}`; +} + +function providerModelRef(provider: GatewayProviderConfig, model: string, selector: string): RouteModelRef { + return { + canonicalSelector: `${provider.name}/${model}`, + kind: "provider", + model, + provider, + selector + }; +} + +function configuredProviderModel(provider: GatewayProviderConfig, model: string): string | undefined { + const normalized = model.trim().toLowerCase(); + return provider.models.find((candidate) => candidate.trim().toLowerCase() === normalized)?.trim(); +} + +function providerAliases(provider: GatewayProviderConfig): Set { + return new Set( + [provider.name, provider.id, provider.provider, providerRuntimeId(provider)] + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)) + ); +} + +function providerSelectorBase(value: string | undefined): string { + const normalized = value?.trim() ?? ""; + const separator = normalized.indexOf("::"); + if (separator < 0) { + return normalized; + } + const provider = normalized.slice(0, separator).trim(); + const suffix = normalized.slice(separator + 2).trim(); + return provider && isKnownProviderInternalSuffix(suffix) ? provider : normalized; +} + +function providerBaseUrl(provider: GatewayProviderConfig): string | undefined { + return provider.baseurl || provider.baseUrl || provider.api_base_url; +} + +function isKnownProviderInternalSuffix(value: string): boolean { + const credentialMarker = "::cred:"; + const credentialIndex = value.indexOf(credentialMarker); + const hasCredential = credentialIndex >= 0; + if (hasCredential && !value.slice(credentialIndex + credentialMarker.length).trim()) { + return false; + } + const protocol = hasCredential ? value.slice(0, credentialIndex) : value; + return providerInternalProtocols.has(protocol); +} + +const providerInternalProtocols = new Set([ + "anthropic_messages", + "gemini_generate_content", + "gemini_interactions", + "openai_chat_completions", + "openai_responses" +]); + +function sanitizeProviderHeaderId(value: string | undefined): string | undefined { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || undefined; +} diff --git a/packages/core/src/routing/policy-engine.ts b/packages/core/src/routing/policy-engine.ts new file mode 100644 index 00000000..8964e70d --- /dev/null +++ b/packages/core/src/routing/policy-engine.ts @@ -0,0 +1,23 @@ +export type RoutePolicy = { + evaluate: (context: TContext) => TDecision | undefined; + id: string; +}; + +export type RoutePolicyMatch = { + decision: TDecision; + policyId: string; +}; + +export class RoutePolicyEngine { + constructor(private readonly policies: RoutePolicy[]) {} + + evaluate(context: TContext): RoutePolicyMatch | undefined { + for (const policy of this.policies) { + const decision = policy.evaluate(context); + if (decision) { + return { decision, policyId: policy.id }; + } + } + return undefined; + } +} diff --git a/packages/core/src/routing/protocol-adapter.ts b/packages/core/src/routing/protocol-adapter.ts new file mode 100644 index 00000000..6f88389b --- /dev/null +++ b/packages/core/src/routing/protocol-adapter.ts @@ -0,0 +1,53 @@ +const geminiGenerateContentPathPattern = /(\/v1(?:beta)?\/models\/)([^/:]+)(:(?:generatecontent|streamgeneratecontent))/i; + +export type RouteProtocolAdaptation = { + body: Record; + modelLocation: "body" | "path"; +}; + +export function adaptRouteRequestBody( + path: string, + body: Record +): RouteProtocolAdaptation { + const pathModel = routeModelFromPath(path); + return pathModel + ? { body: { ...body, model: pathModel }, modelLocation: "path" } + : { body, modelLocation: "body" }; +} + +export function restoreRouteRequestBody( + body: Record, + adaptation: Pick +): Record { + if (adaptation.modelLocation !== "path") { + return body; + } + const next = { ...body }; + delete next.model; + return next; +} + +export function rewriteRouteModelInUrl(url: string, model: string | undefined): string { + if (!model || !geminiGenerateContentPathPattern.test(url)) { + geminiGenerateContentPathPattern.lastIndex = 0; + return url; + } + geminiGenerateContentPathPattern.lastIndex = 0; + return url.replace( + geminiGenerateContentPathPattern, + (_match, prefix: string, _current: string, suffix: string) => `${prefix}${encodeURIComponent(model)}${suffix}` + ); +} + +export function routeModelFromPath(path: string): string | undefined { + const match = geminiGenerateContentPathPattern.exec(path); + geminiGenerateContentPathPattern.lastIndex = 0; + if (!match?.[2]) { + return undefined; + } + try { + return decodeURIComponent(match[2]); + } catch { + return match[2]; + } +} diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index df4f1465..fa60136e 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -509,7 +509,7 @@ test("gateway does not route hosted web search through an unrelated Fusion searc assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search"); }); -test("gateway resolves only browser-backed Fusion web search tools for hosted protocol bridging", () => { +test("gateway resolves non-browser Fusion web search tools for hosted protocol bridging", () => { const config = { Providers: [], Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, @@ -538,7 +538,7 @@ test("gateway resolves only browser-backed Fusion web search tools for hosted pr ] }; - assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), undefined); + assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), "research_web_search"); assert.equal(fusionWebSearchToolNameForRequest(config, "gpt-5"), undefined); }); diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index 5e4bfdcb..fcaad7c6 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -15,7 +15,7 @@ function createRouterPlugin(options = {}) { { modelDescriptions: options.modelDescriptions, modelDisplayNames: options.modelDisplayNames, - models: ["claude-sonnet", "gpt-5-codex"], + models: ["claude-sonnet", "claude-opus", "claude-haiku", "gpt-5-codex"], name: "Provider", type: "anthropic_messages" } @@ -432,6 +432,170 @@ test("router rules override the built-in Claude Code profile route", async () => assert.equal(result.decision.reason, "rule:default"); }); +test("issue 1520 configured bare profile models do not bypass rule fallback", async () => { + const plugin = createRouterPlugin({ + profileModel: "deepseek-v4-flash", + providers: [ + { + models: ["deepseek-v4-flash"], + name: "OpenCode", + type: "openai_chat_completions" + }, + { + models: ["Qwen3-235B-A22B"], + name: "CoClaw", + type: "openai_chat_completions" + } + ], + routerRules: [ + { + condition: { + left: "request.url", + operator: "contains", + right: "/v1" + }, + enabled: true, + fallback: { + mode: "model-chain", + models: ["CoClaw/Qwen3-235B-A22B"], + retryCount: 0 + }, + id: "issue-1520", + name: "Issue 1520 default route", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "OpenCode" }, + { key: "request.body.model", operation: "set", value: "deepseek-v4-flash" } + ], + type: "condition" + } + ] + }); + const headers = { + "user-agent": "claude-code/1.0" + }; + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "deepseek-v4-flash" + }, + headers, + method: "POST", + url: "/v1/messages" + }); + + assert.equal(result.decision.reason, "rule:issue-1520"); + assert.equal(result.decision.source, "rule"); + assert.deepEqual(result.decision.fallback, { + mode: "model-chain", + models: ["CoClaw/Qwen3-235B-A22B"], + retryCount: 0 + }); + assert.equal(headers["x-target-provider"], "OpenCode"); +}); + +test("router rules with unconfigured model rewrites are ignored", async () => { + const plugin = createRouterPlugin({ + profileModel: "Provider/claude-sonnet", + routerRules: [ + { + condition: { + left: "request.url", + operator: "contains", + right: "/v1" + }, + enabled: true, + id: "unknown-target", + name: "Unknown target", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "Provider" }, + { key: "request.body.model", operation: "set", value: "not-configured" } + ], + type: "condition" + }, + { + condition: { + left: "request.url", + operator: "contains", + right: "/v1" + }, + enabled: true, + id: "known-target", + name: "Known target", + rewrites: [ + { key: "request.body.model", operation: "set", value: "Provider/gpt-5-codex" } + ], + type: "condition" + } + ] + }); + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "claude-default" + }, + headers: { + "user-agent": "claude-code/1.0" + }, + method: "POST", + url: "/v1/messages" + }); + + assert.equal(result.body.model, "Provider/gpt-5-codex"); + assert.equal(result.decision.model, "Provider/gpt-5-codex"); + assert.equal(result.decision.reason, "rule:known-target"); +}); + +test("router rules with unconfigured fallback models are ignored", async () => { + const plugin = createRouterPlugin({ + profileModel: "Provider/claude-sonnet", + routerRules: [ + { + condition: { + left: "request.url", + operator: "contains", + right: "/v1" + }, + enabled: true, + fallback: { mode: "model-chain", models: ["Provider/not-configured"], retryCount: 0 }, + id: "unknown-fallback", + name: "Unknown fallback", + rewrites: [ + { key: "request.body.model", operation: "set", value: "Provider/gpt-5-codex" } + ], + type: "condition" + }, + { + condition: { + left: "request.url", + operator: "contains", + right: "/v1" + }, + enabled: true, + id: "known-fallback", + name: "Known fallback", + rewrites: [ + { key: "request.body.model", operation: "set", value: "Provider/gpt-5-codex" } + ], + type: "condition" + } + ] + }); + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "claude-default" + }, + headers: { + "user-agent": "claude-code/1.0" + }, + method: "POST", + url: "/v1/messages" + }); + + assert.equal(result.body.model, "Provider/gpt-5-codex"); + assert.equal(result.decision.reason, "rule:known-fallback"); +}); + test("router rules normalize legacy comma provider selectors before gateway provider routing", async () => { const config = { CUSTOM_ROUTER_PATH: "", @@ -547,6 +711,70 @@ test("router rules can add headers after the built-in Claude Code profile route" assert.equal(result.decision.reason, "rule:target-provider"); }); +test("router rules override explicit provider model requests", async () => { + const ruleFallback = { mode: "model-chain", models: ["CoClaw/Qwen3-235B-A22B"], retryCount: 0 }; + const config = { + CUSTOM_ROUTER_PATH: "", + Providers: [ + { + api_base_url: "https://opencode.example/v1/chat/completions", + models: ["deepseek-v4-flash"], + name: "OpenCode" + }, + { + api_base_url: "https://coclaw.example/v1/chat/completions", + models: ["Qwen3-235B-A22B"], + name: "CoClaw" + } + ], + Router: { + fallback: { mode: "retry", models: [], retryCount: 1 }, + rules: [ + { + condition: { + left: "request.header.anthropic-background", + operator: "==", + right: "true" + }, + enabled: true, + fallback: ruleFallback, + id: "background", + name: "Background tasks", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "CoClaw" }, + { key: "request.body.model", operation: "set", value: "Qwen3-235B-A22B" } + ], + type: "condition" + } + ] + }, + profile: { + enabled: false, + profiles: [] + }, + virtualModelProfiles: [] + }; + const plugin = new ClaudeCodeRouterPlugin(config); + const headers = { + "anthropic-background": "true" + }; + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "OpenCode/deepseek-v4-flash" + }, + headers, + method: "POST", + url: "/v1/messages" + }); + + assert.equal(headers["x-target-provider"], "CoClaw"); + assert.equal(result.body.model, "Qwen3-235B-A22B"); + assert.equal(result.decision.model, "Qwen3-235B-A22B"); + assert.equal(result.decision.reason, "rule:background"); + assert.deepEqual(result.decision.fallback, ruleFallback); +}); + test("issue 1480 raw user config no longer reproduces the Claude Code profile routing failure", async () => { const config = createIssue1480UserConfig(); const plugin = new ClaudeCodeRouterPlugin(config); @@ -724,10 +952,132 @@ test("explicit OpenAI chat provider selectors request upstream usage chunks with assert.equal(upstreamAttempt.credentialProtocol, undefined); assert.equal(upstreamAttempt.logicalProvider, undefined); + assert.equal(upstreamAttempt.body.model, "kimi-for-coding"); assert.equal(upstreamAttempt.body.stream_options.include_usage, true); }); -test("built-in Claude Code route preserves explicit virtual gateway models", async () => { +test("explicit provider selectors without capability routing strip provider prefix upstream", () => { + const config = { + CUSTOM_ROUTER_PATH: "", + Providers: [ + { + api_base_url: "https://nebulacoder.example/v1/chat/completions", + models: ["nebulacoder-v8.0", "nebulacoder-cot-v8.0"], + name: "NebulaCoder" + } + ], + Router: { + fallback: { mode: "off", models: [], retryCount: 0 }, + rules: [] + }, + virtualModelProfiles: [] + }; + + const upstreamAttempt = prepareGatewayUpstreamAttemptForTest({ + body: { + messages: [], + model: "NebulaCoder/nebulacoder-cot-v8.0" + }, + config, + headers: {}, + method: "POST", + path: "/v1/chat/completions", + routedModel: "NebulaCoder/nebulacoder-cot-v8.0" + }); + + assert.equal(upstreamAttempt.body.model, "nebulacoder-cot-v8.0"); +}); + +test("model-chain fallback model selectors must not keep stale target provider headers", () => { + const config = { + CUSTOM_ROUTER_PATH: "", + Providers: [ + { + api_base_url: "https://opencode.example/v1/chat/completions", + models: ["deepseek-v4-flash"], + name: "OpenCode" + }, + { + api_base_url: "https://coclaw.example/v1/chat/completions", + models: ["Qwen3-235B-A22B"], + name: "CoClaw" + } + ], + Router: { + fallback: { mode: "off", models: [], retryCount: 0 }, + rules: [] + }, + virtualModelProfiles: [] + }; + const upstreamAttempt = prepareGatewayUpstreamAttemptForTest({ + body: { + messages: [], + model: "Qwen3-235B-A22B" + }, + config, + headers: { + "x-target-provider": "OpenCode" + }, + method: "POST", + path: "/v1/chat/completions", + routedModel: "Qwen3-235B-A22B" + }); + + assert.notEqual(upstreamAttempt.headers["x-target-provider"], "OpenCode"); + assert.equal(upstreamAttempt.body.model, "Qwen3-235B-A22B"); +}); + +test("gateway strips unsupported OpenAI upstream request parameters", () => { + const config = { + CUSTOM_ROUTER_PATH: "", + Providers: [ + { + api_base_url: "https://openai-compatible.example/v1", + capabilities: [ + { baseUrl: "https://openai-compatible.example/v1", type: "openai_chat_completions" }, + { baseUrl: "https://openai-compatible.example/v1", type: "openai_responses" } + ], + credentials: [{ apiKey: "provider-key", id: "provider-main" }], + models: ["gpt-compatible"], + name: "OpenAI Compatible" + } + ], + Router: { + fallback: { mode: "off", models: [], retryCount: 0 }, + rules: [] + }, + virtualModelProfiles: [] + }; + const cases = [ + { + body: { messages: [], model: "gpt-compatible", reasoning: { effort: "medium" }, reasoning_split: true, thinking: { type: "enabled" } }, + path: "/v1/chat/completions" + }, + { + body: { input: "hello", model: "gpt-compatible", reasoning: { effort: "medium" }, reasoning_split: true, thinking: { type: "enabled" } }, + path: "/v1/responses" + } + ]; + + for (const item of cases) { + const upstreamAttempt = prepareGatewayUpstreamAttemptForTest({ + body: item.body, + config, + headers: { + "x-target-provider": "OpenAI Compatible" + }, + method: "POST", + path: item.path, + routedModel: "gpt-compatible" + }); + + assert.equal(upstreamAttempt.body.thinking, undefined); + assert.equal(upstreamAttempt.body.reasoning_split, undefined); + assert.deepEqual(upstreamAttempt.body.reasoning, { effort: "medium" }); + } +}); + +test("built-in Claude Code route overrides explicit virtual gateway models", async () => { const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet", virtualModelProfiles: [ @@ -753,9 +1103,9 @@ test("built-in Claude Code route preserves explicit virtual gateway models", asy url: "/v1/messages" }); - assert.equal(result.body.model, "Fusion/kimisearch"); - assert.equal(result.decision.model, "Fusion/kimisearch"); - assert.equal(result.decision.reason, "inline-model"); + assert.equal(result.body.model, "Provider/claude-sonnet"); + assert.equal(result.decision.model, "Provider/claude-sonnet"); + assert.equal(result.decision.reason, "builtin:claude-code"); }); test("built-in Codex route stays inactive when profile model is unset", async () => { diff --git a/tests/main/routing-architecture.test.mjs b/tests/main/routing-architecture.test.mjs new file mode 100644 index 00000000..c2b5a161 --- /dev/null +++ b/tests/main/routing-architecture.test.mjs @@ -0,0 +1,288 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyAgentRequestEnrichers } from "../../packages/core/src/agents/request-enricher.ts"; +import { shouldApplyGatewayRouting } from "../../packages/core/src/gateway/service.ts"; +import { compileRouterConfig } from "../../packages/core/src/routing/config-compiler.ts"; +import { createRouteExecutionPlan } from "../../packages/core/src/routing/execution-plan.ts"; +import { classifyRouteFailure } from "../../packages/core/src/routing/failure-classifier.ts"; +import { ModelRegistry } from "../../packages/core/src/routing/model-registry.ts"; +import { RoutePolicyEngine } from "../../packages/core/src/routing/policy-engine.ts"; +import { + adaptRouteRequestBody, + restoreRouteRequestBody, + rewriteRouteModelInUrl +} from "../../packages/core/src/routing/protocol-adapter.ts"; + +function routingConfig(overrides = {}) { + return { + CUSTOM_ROUTER_PATH: "", + Providers: [ + { models: ["shared", "alpha"], name: "Primary" }, + { models: ["shared", "beta"], name: "Secondary" } + ], + Router: { + builtInRules: { + "claude-code": { enabled: true }, + codex: { enabled: true } + }, + fallback: { mode: "off", models: [], retryCount: 0 }, + rules: [] + }, + profile: { enabled: true, profiles: [] }, + virtualModelProfiles: [], + ...overrides + }; +} + +test("model registry canonicalizes provider models and rejects ambiguous bare models", () => { + const registry = new ModelRegistry(routingConfig()); + + assert.equal(registry.resolve("Primary/alpha")?.canonicalSelector, "Primary/alpha"); + assert.equal(registry.resolve("alpha")?.canonicalSelector, "Primary/alpha"); + assert.equal(registry.resolve("shared"), undefined); + assert.equal(registry.resolve("Primary,alpha")?.selector, "Primary/alpha"); + assert.equal(registry.resolve("Primary/not-configured"), undefined); +}); + +test("model registry accepts known internal provider suffixes only", () => { + const registry = new ModelRegistry(routingConfig()); + + assert.equal(registry.findProvider("Primary::openai_chat_completions")?.name, "Primary"); + assert.equal(registry.findProvider("Primary::openai_chat_completions::cred:main")?.name, "Primary"); + assert.equal(registry.findProvider("Primary::openai_chat_completions::cred:"), undefined); + assert.equal(registry.findProvider("Primary::bogus"), undefined); +}); + +test("router config compilation disables invalid rules and reports their model", () => { + const config = routingConfig(); + config.Router.rules = [ + { + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: true, + id: "invalid", + name: "Invalid model", + rewrites: [{ key: "request.body.model", operation: "set", value: "Primary/missing" }], + type: "condition" + }, + { + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: true, + id: "valid", + name: "Valid model", + rewrites: [{ key: "request.body.model", operation: "set", value: "Primary/alpha" }], + type: "condition" + } + ]; + + const compiled = compileRouterConfig(config); + + assert.equal(compiled.rules[0].active, false); + assert.equal(compiled.rules[1].active, true); + assert.equal(compiled.rules[0].diagnostics[0].code, "rule-model-not-configured"); + assert.equal(compiled.rules[0].diagnostics[0].model, "Primary/missing"); +}); + +test("router config compilation uses the final model rewrite", () => { + const config = routingConfig(); + config.Router.rules = [{ + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: true, + id: "final-model", + name: "Final model wins", + rewrites: [ + { key: "request.body.model", operation: "set", value: "Primary/missing" }, + { key: "request.body.model", operation: "set", value: "Primary/alpha" } + ], + type: "condition" + }]; + + const compiled = compileRouterConfig(config); + + assert.equal(compiled.rules[0].active, true); + assert.deepEqual(compiled.rules[0].diagnostics, []); + assert.equal(compiled.rules[0].model?.canonicalSelector, "Primary/alpha"); +}); + +test("router config compilation filters invalid global fallback models", () => { + const config = routingConfig(); + config.Router.fallback = { + mode: "model-chain", + models: ["Primary/alpha", "Secondary/missing", "Secondary/beta"], + retryCount: 0 + }; + + const compiled = compileRouterConfig(config); + + assert.deepEqual(compiled.fallback.models, ["Primary/alpha", "Secondary/beta"]); + assert.equal(compiled.diagnostics[0].code, "fallback-model-not-configured"); +}); + +test("router config compilation rejects conflicting provider and model targets", () => { + const config = routingConfig(); + config.Router.rules = [{ + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: true, + id: "conflict", + name: "Conflicting target", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "Secondary" }, + { key: "request.body.model", operation: "set", value: "Primary/alpha" } + ], + type: "condition" + }]; + + const compiled = compileRouterConfig(config); + + assert.equal(compiled.rules[0].active, false); + assert.equal(compiled.rules[0].diagnostics[0].code, "rule-provider-model-conflict"); +}); + +test("router config compilation validates provider conflicts against final header rewrites", () => { + const config = routingConfig(); + config.Router.rules = [ + { + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: true, + id: "case-conflict", + name: "Case-insensitive conflict", + rewrites: [ + { key: "request.header.X-Target-Provider", operation: "set", value: "Secondary" }, + { key: "request.body.model", operation: "set", value: "Primary/alpha" } + ], + type: "condition" + }, + { + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: true, + id: "final-provider", + name: "Final provider wins", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "Secondary" }, + { key: "request.header.x-target-provider", operation: "set", value: "Primary" }, + { key: "request.body.model", operation: "set", value: "alpha" } + ], + type: "condition" + } + ]; + + const compiled = compileRouterConfig(config); + + assert.equal(compiled.rules[0].active, false); + assert.equal(compiled.rules[0].diagnostics[0].code, "rule-provider-model-conflict"); + assert.equal(compiled.rules[1].active, true); + assert.deepEqual(compiled.rules[1].diagnostics, []); + assert.equal(compiled.rules[1].model?.canonicalSelector, "Primary/alpha"); +}); + +test("router config compilation ignores diagnostics from disabled rules", () => { + const config = routingConfig(); + config.Router.rules = [{ + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: false, + fallback: { + mode: "model-chain", + models: ["Secondary/missing"], + retryCount: 0 + }, + id: "disabled-invalid", + name: "Disabled invalid rule", + rewrites: [{ key: "request.body.model", operation: "set", value: "Primary/missing" }], + type: "condition" + }]; + + const compiled = compileRouterConfig(config); + + assert.equal(compiled.rules[0].active, false); + assert.deepEqual(compiled.rules[0].diagnostics, []); + assert.deepEqual(compiled.diagnostics, []); +}); + +test("route policy engine returns the first matching policy", () => { + const engine = new RoutePolicyEngine([ + { evaluate: () => undefined, id: "custom" }, + { evaluate: () => ({ model: "Primary/alpha" }), id: "rule:first" }, + { evaluate: () => ({ model: "Secondary/beta" }), id: "default" } + ]); + + const match = engine.evaluate({}); + + assert.equal(match.policyId, "rule:first"); + assert.equal(match.decision.model, "Primary/alpha"); +}); + +test("execution planner includes primary and de-duplicated fallback attempts", () => { + const plan = createRouteExecutionPlan({ + bodyModel: "Primary/alpha", + fallback: { + mode: "model-chain", + models: ["Primary/alpha", "Secondary/beta", "Secondary/beta"], + retryCount: 0 + }, + hasRequestBody: true + }); + + assert.deepEqual(plan.attempts, [ + { index: 0, model: "Primary/alpha" }, + { index: 1, model: "Secondary/beta" } + ]); +}); + +test("failure classifier keeps retry and model-chain policies explicit", () => { + assert.deepEqual(classifyRouteFailure(400, "retry"), { + failureClass: "client", + shouldFallback: false + }); + assert.equal(classifyRouteFailure(400, "model-chain").shouldFallback, true); + assert.equal(classifyRouteFailure(429, "retry").shouldFallback, true); + assert.equal(classifyRouteFailure(503, "retry").failureClass, "server"); +}); + +test("gateway routing runs for body-model protocols independent of agent user-agent", () => { + assert.equal(shouldApplyGatewayRouting("POST", "/v1/messages"), true); + assert.equal(shouldApplyGatewayRouting("POST", "/v1/chat/completions"), true); + assert.equal(shouldApplyGatewayRouting("POST", "/v1/responses"), true); + assert.equal(shouldApplyGatewayRouting("POST", "/v1beta/interactions"), true); + assert.equal(shouldApplyGatewayRouting("POST", "/v1beta/interactions/interaction-123"), false); + assert.equal(shouldApplyGatewayRouting("POST", "/v1beta/interactions/interaction-123/cancel"), false); + assert.equal(shouldApplyGatewayRouting("POST", "/v1beta/models/gemini:generateContent"), true); + assert.equal(shouldApplyGatewayRouting("GET", "/v1/messages"), false); +}); + +test("Gemini path-model adapter routes and restores generateContent requests", () => { + const adaptation = adaptRouteRequestBody( + "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", + { contents: [] } + ); + + assert.equal(adaptation.modelLocation, "path"); + assert.equal(adaptation.body.model, "gemini-2.5-pro"); + assert.deepEqual(restoreRouteRequestBody({ ...adaptation.body, model: "Provider/gemini-next" }, adaptation), { + contents: [] + }); + assert.equal( + rewriteRouteModelInUrl( + "http://127.0.0.1:3457/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", + "Provider/gemini-next" + ), + "http://127.0.0.1:3457/v1beta/models/Provider%2Fgemini-next:streamGenerateContent?alt=sse" + ); +}); + +test("agent enrichers run only for matching agent contexts", () => { + const request = { agent: "claude-code", enriched: [] }; + const applied = applyAgentRequestEnrichers(request, [ + { + enrich: (value) => value.enriched.push("claude"), + id: "claude-code", + matches: (value) => value.agent === "claude-code" + }, + { + enrich: (value) => value.enriched.push("codex"), + id: "codex", + matches: (value) => value.agent === "codex" + } + ]); + + assert.deepEqual(applied, ["claude-code"]); + assert.deepEqual(request.enriched, ["claude"]); +}); From 1d8ff6742c953772a23e4131f760f4662d7890a7 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 16:41:45 +0800 Subject: [PATCH 15/38] Refine router configuration and request handling --- .../src/agents/local-providers/opencode.ts | 538 ++++++++++++++++++ .../src/agents/local-providers/service.ts | 5 + .../core/src/agents/local-providers/shared.ts | 107 ++++ packages/core/src/config/config.ts | 5 +- packages/core/src/contracts/app.ts | 5 +- packages/core/src/contracts/i18n.ts | 2 + packages/core/src/providers/probe.ts | 73 ++- packages/core/src/providers/url.ts | 16 +- .../ui/src/assets/agent-logos/opencode.ico | Bin 0 -> 15086 bytes packages/ui/src/pages/home/App.tsx | 1 + .../src/pages/home/components/providers.tsx | 17 +- packages/ui/src/pages/home/shared/i18n.tsx | 14 +- .../ui/src/pages/home/shared/providers.ts | 2 + .../local-agent-provider-opencode.test.mjs | 328 +++++++++++ tests/main/provider-probe.test.mjs | 60 +- tests/main/provider-url.test.mjs | 12 + 16 files changed, 1169 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/agents/local-providers/opencode.ts create mode 100644 packages/ui/src/assets/agent-logos/opencode.ico create mode 100644 tests/main/local-agent-provider-opencode.test.mjs diff --git a/packages/core/src/agents/local-providers/opencode.ts b/packages/core/src/agents/local-providers/opencode.ts new file mode 100644 index 00000000..c596d91b --- /dev/null +++ b/packages/core/src/agents/local-providers/opencode.ts @@ -0,0 +1,538 @@ +import { existsSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + GatewayProviderConfig, + GatewayProviderProtocol, + LocalAgentProviderCandidate, + LocalAgentProviderImportResult, + ProviderAccountConnectorConfig +} from "@ccr/core/contracts/app"; +import { + apiKeyAuthPlugin, + bearerAuthPlugin, + isRecord, + missingCandidate, + parseJsoncRecord, + providerInternalNamePlaceholder, + providerNamePlaceholder, + providerNameSlugPlaceholder, + providerPayload, + readJsonRecord, + readJsoncRecord, + readString, + uniqueProviderName, + uniqueStrings +} from "@ccr/core/agents/local-providers/shared"; + +type OpenCodeCredential = { + apiKey?: string; + hasCredential: boolean; + sourceFile: string; +}; + +type OpenCodeConfig = { + record: Record; + sourceFile?: string; +}; + +type OpenCodeCatalog = { + baseUrl: string; + modelDisplayNames: Partial>>; + models: Record; + name: string; +}; + +type OpenCodeProtocol = Exclude; + +const openCodeProviderId = "opencode"; +const openCodeDefaultBaseUrl = "https://opencode.ai/zen/v1"; +const openCodeProtocolOrder: OpenCodeProtocol[] = [ + "openai_responses", + "anthropic_messages", + "openai_chat_completions", + "gemini_generate_content" +]; +const openCodeProtocolLabels: Record = { + anthropic_messages: "Anthropic", + gemini_generate_content: "Gemini", + openai_chat_completions: "Chat Completions", + openai_responses: "Responses" +}; +const openCodeFallbackModels: Record = { + anthropic_messages: ["claude-sonnet-4-5"], + gemini_generate_content: ["gemini-3-flash"], + openai_chat_completions: ["big-pickle"], + openai_responses: ["gpt-5.2"] +}; + +export function opencodeCandidates(): LocalAgentProviderCandidate[] { + const credential = readOpenCodeCredential(); + const invalidCredential = Boolean(credential?.hasCredential && !credential.apiKey); + const publicOnly = !credential; + const catalog = readOpenCodeCatalog({ publicOnly }); + const sourceFile = credential?.sourceFile || openCodeModelsCacheFile(); + return openCodeProtocolOrder.map((protocol) => { + const providerName = publicOnly ? "OpenCode Public" : catalog.name; + const name = `${providerName} (${openCodeProtocolLabels[protocol]})`; + const id = `opencode-api-${protocol.replaceAll("_", "-")}`; + const models = catalog.models[protocol]; + const modelDisplayNames = catalog.modelDisplayNames[protocol]; + if (publicOnly && models.length > 0) { + return { + detail: "OpenCode CLI public models detected. No login is required.", + id, + importable: true, + kind: "opencode", + modelDisplayNames, + models, + name, + protocol, + sourceFile, + status: "available" + }; + } + if (invalidCredential) { + return { + detail: "OpenCode CLI credential was found, but no usable API key was detected.", + id, + importable: false, + kind: "opencode", + modelDisplayNames, + models, + name, + protocol, + sourceFile, + status: "locked" + }; + } + if (credential?.apiKey) { + return { + detail: "OpenCode CLI login detected. Click Import to add it as a gateway provider.", + id, + importable: true, + kind: "opencode", + modelDisplayNames, + models, + name, + protocol, + sourceFile: credential.sourceFile, + status: "available" + }; + } + return missingCandidate("opencode", id, name, protocol, models, modelDisplayNames); + }); +} + +export function importOpenCodeProvider( + candidate: LocalAgentProviderCandidate, + providerNames: string[] +): LocalAgentProviderImportResult { + const credential = readOpenCodeCredential(); + if (credential?.hasCredential && !credential.apiKey) { + throw new Error("OpenCode CLI API key was not found."); + } + const publicOnly = !credential; + const catalog = readOpenCodeCatalog({ publicOnly }); + if (!isOpenCodeProtocol(candidate.protocol)) { + throw new Error(`Unsupported OpenCode protocol: ${candidate.protocol}`); + } + const protocol = candidate.protocol; + if (publicOnly && !candidate.models.every((model) => catalog.models[protocol].includes(model))) { + throw new Error("OpenCode CLI public models were not found."); + } + const apiKey = credential?.apiKey || "public"; + const authSuffix = `opencode-${candidate.protocol.replaceAll("_", "-")}-api-key`; + const provider = providerPayload( + candidate, + uniqueProviderName(providerNames, candidate.name), + catalog.baseUrl + ); + return { + candidate, + provider, + providerPlugins: [ + openCodeAuthPlugin(candidate.protocol, authSuffix, apiKey), + openCodeAuthPlugin(candidate.protocol, `${authSuffix}-internal`, apiKey, providerInternalNamePlaceholder) + ] + }; +} + +export function removeOpenCodeProviderAccountConfig(provider: GatewayProviderConfig): GatewayProviderConfig { + const account = provider.account; + if (!account?.connectors?.some(isGeneratedOpenCodeAccountConnector)) { + return provider; + } + const connectors = account.connectors.filter((connector) => !isGeneratedOpenCodeAccountConnector(connector)); + return { + ...provider, + account: connectors.length > 0 ? { ...account, connectors } : undefined + }; +} + +function isGeneratedOpenCodeAccountConnector(connector: ProviderAccountConnectorConfig): boolean { + if (connector.type !== "local-estimate") { + return false; + } + const ids = new Set(connector.windows.map((window) => window.id)); + return ids.has("opencode_monthly_spend") && + ids.has("opencode_monthly_tokens") && + ids.has("opencode_monthly_requests"); +} + +function openCodeAuthPlugin( + protocol: GatewayProviderProtocol, + suffix: string, + apiKey: string, + providerName = providerNamePlaceholder +): Record { + if (protocol === "anthropic_messages") { + return apiKeyAuthPlugin(suffix, apiKey, providerName); + } + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { + return { + auth: { + headers: { + "x-goog-api-key": apiKey + }, + query: { + key: apiKey + }, + removeHeaders: ["authorization", "x-api-key"], + strict: true + }, + key: `ccr-local-agent-${providerNameSlugPlaceholder}-${suffix}`, + providerName + }; + } + return bearerAuthPlugin(suffix, apiKey, {}, providerName); +} + +function readOpenCodeCredential(): OpenCodeCredential | undefined { + const config = readOpenCodeConfig(); + const configuredApiKey = configuredOpenCodeApiKey(config); + const configuredApiKeyPresent = configuredOpenCodeApiKeyIsPresent(config); + if (configuredApiKey) { + return { + apiKey: configuredApiKey, + hasCredential: true, + sourceFile: config.sourceFile || "OpenCode config" + }; + } + + const inlineAuth = process.env.OPENCODE_AUTH_CONTENT?.trim(); + if (inlineAuth) { + const record = parseJsoncRecord(inlineAuth); + const credential = openCodeCredentialFromRecord(record, "env:OPENCODE_AUTH_CONTENT"); + if (credential) { + return credential; + } + } + + for (const sourceFile of openCodeAuthFiles()) { + const record = readJsonRecord(sourceFile); + if (!record) { + continue; + } + const credential = openCodeCredentialFromRecord(record, sourceFile); + if (credential) { + return credential; + } + } + + const environmentApiKey = process.env.OPENCODE_API_KEY?.trim(); + if (environmentApiKey) { + return { apiKey: environmentApiKey, hasCredential: true, sourceFile: "env:OPENCODE_API_KEY" }; + } + + return configuredApiKeyPresent + ? { hasCredential: true, sourceFile: config.sourceFile || "OpenCode config" } + : undefined; +} + +function openCodeCredentialFromRecord( + record: Record | undefined, + sourceFile: string +): OpenCodeCredential | undefined { + if (!record || !(openCodeProviderId in record)) { + return undefined; + } + const value = record[openCodeProviderId]; + if (typeof value === "string") { + return { + apiKey: readString(value), + hasCredential: true, + sourceFile + }; + } + if (!isRecord(value)) { + return { hasCredential: true, sourceFile }; + } + return { + apiKey: readString(value.key) || readString(value.access) || readString(value.token), + hasCredential: true, + sourceFile + }; +} + +function configuredOpenCodeApiKey(config: OpenCodeConfig): string | undefined { + const value = configuredOpenCodeApiKeyValue(config); + if (!value) { + return undefined; + } + const environmentReference = value.match(/^\{env:([^}]+)\}$/); + if (environmentReference) { + return process.env[environmentReference[1]]?.trim() || undefined; + } + const fileReference = value.match(/^\{file:([^}]+)\}$/); + if (fileReference) { + try { + const sourceDirectory = config.sourceFile && !config.sourceFile.startsWith("env:") + ? path.dirname(config.sourceFile) + : undefined; + return readFileSync(resolveOpenCodeReferencePath(fileReference[1], sourceDirectory), "utf8").trim() || undefined; + } catch { + return undefined; + } + } + return value; +} + +function configuredOpenCodeApiKeyIsPresent(config: OpenCodeConfig): boolean { + return Boolean(configuredOpenCodeApiKeyValue(config)); +} + +function configuredOpenCodeApiKeyValue(config: OpenCodeConfig): string | undefined { + const provider = openCodeProviderConfig(config.record); + const options = isRecord(provider?.options) ? provider.options : {}; + return readString(options.apiKey) || readString(options.api_key); +} + +function readOpenCodeCatalog(options: { publicOnly: boolean }): OpenCodeCatalog { + const cache = readJsonRecord(openCodeModelsCacheFile()); + const cachedProvider = isRecord(cache?.[openCodeProviderId]) ? cache[openCodeProviderId] : {}; + const config = readOpenCodeConfig().record; + const configuredProvider = openCodeProviderConfig(config) ?? {}; + const configuredOptions = isRecord(configuredProvider.options) ? configuredProvider.options : {}; + const baseUrl = + readString(configuredOptions.baseURL) || + readString(configuredOptions.baseUrl) || + readString(cachedProvider.api) || + openCodeDefaultBaseUrl; + const name = readString(configuredProvider.name) || readString(cachedProvider.name) || "OpenCode Zen"; + const providerNpm = readString(configuredProvider.npm) || readString(cachedProvider.npm) || "@ai-sdk/openai-compatible"; + const cachedModels = isRecord(cachedProvider.models) ? cachedProvider.models : {}; + const configuredModels = isRecord(configuredProvider.models) ? configuredProvider.models : {}; + const configuredModelIds = new Set(Object.keys(configuredModels)); + const mergedModels = new Map>(); + for (const [modelId, value] of Object.entries(cachedModels)) { + if (isRecord(value)) { + mergedModels.set(modelId, value); + } + } + for (const [modelId, value] of Object.entries(configuredModels)) { + const previous = mergedModels.get(modelId) ?? {}; + mergedModels.set(modelId, isRecord(value) ? deepMergeRecords(previous, value) : previous); + } + + const selectedModels = uniqueStrings([ + openCodeModelId(readString(config.model)), + openCodeModelId(readString(config.small_model)) + ]); + const orderedModelIds = uniqueStrings([...selectedModels, ...mergedModels.keys()]); + const models = emptyOpenCodeProtocolRecord(() => []); + const modelDisplayNames = emptyOpenCodeProtocolRecord>(() => ({})); + + for (const configuredModelId of orderedModelIds) { + const model = mergedModels.get(configuredModelId); + if (!model || (readString(model.status) === "deprecated" && !configuredModelIds.has(configuredModelId) && !selectedModels.includes(configuredModelId))) { + continue; + } + if (options.publicOnly && !openCodeModelIsFree(model)) { + continue; + } + const modelId = readString(model.id) || configuredModelId; + const modelProvider = isRecord(model.provider) ? model.provider : {}; + const protocol = openCodeProtocolFromNpm(readString(modelProvider.npm) || readString(model.npm) || providerNpm); + models[protocol].push(modelId); + const displayName = readString(model.name); + if (displayName && displayName !== modelId) { + modelDisplayNames[protocol][modelId] = displayName; + } + } + + for (const protocol of openCodeProtocolOrder) { + models[protocol] = uniqueStrings( + models[protocol].length > 0 + ? models[protocol] + : options.publicOnly ? [] : openCodeFallbackModels[protocol] + ); + const allowedModels = new Set(models[protocol]); + modelDisplayNames[protocol] = Object.fromEntries( + Object.entries(modelDisplayNames[protocol]).filter(([modelId]) => allowedModels.has(modelId)) + ); + } + + return { baseUrl, modelDisplayNames, models, name }; +} + +function openCodeModelIsFree(model: Record): boolean { + const cost = isRecord(model.cost) ? model.cost : undefined; + if (!cost) { + return false; + } + return requiredOpenCodeCostIsFree(cost.input) && + requiredOpenCodeCostIsFree(cost.output) && + optionalOpenCodeCostFieldsAreFree(cost, [ + "cache_read", + "cache_write", + "cacheRead", + "cacheWrite", + "input_cache_read", + "input_cache_write" + ]); +} + +function requiredOpenCodeCostIsFree(value: unknown): boolean { + return openCodeCostValue(value) === 0; +} + +function optionalOpenCodeCostIsFree(value: unknown): boolean { + const cost = openCodeCostValue(value); + return cost === undefined || cost === 0; +} + +function optionalOpenCodeCostFieldsAreFree(cost: Record, fields: string[]): boolean { + return fields.every((field) => optionalOpenCodeCostIsFree(cost[field])); +} + +function openCodeCostValue(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + const parsed = Number(readString(value)); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function openCodeProtocolFromNpm(value: string): OpenCodeProtocol { + const normalized = value.trim().toLowerCase(); + if (normalized.includes("anthropic")) { + return "anthropic_messages"; + } + if (normalized.includes("google")) { + return "gemini_generate_content"; + } + if (normalized === "@ai-sdk/openai" || normalized.endsWith("/openai")) { + return "openai_responses"; + } + return "openai_chat_completions"; +} + +function isOpenCodeProtocol(protocol: GatewayProviderProtocol): protocol is OpenCodeProtocol { + return protocol !== "gemini_interactions"; +} + +function openCodeModelId(value: string | undefined): string | undefined { + if (!value?.startsWith(`${openCodeProviderId}/`)) { + return undefined; + } + return readString(value.slice(openCodeProviderId.length + 1)); +} + +function openCodeProviderConfig(config: Record): Record | undefined { + const providers = isRecord(config.provider) ? config.provider : undefined; + return isRecord(providers?.[openCodeProviderId]) ? providers[openCodeProviderId] : undefined; +} + +function readOpenCodeConfig(): OpenCodeConfig { + let record: Record = {}; + let sourceFile: string | undefined; + for (const file of openCodeConfigFiles()) { + const next = readJsoncRecord(file); + if (!next) { + continue; + } + record = deepMergeRecords(record, next); + if (openCodeProviderConfig(next)) { + sourceFile = file; + } + } + const inlineConfig = process.env.OPENCODE_CONFIG_CONTENT?.trim(); + if (inlineConfig) { + const next = parseJsoncRecord(inlineConfig); + if (next) { + record = deepMergeRecords(record, next); + if (openCodeProviderConfig(next)) { + sourceFile = "env:OPENCODE_CONFIG_CONTENT"; + } + } + } + return { record, sourceFile }; +} + +function deepMergeRecords(left: Record, right: Record): Record { + const result = { ...left }; + for (const [key, value] of Object.entries(right)) { + result[key] = isRecord(result[key]) && isRecord(value) + ? deepMergeRecords(result[key], value) + : value; + } + return result; +} + +function emptyOpenCodeProtocolRecord(factory: (protocol: OpenCodeProtocol) => T): Record { + return Object.fromEntries(openCodeProtocolOrder.map((protocol) => [protocol, factory(protocol)])) as Record; +} + +function openCodeAuthFiles(): string[] { + return uniqueStrings([ + path.join(openCodeDataRoot(), "auth.json") + ]); +} + +function openCodeConfigFiles(): string[] { + const customConfig = process.env.OPENCODE_CONFIG?.trim(); + return uniqueStrings([ + path.join(openCodeConfigRoot(), "opencode.json"), + path.join(openCodeConfigRoot(), "opencode.jsonc"), + path.join(openCodeDataRoot(), "opencode.json"), + path.join(openCodeDataRoot(), "opencode.jsonc"), + customConfig ? resolveOpenCodeReferencePath(customConfig) : undefined + ]).filter((file) => existsSync(file)); +} + +function openCodeDataRoot(): string { + return path.join(openCodeXdgRoot("XDG_DATA_HOME", path.join(".local", "share")), "opencode"); +} + +function openCodeConfigRoot(): string { + return path.join(openCodeXdgRoot("XDG_CONFIG_HOME", ".config"), "opencode"); +} + +function openCodeModelsCacheFile(): string { + return path.join(openCodeXdgRoot("XDG_CACHE_HOME", ".cache"), "opencode", "models.json"); +} + +function openCodeXdgRoot(environmentName: "XDG_CACHE_HOME" | "XDG_CONFIG_HOME" | "XDG_DATA_HOME", fallback: string): string { + const internalHome = process.env.CCR_INTERNAL_HOME_DIR?.trim(); + if (internalHome) { + return path.join(internalHome, fallback); + } + const explicitRoot = process.env[environmentName]?.trim(); + return explicitRoot || path.join(openCodeHomeDir(), fallback); +} + +function openCodeHomeDir(): string { + return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir(); +} + +function resolveOpenCodeReferencePath(value: string, baseDirectory?: string): string { + const trimmed = value.trim(); + if (trimmed === "~") { + return openCodeHomeDir(); + } + if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { + return path.join(openCodeHomeDir(), trimmed.slice(2)); + } + return path.resolve(baseDirectory || process.cwd(), trimmed); +} diff --git a/packages/core/src/agents/local-providers/service.ts b/packages/core/src/agents/local-providers/service.ts index e485cb55..c9a77ba6 100644 --- a/packages/core/src/agents/local-providers/service.ts +++ b/packages/core/src/agents/local-providers/service.ts @@ -8,6 +8,7 @@ import type { import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code"; import { codexCandidate, importCodexProvider, probeCodexProvider } from "@ccr/core/agents/local-providers/codex"; import { grokCandidate, importGrokProvider } from "@ccr/core/agents/local-providers/grok"; +import { importOpenCodeProvider, opencodeCandidates } from "@ccr/core/agents/local-providers/opencode"; import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode"; export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex"; @@ -20,6 +21,7 @@ export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[] codexCandidate(), claudeCodeCandidate(), grokCandidate(), + ...opencodeCandidates(), zcodeCandidate() ].filter((candidate) => candidate.status !== "missing"); } @@ -42,6 +44,9 @@ export async function importLocalAgentProvider(request: LocalAgentProviderImport if (candidate.kind === "grok") { return importGrokProvider(candidate, request.providerNames ?? []); } + if (candidate.kind === "opencode") { + return importOpenCodeProvider(candidate, request.providerNames ?? []); + } return importZcodeProvider(candidate, request.providerNames ?? []); } diff --git a/packages/core/src/agents/local-providers/shared.ts b/packages/core/src/agents/local-providers/shared.ts index 1432c015..fbbbf044 100644 --- a/packages/core/src/agents/local-providers/shared.ts +++ b/packages/core/src/agents/local-providers/shared.ts @@ -166,6 +166,113 @@ export function readJsonRecord(file: string): Record | undefine } } +export function readJsoncRecord(file: string): Record | undefined { + if (!existsSync(file)) { + return undefined; + } + try { + return parseJsoncRecord(readFileSync(file, "utf8")); + } catch { + return undefined; + } +} + +export function parseJsoncRecord(value: string): Record | undefined { + try { + const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(value)) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function stripJsonCommentsAndTrailingCommas(value: string): string { + let withoutComments = ""; + let inString = false; + let escaped = false; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + const nextCharacter = value[index + 1]; + if (inString) { + withoutComments += character; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + withoutComments += character; + continue; + } + if (character === "/" && nextCharacter === "/") { + withoutComments += " "; + index += 1; + while (index + 1 < value.length && value[index + 1] !== "\n" && value[index + 1] !== "\r") { + withoutComments += " "; + index += 1; + } + continue; + } + if (character === "/" && nextCharacter === "*") { + withoutComments += " "; + index += 1; + while (index + 1 < value.length) { + const commentCharacter = value[index + 1]; + const commentNextCharacter = value[index + 2]; + if (commentCharacter === "*" && commentNextCharacter === "/") { + withoutComments += " "; + index += 2; + break; + } + withoutComments += commentCharacter === "\n" || commentCharacter === "\r" ? commentCharacter : " "; + index += 1; + } + continue; + } + withoutComments += character; + } + + let result = ""; + inString = false; + escaped = false; + for (let index = 0; index < withoutComments.length; index += 1) { + const character = withoutComments[index]; + if (inString) { + result += character; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + result += character; + continue; + } + if (character === ",") { + let lookahead = index + 1; + while (lookahead < withoutComments.length && /\s/.test(withoutComments[lookahead])) { + lookahead += 1; + } + if (withoutComments[lookahead] === "}" || withoutComments[lookahead] === "]") { + continue; + } + } + result += character; + } + return result; +} + export function uniqueProviderName(existingNames: string[], baseName: string): string { const existing = new Set(existingNames.map((name) => name.trim().toLowerCase()).filter(Boolean)); if (!existing.has(baseName.toLowerCase())) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 60d901d5..47a6e76c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -5,6 +5,7 @@ import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/ import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants"; import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex"; import { normalizeGrokProviderAccountConfig } from "@ccr/core/agents/local-providers/grok"; +import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-providers/opencode"; import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app"; import { createDefaultAppConfig } from "@ccr/core/config/default-config"; import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; @@ -1068,7 +1069,9 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { transformer: item.transformer, type: readString(item.type) }; - return normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider)); + return removeOpenCodeProviderAccountConfig( + normalizeGrokProviderAccountConfig(normalizeCodexProviderAccountConfig(provider)) + ); }) .filter((item): item is GatewayProviderConfig => Boolean(item)); diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index a6babcc3..c6db75c2 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -329,7 +329,7 @@ export type ProviderManifestFetchResult = { url: string; }; -export type LocalAgentProviderKind = "claude-code" | "codex" | "grok" | "zcode"; +export type LocalAgentProviderKind = "claude-code" | "codex" | "grok" | "opencode" | "zcode"; export type LocalAgentProviderStatus = "available" | "locked" | "missing"; @@ -442,6 +442,7 @@ export type GatewayProviderProbeRequest = { forceRefresh?: boolean; mode?: "connectivity" | "models" | "protocols"; models?: string[]; + providerPlugins?: unknown[]; protocols?: GatewayProviderProtocol[]; skipModelDiscovery?: boolean; }; @@ -460,6 +461,7 @@ export type GatewayProviderProbeCandidatesRequest = { forceRefresh?: boolean; mode?: "connectivity" | "models" | "protocols"; models?: string[]; + providerPlugins?: unknown[]; protocols?: GatewayProviderProtocol[]; }; @@ -515,6 +517,7 @@ export type GatewayProviderConnectivityCheckRequest = { candidates: GatewayProviderProbeCandidate[]; forceRefresh?: boolean; models: string[]; + providerPlugins?: unknown[]; protocols?: GatewayProviderProtocol[]; }; diff --git a/packages/core/src/contracts/i18n.ts b/packages/core/src/contracts/i18n.ts index 4cc9c576..fe60cf91 100644 --- a/packages/core/src/contracts/i18n.ts +++ b/packages/core/src/contracts/i18n.ts @@ -33,6 +33,8 @@ const zhExactErrorMessages: Record = { "Network capture MCP is disabled.": "网络捕获 MCP 已禁用。", "No available models": "没有可用模型", "No available models. Configure at least one provider with a model before starting CCR Gateway or opening an agent through CCR.": "没有可用模型。请先配置至少一个包含模型的供应商,再启动 CCR 网关或通过 CCR 打开 Agent。", + "OpenCode CLI API key was not found.": "未找到 OpenCode CLI API key。", + "OpenCode CLI public models were not found.": "未找到 OpenCode CLI 公共模型。", "No Bot Gateway conversationRef is available for inbound bot response.": "没有可用于入站 Bot 响应的 Bot Gateway conversationRef。", "No Bot Gateway conversationRef is configured and no inbound bot event context is available.": "未配置 Bot Gateway conversationRef,且没有可用的入站 Bot 事件上下文。", "No endpoint candidates available.": "没有可用的端点候选项。", diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index 3f6c7bd4..5d50dab9 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -137,6 +137,7 @@ export async function probeGatewayProviderCandidates( forceRefresh: request.forceRefresh, mode, models: mode === "connectivity" ? request.models ?? [] : [], + providerPlugins: request.providerPlugins, protocols }); results.push({ candidate, probe }); @@ -161,6 +162,7 @@ export async function checkGatewayProviderConnectivity( forceRefresh: request.forceRefresh, mode: "connectivity", models: [model], + providerPlugins: request.providerPlugins, protocols: request.protocols }); if (!result) { @@ -231,7 +233,7 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) const models = (mode === "connectivity" || mode === "models") && modelProbe.models.length > 0 ? modelProbe.models : typedModels; - const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode); + const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode, request.providerPlugins ?? []); const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols); const normalizedBaseUrl = detectedProtocol ? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe) @@ -258,6 +260,7 @@ function providerProbeCacheKey(request: GatewayProviderProbeRequest): string { baseUrl: request.baseUrl.trim(), mode: request.mode ?? "protocols", models: uniqueStrings(request.models ?? []), + providerPluginsHash: hashSensitiveValue(JSON.stringify(request.providerPlugins ?? [])), protocols: uniqueProtocols(request.protocols ?? []), skipModelDiscovery: request.skipModelDiscovery === true }); @@ -482,14 +485,15 @@ async function probeProtocols( apiKey: string | undefined, models: string[], allowedProtocols: GatewayProviderProtocol[] = [], - mode: NonNullable = "protocols" + mode: NonNullable = "protocols", + providerPlugins: unknown[] = [] ): Promise { const results: GatewayProviderProbeProtocolResult[] = []; for (const protocol of orderedProtocols(parsed, allowedProtocols)) { results.push( mode === "connectivity" - ? await probeProtocolConnectivity(parsed, apiKey, models, protocol) + ? await probeProtocolConnectivity(parsed, apiKey, models, protocol, providerPlugins) : await probeProtocolSupport(parsed, apiKey, protocol) ); } @@ -538,7 +542,8 @@ async function probeProtocolConnectivity( parsed: ParsedProviderUrl, apiKey: string | undefined, models: string[], - protocol: GatewayProviderProtocol + protocol: GatewayProviderProtocol, + providerPlugins: unknown[] = [] ): Promise { const model = pickProbeModel(models, protocol); const endpoints = endpointsForProtocol(parsed, protocol, model); @@ -556,7 +561,12 @@ async function probeProtocolConnectivity( let firstResult: GatewayProviderProbeProtocolResult | undefined; for (const candidate of endpoints) { - const result = await requestJson(candidate.endpoint, requestForProtocol(protocol, model, apiKey)); + const request = providerProbeAuthRequest( + candidate.endpoint, + requestForProtocol(protocol, model, apiKey), + providerPlugins + ); + const result = await requestJson(request.url, request.init); const message = readResponseMessage(result); const supported = isProtocolSupported(result.status, message, protocol); const probeResult = { @@ -676,6 +686,59 @@ function requestForProtocolSupport(protocol: GatewayProviderProtocol, apiKey: st }; } +function providerProbeAuthRequest( + url: string, + init: RequestInit, + providerPlugins: unknown[] +): { init: RequestInit; url: string } { + const auth = providerPlugins + .map(providerPluginAuth) + .find((item): item is Record => Boolean(item)); + if (!auth) { + return { init, url }; + } + + const headers = new Headers(init.headers); + for (const header of readStringArray(auth.removeHeaders)) { + headers.delete(header); + } + for (const [name, value] of Object.entries(isRecord(auth.headers) ? auth.headers : {})) { + const headerValue = readString(value); + if (headerValue) { + headers.set(name, headerValue); + } + } + + const nextUrl = new URL(url); + for (const [name, value] of Object.entries(isRecord(auth.query) ? auth.query : {})) { + const queryValue = readString(value); + if (queryValue) { + nextUrl.searchParams.set(name, queryValue); + } + } + + return { + init: { + ...init, + headers + }, + url: nextUrl.toString() + }; +} + +function providerPluginAuth(plugin: unknown): Record | undefined { + if (!isRecord(plugin) || !isRecord(plugin.auth)) { + return undefined; + } + return plugin.auth; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.map(readString).filter((item): item is string => Boolean(item)) + : []; +} + async function requestJson(url: string, init: RequestInit): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), probeTimeoutMs); diff --git a/packages/core/src/providers/url.ts b/packages/core/src/providers/url.ts index e3f66b39..58c95c7e 100644 --- a/packages/core/src/providers/url.ts +++ b/packages/core/src/providers/url.ts @@ -127,7 +127,8 @@ function stripProviderApiVersion(value: string): string { } function providerGeminiBaseUrl(normalizedInputBaseUrl: string, rootBaseUrl: string): string { - return isVersionedVertexBypassBaseUrl(normalizedInputBaseUrl) + return isVersionedVertexBypassBaseUrl(normalizedInputBaseUrl) || + isNestedVersionedGeminiBaseUrl(normalizedInputBaseUrl) ? normalizedInputBaseUrl : rootBaseUrl; } @@ -147,6 +148,19 @@ function isVersionedVertexBypassBaseUrl(value: string): boolean { } } +function isNestedVersionedGeminiBaseUrl(value: string): boolean { + try { + const url = new URL(value); + const segments = url.pathname + .split("/") + .map((segment) => segment.trim().toLowerCase()) + .filter(Boolean); + return segments.length > 1 && /^(v1|v1beta)$/.test(segments[segments.length - 1] ?? ""); + } catch { + return false; + } +} + function stripNestedProviderApiVersion(pathname: string): string { return pathname.replace(/(\/v[0-9][a-z0-9-]*)\/v1$/i, "$1") || "/"; } diff --git a/packages/ui/src/assets/agent-logos/opencode.ico b/packages/ui/src/assets/agent-logos/opencode.ico new file mode 100644 index 0000000000000000000000000000000000000000..34ca0b9c01b23ca30f64ed17bd0705bb08cca4b1 GIT binary patch literal 15086 zcmeHOK~BRk5L`qb5Qz`ug4APAoOwpYiDO^FTX+>;32UH|yXxAWdV|$=ELq9glikTM zu~I9DJjkQ$cB02wKA%NiL}Z@-;z{I7$0j|d`NKx!LjhaG=q2*5J&k*9nw}PRYCsRr z1M~nraO)meR&HzSw=(_J-v!9OtN{H}UW3c(_0Nvdf4_fQknY_n`CsNAMEivG8uh3C zEncF3%0BU)BTu&$&2x;G$EzDP?$yn?wVZo-qIi0Fyt*x%bBveAs~a`$)y=uJoO^kq zczSufx-Fb@jF-o&8#NwPH!2T$Ur}|V@{s?plB0AORhRyWeSh%uM8{D-pm>xIibwgN zc$5!{NBN+5ln;tW`Ji}|4~j?mpm>xIibwenf7v=r-KZNqKo8IZV?E$~7sT=ZQ6PP% zHEVy>&hNCwJ_}rnu2+BbJ>TMc|MoT?8nb?%_pILeUiZ0ki;vd-T+Mup^-mR`{`7y3 z2Tt9zRKH_G@ilH}J`~UT<>$ldiN-a3Yx8J5i?8Lc&D(rvT+3gZN9$R9EkEbW{--{- zt&eUG3~gzjLjT3=XRqR(_9^T-W#ig&XYn>}y*~f#kj{_oUaq(IiQa$j^~ZYlX!F|G N;%#j4*B
-
{t("Import local agent login")}
-
{t("CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.")}
+
{t("Import local agent provider")}
+
{t("CCR scanned this computer for local Claude Code, Codex, Grok CLI, OpenCode CLI, and ZCode providers. Click Import to add one as a gateway provider.")}
{loading ? : null}
@@ -1278,19 +1278,27 @@ function LocalAgentProviderImportPanel({ } const localAgentProviderApiKey = "ccr-local-agent-login"; -const localAgentProviderPluginSuffixes: Record = { +const localAgentProviderPluginSuffixes: Record, string[]> = { "claude-code": ["-claude-code-oauth", "-claude-code-oauth-internal"], codex: ["-codex-oauth", "-codex-oauth-internal"], grok: ["-grok-cli-oauth", "-grok-cli-oauth-internal"], zcode: ["-zcode-api-key", "-zcode-api-key-internal"] }; +function localAgentProviderPluginSuffixesForCandidate(candidate: LocalAgentProviderCandidate): string[] { + if (candidate.kind === "opencode") { + const baseSuffix = `-opencode-${candidate.protocol.replaceAll("_", "-")}-api-key`; + return [baseSuffix, `${baseSuffix}-internal`]; + } + return localAgentProviderPluginSuffixes[candidate.kind]; +} + function localAgentProviderAlreadyImported( candidate: LocalAgentProviderCandidate, providers: GatewayProviderConfig[], providerPlugins: unknown[] ): boolean { - const suffixes = localAgentProviderPluginSuffixes[candidate.kind]; + const suffixes = localAgentProviderPluginSuffixesForCandidate(candidate); const localProviderNames = new Set(providers .filter((provider) => provider.api_key === localAgentProviderApiKey) .flatMap((provider) => [ @@ -1378,7 +1386,6 @@ export function AddProviderForm({ const protocolProbeRows = useMemo(() => uniqueProviderProbeProtocolRows(probe?.protocols ?? []), [probe]); const configuredModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText)); const hasConnectivityCheckInputs = Boolean( - !localAgentImport && draft.baseUrl.trim() && draft.apiKey.trim() && configuredModels.length > 0 diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index b5c299de..129e9434 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -259,12 +259,13 @@ export const appCopy: Record = { "Checking connection": "Checking connection", "Click Check Connection to verify connectivity with a real model request.": "Click Check Connection to verify connectivity with a real model request.", "Connection verified": "Connection verified", - "CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.", + "CCR scanned this computer for Claude Code, Codex, Grok CLI, OpenCode CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR scanned this computer for Claude Code, Codex, Grok CLI, OpenCode CLI, and ZCode login states. Click Import to add one as a gateway provider.", "Detected": "Detected", "Detecting protocols": "Detecting protocols", "Enter API endpoint, API key, and at least one model to enable connectivity check.": "Enter API endpoint, API key, and at least one model to enable connectivity check.", "Generated output is limited to 1 token for connectivity checks.": "Generated output is limited to 1 token for connectivity checks.", "Import local agent login": "Import local agent login", + "Import local agent provider": "Import local agent provider", "ChatGPT login detected. Click Import to add it as a gateway provider.": "ChatGPT login detected. Click Import to add it as a gateway provider.", "Claude Code login detected. Click Import to add it as a gateway provider.": "Claude Code login detected. Click Import to add it as a gateway provider.", "Claude Code login was detected, but no usable access token was found.": "Claude Code login was detected, but no usable access token was found.", @@ -273,6 +274,10 @@ export const appCopy: Record = { "Grok CLI login detected. Click Import to add it as a gateway provider.": "Grok CLI login detected. Click Import to add it as a gateway provider.", "Grok CLI login was detected, but no usable access token was found.": "Grok CLI login was detected, but no usable access token was found.", "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.": "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.", + "OpenCode CLI credential was found, but no usable API key was detected.": "OpenCode CLI credential was found, but no usable API key was detected.", + "OpenCode CLI login detected. Click Import to add it as a gateway provider.": "OpenCode CLI login detected. Click Import to add it as a gateway provider.", + "OpenCode CLI public models detected. No login is required.": "OpenCode CLI public models detected. No login is required.", + "CCR scanned this computer for local Claude Code, Codex, Grok CLI, OpenCode CLI, and ZCode providers. Click Import to add one as a gateway provider.": "CCR scanned this computer for local Claude Code, Codex, Grok CLI, OpenCode CLI, and ZCode providers. Click Import to add one as a gateway provider.", "Locked": "Locked", "Local agent login will be connected after saving this provider.": "Local agent login will be connected after saving this provider.", "Models to check": "Models to check", @@ -773,11 +778,12 @@ export const appCopy: Record = { "Default failure handling": "默认故障处理", "Default on failure": "默认失败处理", "Description": "描述", - "CCR scanned this computer for Claude Code, Codex, Grok CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex、Grok CLI 和 ZCode 登录态。点击导入即可添加为网关供应商。", + "CCR scanned this computer for Claude Code, Codex, Grok CLI, OpenCode CLI, and ZCode login states. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex、Grok CLI、OpenCode CLI 和 ZCode 登录态。点击导入即可添加为网关供应商。", "Detected": "已检测", "Detecting protocols": "正在探测协议", "Enter API endpoint, API key, and at least one model to enable connectivity check.": "填写 API 地址、API Key 和至少一个模型后,才可检测连通性。", "Import local agent login": "导入本机 Agent 登录态", + "Import local agent provider": "导入本机 Agent 供应商", "ChatGPT login detected. Click Import to add it as a gateway provider.": "已检测到 ChatGPT 登录态。点击导入即可添加为网关供应商。", "Claude Code login detected. Click Import to add it as a gateway provider.": "已检测到 Claude Code 登录态。点击导入即可添加为网关供应商。", "Claude Code login was detected, but no usable access token was found.": "已检测到 Claude Code 登录态,但没有找到可用的 access token。", @@ -786,6 +792,10 @@ export const appCopy: Record = { "Grok CLI login detected. Click Import to add it as a gateway provider.": "已检测到 Grok CLI 登录态。点击导入即可添加为网关供应商。", "Grok CLI login was detected, but no usable access token was found.": "已检测到 Grok CLI 登录态,但没有找到可用的 access token。", "Grok CLI login was detected, but the access token is expired. Run grok login again, then rescan.": "已检测到 Grok CLI 登录态,但 access token 已过期。请重新运行 grok login,然后重新扫描。", + "OpenCode CLI credential was found, but no usable API key was detected.": "已找到 OpenCode CLI 凭据,但没有检测到可用的 API key。", + "OpenCode CLI login detected. Click Import to add it as a gateway provider.": "已检测到 OpenCode CLI 登录态。点击导入即可添加为网关供应商。", + "OpenCode CLI public models detected. No login is required.": "已检测到 OpenCode CLI 公共模型,无需登录即可导入。", + "CCR scanned this computer for local Claude Code, Codex, Grok CLI, OpenCode CLI, and ZCode providers. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex、Grok CLI、OpenCode CLI 和 ZCode 供应商。点击导入即可添加为网关供应商。", "Locked": "已加密", "Local agent login will be connected after saving this provider.": "保存这个供应商后会接入本机 Agent 登录态。", "Display name": "显示名称", diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index 1b3b295f..f0d7fe53 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -103,6 +103,7 @@ import appLogoUrl from "@/assets/logo.png"; import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; import codexLogoUrl from "@/assets/agent-logos/codex.png"; import grokLogoUrl from "@/assets/agent-logos/grok.ico"; +import openCodeLogoUrl from "@/assets/agent-logos/opencode.ico"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; @@ -390,6 +391,7 @@ export const localAgentProviderIconUrls: Record "claude-code": claudeCodeLogoUrl, codex: codexLogoUrl, grok: grokLogoUrl, + opencode: openCodeLogoUrl, zcode: zcodeLogoUrl }; diff --git a/tests/main/local-agent-provider-opencode.test.mjs b/tests/main/local-agent-provider-opencode.test.mjs new file mode 100644 index 00000000..e5ce1a9c --- /dev/null +++ b/tests/main/local-agent-provider-opencode.test.mjs @@ -0,0 +1,328 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + importOpenCodeProvider, + opencodeCandidates, + removeOpenCodeProviderAccountConfig +} from "../../packages/core/src/agents/local-providers/opencode.ts"; +import { localAgentProviderApiKey } from "../../packages/core/src/agents/local-providers/shared.ts"; + +test("OpenCode local provider imports Zen models using each model's native protocol", async () => { + await withOpenCodeHome(async (home) => { + writeOpenCodeAuth(home, { + opencode: { + key: "opencode-zen-key", + type: "api" + } + }); + writeOpenCodeModels(home, { + api: "https://opencode.ai/zen/v1", + models: { + "gpt-current": { + name: "GPT Current", + provider: { npm: "@ai-sdk/openai" } + }, + "claude-current": { + name: "Claude Current", + provider: { npm: "@ai-sdk/anthropic" } + }, + "chat-current": { + name: "Chat Current" + }, + "gemini-current": { + name: "Gemini Current", + provider: { npm: "@ai-sdk/google" } + }, + "gpt-deprecated": { + name: "GPT Deprecated", + provider: { npm: "@ai-sdk/openai" }, + status: "deprecated" + } + }, + name: "OpenCode Zen", + npm: "@ai-sdk/openai-compatible" + }); + writeOpenCodeConfig(home, `{ + // OpenCode accepts JSONC and trailing commas. + "model": "opencode/gpt-current", + "provider": { + "opencode": { + "name": "OpenCode Local", + "options": { + "baseURL": "https://opencode.example/v1", + }, + "models": { + "custom-chat": { "name": "Custom Chat", }, + "custom-chat-alias": { "id": "custom-chat-target", "name": "Custom Chat Alias", }, + }, + }, + }, + }`); + + const candidates = opencodeCandidates(); + assert.equal(candidates.length, 4); + assert.ok(candidates.every((candidate) => candidate.kind === "opencode")); + assert.ok(candidates.every((candidate) => candidate.importable)); + assert.ok(candidates.every((candidate) => candidate.status === "available")); + + const responses = candidateForProtocol(candidates, "openai_responses"); + const anthropic = candidateForProtocol(candidates, "anthropic_messages"); + const chat = candidateForProtocol(candidates, "openai_chat_completions"); + const gemini = candidateForProtocol(candidates, "gemini_generate_content"); + assert.deepEqual(responses.models, ["gpt-current"]); + assert.deepEqual(responses.modelDisplayNames, { "gpt-current": "GPT Current" }); + assert.deepEqual(anthropic.models, ["claude-current"]); + assert.deepEqual(chat.models, ["chat-current", "custom-chat", "custom-chat-target"]); + assert.deepEqual(chat.modelDisplayNames, { + "chat-current": "Chat Current", + "custom-chat": "Custom Chat", + "custom-chat-target": "Custom Chat Alias" + }); + assert.deepEqual(gemini.models, ["gemini-current"]); + assert.ok(!responses.models.includes("gpt-deprecated")); + + const result = importOpenCodeProvider(responses, [responses.name]); + assert.equal(result.provider.name, `${responses.name} 2`); + assert.equal(result.provider.baseUrl, "https://opencode.example/v1"); + assert.equal(result.provider.protocol, "openai_responses"); + assert.equal(result.provider.apiKey, localAgentProviderApiKey); + assert.deepEqual(result.provider.models, ["gpt-current"]); + assert.equal(result.provider.account, undefined); + assert.equal(result.providerPlugins.length, 2); + assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer opencode-zen-key"); + assert.equal(result.providerPlugins[0].key, "ccr-local-agent-__CCR_PROVIDER_NAME_SLUG__-opencode-openai-responses-api-key"); + assert.equal(result.providerPlugins[1].providerName, "__CCR_PROVIDER_INTERNAL_NAME__"); + + const anthropicResult = importOpenCodeProvider(anthropic, []); + assert.equal(anthropicResult.providerPlugins[0].auth.headers["x-api-key"], "opencode-zen-key"); + assert.deepEqual(anthropicResult.providerPlugins[0].auth.removeHeaders, ["authorization"]); + + const geminiResult = importOpenCodeProvider(gemini, []); + assert.equal(geminiResult.providerPlugins[0].auth.headers["x-goog-api-key"], "opencode-zen-key"); + assert.equal(geminiResult.providerPlugins[0].auth.query.key, "opencode-zen-key"); + }); +}); + +test("OpenCode local provider resolves API keys from OpenCode JSONC config", async () => { + await withOpenCodeHome(async (home) => { + process.env.CCR_OPENCODE_TEST_KEY = "configured-opencode-key"; + writeOpenCodeConfig(home, `{ + "provider": { + "opencode": { + "options": { "apiKey": "{env:CCR_OPENCODE_TEST_KEY}" }, + }, + }, + }`); + + const candidates = opencodeCandidates(); + assert.ok(candidates.every((candidate) => candidate.importable)); + assert.ok(candidates.every((candidate) => candidate.sourceFile?.endsWith("opencode.jsonc"))); + assert.deepEqual(candidateForProtocol(candidates, "openai_responses").models, ["gpt-5.2"]); + + const result = importOpenCodeProvider(candidateForProtocol(candidates, "openai_chat_completions"), []); + assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer configured-opencode-key"); + }); +}); + +test("OpenCode local provider imports public free models without a login", async () => { + await withOpenCodeHome(async (home) => { + writeOpenCodeModels(home, { + api: "https://opencode.ai/zen/v1", + models: { + "chat-free": { + cost: { input: 0, output: 0 }, + name: "Chat Free" + }, + "chat-paid": { + cost: { input: 1, output: 2 }, + name: "Chat Paid" + }, + "chat-output-paid": { + cost: { input: 0, output: 1 }, + name: "Chat Output Paid" + }, + "chat-cache-paid": { + cost: { cache_read: 1, input: 0, output: 0 }, + name: "Chat Cache Paid" + }, + "chat-deprecated-free": { + cost: { input: 0, output: 0 }, + name: "Chat Deprecated Free", + status: "deprecated" + }, + "anthropic-free": { + cost: { input: 0, output: 0 }, + name: "Anthropic Free", + provider: { npm: "@ai-sdk/anthropic" } + } + }, + name: "OpenCode Zen", + npm: "@ai-sdk/openai-compatible" + }); + + const candidates = opencodeCandidates(); + const available = candidates.filter((candidate) => candidate.status === "available"); + assert.equal(available.length, 2); + assert.deepEqual(candidateForProtocol(candidates, "openai_chat_completions").models, ["chat-free"]); + assert.deepEqual(candidateForProtocol(candidates, "anthropic_messages").models, ["anthropic-free"]); + assert.ok(available.every((candidate) => candidate.name.startsWith("OpenCode Public"))); + assert.ok(available.every((candidate) => candidate.detail.includes("No login is required"))); + + const chatResult = importOpenCodeProvider(candidateForProtocol(candidates, "openai_chat_completions"), []); + assert.equal(chatResult.providerPlugins[0].auth.headers.authorization, "Bearer public"); + assert.deepEqual(chatResult.provider.models, ["chat-free"]); + assert.equal(chatResult.provider.account, undefined); + + const anthropicResult = importOpenCodeProvider(candidateForProtocol(candidates, "anthropic_messages"), []); + assert.equal(anthropicResult.providerPlugins[0].auth.headers["x-api-key"], "public"); + }); +}); + +test("OpenCode local provider locks malformed credentials instead of importing public models", async () => { + await withOpenCodeHome(async (home) => { + writeOpenCodeAuth(home, { + opencode: { + type: "api" + } + }); + writeOpenCodeModels(home, { + api: "https://opencode.ai/zen/v1", + models: { + "chat-free": { + cost: { input: 0, output: 0 }, + name: "Chat Free" + } + }, + name: "OpenCode Zen", + npm: "@ai-sdk/openai-compatible" + }); + + const candidates = opencodeCandidates(); + assert.ok(candidates.every((candidate) => candidate.status === "locked")); + assert.ok(candidates.every((candidate) => !candidate.importable)); + assert.ok(candidates.every((candidate) => candidate.detail.includes("no usable API key"))); + + assert.throws( + () => importOpenCodeProvider(candidateForProtocol(candidates, "openai_chat_completions"), []), + /OpenCode CLI API key was not found/ + ); + }); +}); + +test("OpenCode local provider preserves nested Zen base URL for Gemini imports", async () => { + await withOpenCodeHome(async (home) => { + writeOpenCodeAuth(home, { + opencode: { + key: "opencode-zen-key", + type: "api" + } + }); + writeOpenCodeModels(home, { + api: "https://opencode.ai/zen/v1", + models: { + "gemini-current": { + name: "Gemini Current", + provider: { npm: "@ai-sdk/google" } + } + }, + name: "OpenCode Zen", + npm: "@ai-sdk/openai-compatible" + }); + + const result = importOpenCodeProvider(candidateForProtocol(opencodeCandidates(), "gemini_generate_content"), []); + assert.equal(result.provider.baseUrl, "https://opencode.ai/zen/v1"); + assert.equal(result.provider.protocol, "gemini_generate_content"); + }); +}); + +test("OpenCode local provider stays hidden without a login or cached public models", async () => { + await withOpenCodeHome(async () => { + const candidates = opencodeCandidates(); + assert.ok(candidates.every((candidate) => candidate.status === "missing")); + assert.ok(candidates.every((candidate) => !candidate.importable)); + }); +}); + +test("OpenCode removes the previously generated local account usage connector", () => { + const provider = removeOpenCodeProviderAccountConfig({ + account: { + connectors: [ + { + message: "Local usage from CCR history. OpenCode does not expose cloud balance through its API.", + type: "local-estimate", + windows: [ + { id: "opencode_monthly_spend", label: "CCR monthly spend", unit: "USD", window: "monthly" }, + { id: "opencode_monthly_tokens", label: "CCR monthly tokens", unit: "tokens", window: "monthly" }, + { id: "opencode_monthly_requests", label: "CCR monthly requests", unit: "requests", window: "monthly" } + ] + } + ], + enabled: true + }, + api_key: localAgentProviderApiKey, + models: ["gpt-5.2"], + name: "OpenCode Zen (Responses)", + protocol: "openai_responses" + }); + assert.equal(provider.account, undefined); +}); + +function candidateForProtocol(candidates, protocol) { + const candidate = candidates.find((item) => item.protocol === protocol); + assert.ok(candidate, `Expected OpenCode candidate for ${protocol}`); + return candidate; +} + +async function withOpenCodeHome(run) { + const environmentNames = [ + "CCR_INTERNAL_HOME_DIR", + "CCR_OPENCODE_TEST_KEY", + "OPENCODE_API_KEY", + "OPENCODE_AUTH_CONTENT", + "OPENCODE_CONFIG", + "OPENCODE_CONFIG_CONTENT" + ]; + const previousEnvironment = Object.fromEntries(environmentNames.map((name) => [name, process.env[name]])); + const home = mkdtempSync(path.join(os.tmpdir(), "ccr-opencode-test-")); + process.env.CCR_INTERNAL_HOME_DIR = home; + for (const name of environmentNames.slice(1)) { + delete process.env[name]; + } + try { + await run(home); + } finally { + for (const name of environmentNames) { + restoreEnv(name, previousEnvironment[name]); + } + rmSync(home, { force: true, recursive: true }); + } +} + +function writeOpenCodeAuth(home, auth) { + const directory = path.join(home, ".local", "share", "opencode"); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, "auth.json"), JSON.stringify(auth, null, 2)); +} + +function writeOpenCodeModels(home, provider) { + const directory = path.join(home, ".cache", "opencode"); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, "models.json"), JSON.stringify({ opencode: provider }, null, 2)); +} + +function writeOpenCodeConfig(home, content) { + const directory = path.join(home, ".config", "opencode"); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, "opencode.jsonc"), content); +} + +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/tests/main/provider-probe.test.mjs b/tests/main/provider-probe.test.mjs index 45054864..0a4f1284 100644 --- a/tests/main/provider-probe.test.mjs +++ b/tests/main/provider-probe.test.mjs @@ -6,7 +6,10 @@ import { newApiUserSelfMetersForTest } from "../../packages/core/src/providers/account-service.ts"; import { detectedProviderFromHeaders, newApiKeyUsageAccountConfig, newApiUserSelfConnectorConfig } from "../../packages/core/src/providers/new-api.ts"; -import { isProviderProtocolEndpointSupportedForProbe } from "../../packages/core/src/providers/probe.ts"; +import { + checkGatewayProviderConnectivity, + isProviderProtocolEndpointSupportedForProbe +} from "../../packages/core/src/providers/probe.ts"; test("protocol support probe does not treat Gemini auth errors as every protocol", () => { const message = "HTTP 403: API key not valid. Please pass a valid API key."; @@ -78,6 +81,61 @@ test("protocol support probe still rejects HTTP 400 route misses", () => { ); }); +test("connectivity probe applies provider plugin auth for local agent imports", async (t) => { + const previousFetch = globalThis.fetch; + let called = false; + + globalThis.fetch = async (input, init) => { + called = true; + const url = new URL(String(input)); + const headers = new Headers(init?.headers); + + assert.equal(url.origin, "http://127.0.0.1:49123"); + assert.equal(url.pathname, "/v1/chat/completions"); + assert.equal(url.searchParams.get("key"), "plugin-query-key"); + assert.equal(headers.get("authorization"), "Bearer plugin-token"); + assert.equal(headers.get("x-local-agent"), "opencode"); + + return new Response(JSON.stringify({ id: "ok" }), { + headers: { "content-type": "application/json" }, + status: 200 + }); + }; + t.after(() => { + globalThis.fetch = previousFetch; + }); + + const report = await checkGatewayProviderConnectivity({ + apiKey: "ccr-local-agent-login", + candidates: [{ + baseUrl: "http://127.0.0.1:49123/v1", + name: "Local Agent", + protocols: ["openai_chat_completions"], + source: "preset" + }], + forceRefresh: true, + models: ["local-model"], + providerPlugins: [{ + auth: { + headers: { + authorization: "Bearer plugin-token", + "x-local-agent": "opencode" + }, + query: { + key: "plugin-query-key" + }, + removeHeaders: ["authorization"] + } + }], + protocols: ["openai_chat_completions"] + }); + + assert.equal(called, true); + assert.equal(report.passed.length, 1); + assert.equal(report.failed.length, 0); + assert.equal(report.results[0]?.supported, true); +}); + test("New API response headers enable key quota account connector", () => { assert.equal(detectedProviderFromHeaders({ "X-New-Api-Version": "0.8.0" }), "new-api"); assert.equal(detectedProviderFromHeaders({ "x-oneapi-request-id": "req-1" }), "new-api"); diff --git a/tests/main/provider-url.test.mjs b/tests/main/provider-url.test.mjs index e9bf3744..58c9126e 100644 --- a/tests/main/provider-url.test.mjs +++ b/tests/main/provider-url.test.mjs @@ -39,6 +39,18 @@ test("provider URL parsing preserves versioned Vertex bypass bases for Gemini", ); }); +test("provider URL parsing preserves nested versioned Gemini bases", () => { + const parsed = parseProviderBaseUrl("https://opencode.ai/zen/v1/models/gemini-3-flash:generateContent"); + + assert.equal(parsed.normalizedInputBaseUrl, "https://opencode.ai/zen/v1"); + assert.equal(parsed.rootBaseUrl, "https://opencode.ai/zen"); + assert.equal(parsed.geminiBaseUrl, "https://opencode.ai/zen/v1"); + assert.equal( + normalizeProviderBaseUrl("https://opencode.ai/zen/v1", "gemini_generate_content"), + "https://opencode.ai/zen/v1" + ); +}); + test("provider URL parsing handles Gemini Interactions endpoint variants", () => { const parsed = parseProviderBaseUrl("localhost:8787/v1/interactions/interaction-123/cancel"); From 96467abe5f68a58159179da652653074148a74d4 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 17:01:13 +0800 Subject: [PATCH 16/38] Add Claude Code OAuth beta header normalization --- packages/core/src/gateway/service.ts | 133 +++++++++++++++++- tests/main/gateway-claude-code-oauth.test.mjs | 86 +++++++++++ 2 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 tests/main/gateway-claude-code-oauth.test.mjs diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 7ba0a2d2..a9db98e8 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -264,6 +264,8 @@ class UpstreamRequestError extends Error { } const requireFromHere = createRequire(__filename); +const claudeCodeOauthBetaHeader = "anthropic-beta"; +const claudeCodeOauthRequiredBeta = "oauth-2025-04-20"; const coreGatewayAuthHeader = "x-ccr-core-auth"; const coreGatewayAuthTokenEnv = "CCR_CORE_GATEWAY_AUTH_TOKEN"; const clientClosedRequestStatusCode = 499; @@ -1185,10 +1187,11 @@ async function writeCoreGatewayConfig( assertLoopbackCoreHost(config.gateway.coreHost); mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); - const providerPlugins = await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults([ + const configuredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins([ ...(config.providerPlugins ?? []).filter(providerPluginEnabled), ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) - ])); + ]); + const providerPlugins = await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins)); const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ ...(config.virtualModelProfiles ?? []), @@ -1489,6 +1492,58 @@ function isLocalCodexOauthProviderPlugin(value: unknown): value is Record { + if (!isRecord(value)) { + return false; + } + const key = stringValue(value.key)?.toLowerCase() ?? ""; + return key.startsWith("ccr-local-agent-") && key.includes("claude-code-oauth"); +} + +export function normalizeClaudeCodeOauthProviderPlugins(providerPlugins: unknown[]): unknown[] { + return providerPlugins.map((plugin) => { + if (!isLocalClaudeCodeOauthProviderPlugin(plugin)) { + return plugin; + } + + const auth = isRecord(plugin.auth) ? plugin.auth : {}; + const headers = isRecord(auth.headers) ? auth.headers : {}; + const configuredBeta = Object.entries(headers) + .find(([name]) => name.trim().toLowerCase() === claudeCodeOauthBetaHeader)?.[1]; + const defaultBeta = mergeAnthropicBetaValues( + configuredAnthropicBetaDefault(configuredBeta), + claudeCodeOauthRequiredBeta + ); + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).filter(([name]) => name.trim().toLowerCase() !== claudeCodeOauthBetaHeader) + ); + + return { + ...plugin, + auth: { + ...auth, + headers: { + ...normalizedHeaders, + [claudeCodeOauthBetaHeader]: { + default: defaultBeta, + from: `request.headers.${claudeCodeOauthBetaHeader}` + } + } + } + }; + }); +} + +function configuredAnthropicBetaDefault(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (!isRecord(value)) { + return undefined; + } + return stringValue(value.default); +} + function isLocalGrokOauthProviderPlugin(value: unknown): value is Record { if (!isRecord(value)) { return false; @@ -5839,6 +5894,8 @@ function prepareUpstreamCredentialAttempt(input: { }; } + const attemptHeaders = withClaudeCodeOauthBetaHeader(input.headers, input.config, target); + const credentials = activeProviderCredentials(target.provider); if (credentials.length === 0) { const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); @@ -5846,8 +5903,8 @@ function prepareUpstreamCredentialAttempt(input: { ...input.attempt, body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), headers: preserveModelSelector - ? clearTargetProviderHeaders(input.headers) - : targetProviderFallbackHeaders(input.headers, target.provider, target.protocol) + ? clearTargetProviderHeaders(attemptHeaders) + : targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol) }; } @@ -5859,13 +5916,13 @@ function prepareUpstreamCredentialAttempt(input: { ...input.attempt, body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), headers: preserveModelSelector - ? clearTargetProviderHeaders(input.headers) - : targetProviderFallbackHeaders(input.headers, target.provider, target.protocol) + ? clearTargetProviderHeaders(attemptHeaders) + : targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol) }; } const headers: Record = { - ...input.headers, + ...attemptHeaders, "x-target-providers": selection.credentials.map((candidate) => candidate.internalName).join(","), "x-ccr-logical-provider": providerRuntimeId(target.provider), "x-ccr-provider-credential-chain": selection.credentials.map((candidate) => candidate.credentialId).join(",") @@ -5886,6 +5943,68 @@ function prepareUpstreamCredentialAttempt(input: { }; } +function withClaudeCodeOauthBetaHeader( + headers: Record, + config: AppConfig, + target: ProviderCredentialRoutingTarget +): Record { + if ( + target.protocol !== "anthropic_messages" || + !claudeCodeOauthPluginMatchesTarget(config, target.provider, target.protocol) + ) { + return headers; + } + + const existingEntry = Object.entries(headers) + .find(([name]) => name.trim().toLowerCase() === claudeCodeOauthBetaHeader); + const merged = mergeAnthropicBetaValues(existingEntry?.[1], claudeCodeOauthRequiredBeta); + if (existingEntry?.[0] === claudeCodeOauthBetaHeader && existingEntry[1] === merged) { + return headers; + } + + const next = Object.fromEntries( + Object.entries(headers).filter(([name]) => name.trim().toLowerCase() !== claudeCodeOauthBetaHeader) + ); + next[claudeCodeOauthBetaHeader] = merged; + return next; +} + +function claudeCodeOauthPluginMatchesTarget( + config: AppConfig, + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol +): boolean { + const targetNames = new Set([ + provider.name, + providerRuntimeId(provider), + providerCapabilityInternalName(provider, protocol) + ].map((name) => name.trim().toLowerCase())); + return (config.providerPlugins ?? []).some((plugin) => { + if (!isLocalClaudeCodeOauthProviderPlugin(plugin)) { + return false; + } + const providerName = stringValue(plugin.providerName)?.toLowerCase(); + return Boolean(providerName && targetNames.has(providerName)); + }); +} + +function mergeAnthropicBetaValues(...values: Array): string { + const seen = new Set(); + const merged: string[] = []; + for (const value of values) { + for (const token of value?.split(",") ?? []) { + const normalized = token.trim(); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + seen.add(key); + merged.push(normalized); + } + } + return merged.join(","); +} + function targetProviderFallbackHeaders( headers: Record, provider: GatewayProviderConfig, diff --git a/tests/main/gateway-claude-code-oauth.test.mjs b/tests/main/gateway-claude-code-oauth.test.mjs new file mode 100644 index 00000000..542ec713 --- /dev/null +++ b/tests/main/gateway-claude-code-oauth.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + normalizeClaudeCodeOauthProviderPlugins, + prepareGatewayUpstreamAttemptForTest +} from "../../packages/core/src/gateway/service.ts"; + +test("issue 1528 normalizes Claude Code OAuth auth to preserve the client anthropic-beta header", () => { + const [plugin] = normalizeClaudeCodeOauthProviderPlugins([ + { + auth: { + headers: { + authorization: "Bearer oauth-token", + "anthropic-beta": "oauth-2025-04-20" + }, + removeHeaders: ["x-api-key"], + strict: true + }, + key: "ccr-local-agent-claude-code-api-claude-code-oauth", + providerName: "Claude Code API" + } + ]); + + assert.equal(plugin.auth.headers.authorization, "Bearer oauth-token"); + assert.deepEqual(plugin.auth.headers["anthropic-beta"], { + default: "oauth-2025-04-20", + from: "request.headers.anthropic-beta" + }); + assert.equal(plugin.auth.strict, true); +}); + +test("issue 1528 merges Claude Code OAuth beta with client beta tokens only for the routed provider", () => { + const claudeCodeProvider = { + api_base_url: "https://api.anthropic.com", + id: "provider-claude-code-api-test", + models: ["claude-sonnet-5"], + name: "Claude Code API", + type: "anthropic_messages" + }; + const otherProvider = { + api_base_url: "https://anthropic.example/v1", + id: "provider-other-anthropic-test", + models: ["claude-other"], + name: "Other Anthropic", + type: "anthropic_messages" + }; + const config = { + Providers: [claudeCodeProvider, otherProvider], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {}, + providerPlugins: [ + { + auth: { + headers: { + authorization: "Bearer oauth-token", + "anthropic-beta": "oauth-2025-04-20" + }, + strict: true + }, + key: "ccr-local-agent-claude-code-api-claude-code-oauth", + providerName: claudeCodeProvider.name + } + ] + }; + + const claudeCodeAttempt = prepareGatewayUpstreamAttemptForTest({ + body: { messages: [{ content: "hi", role: "user" }], model: "Claude Code API/claude-sonnet-5" }, + config, + headers: { "anthropic-beta": "context-management-2025-06-27,effort-2025-11-24" }, + method: "POST", + path: "/v1/messages" + }); + const otherAttempt = prepareGatewayUpstreamAttemptForTest({ + body: { messages: [{ content: "hi", role: "user" }], model: "Other Anthropic/claude-other" }, + config, + headers: { "anthropic-beta": "context-management-2025-06-27" }, + method: "POST", + path: "/v1/messages" + }); + + assert.equal( + claudeCodeAttempt.headers["anthropic-beta"], + "context-management-2025-06-27,effort-2025-11-24,oauth-2025-04-20" + ); + assert.equal(otherAttempt.headers["anthropic-beta"], "context-management-2025-06-27"); +}); From 6f3df2f3315c80a2c6ffd8eff9e34ee291241d24 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 17:39:31 +0800 Subject: [PATCH 17/38] Rework router internals and refresh provider adapters --- packages/core/src/contracts/app.ts | 1 + .../gateway/application/gateway-service.ts | 265 + .../src/gateway/auth/api-key-authorizer.ts | 132 + .../src/gateway/claude-code-router-plugin.ts | 33 +- .../gateway/core-runtime/config-compiler.ts | 413 + .../src/gateway/core-runtime/config-writer.ts | 45 + .../src/gateway/core-runtime/supervisor.ts | 525 + .../gateway/features/codex-patch-bridge.ts | 460 + .../src/gateway/features/cursor-compat.ts | 205 + .../features/hosted-web-search/discovery.ts | 526 + .../features/hosted-web-search/evidence.ts | 607 ++ .../features/hosted-web-search/index.ts | 4 + .../hosted-web-search/request-transform.ts | 456 + .../hosted-web-search/response-transform.ts | 1246 +++ .../gateway/features/hosted-web-search/sse.ts | 78 + .../src/gateway/features/model-discovery.ts | 511 + packages/core/src/gateway/http/body.ts | 16 + packages/core/src/gateway/http/io.ts | 223 + .../core/src/gateway/http/request-handler.ts | 168 + packages/core/src/gateway/internal/clock.ts | 3 + .../core/src/gateway/internal/collections.ts | 17 + packages/core/src/gateway/internal/shared.ts | 313 + packages/core/src/gateway/internal/value.ts | 23 + .../core/src/gateway/limits/window-limiter.ts | 112 + packages/core/src/gateway/request/pipeline.ts | 475 + packages/core/src/gateway/service.ts | 8602 +---------------- .../core/src/gateway/upstream/executor.ts | 892 ++ .../core/src/gateway/upstream/retry-policy.ts | 53 + packages/core/src/mcp/fusion-config.ts | 733 ++ .../core/src/observability/raw-trace-sync.ts | 281 + .../core/src/providers/credential-pool.ts | 112 + packages/core/src/providers/oauth-plugin.ts | 22 + .../core/src/providers/runtime-topology.ts | 487 + packages/core/src/routing/model-resolution.ts | 16 + .../core/src/routing/protocol-endpoints.ts | 32 + packages/core/src/web/management-server.ts | 3 + packages/electron/src/main/ipc.ts | 3 + packages/ui/src/pages/home/App.tsx | 47 +- .../pages/home/components/dialog-stack.tsx | 5 +- .../ui/src/pages/home/components/index.ts | 2 +- .../ui/src/pages/home/components/profiles.tsx | 59 +- packages/ui/src/pages/home/shared/i18n.tsx | 2 + packages/ui/src/pages/home/shared/profiles.ts | 8 + .../gateway-service-architecture.test.mjs | 55 + tests/main/router-builtins.test.mjs | 110 +- tests/renderer/profiles.test.tsx | 51 + 46 files changed, 9816 insertions(+), 8616 deletions(-) create mode 100644 packages/core/src/gateway/application/gateway-service.ts create mode 100644 packages/core/src/gateway/auth/api-key-authorizer.ts create mode 100644 packages/core/src/gateway/core-runtime/config-compiler.ts create mode 100644 packages/core/src/gateway/core-runtime/config-writer.ts create mode 100644 packages/core/src/gateway/core-runtime/supervisor.ts create mode 100644 packages/core/src/gateway/features/codex-patch-bridge.ts create mode 100644 packages/core/src/gateway/features/cursor-compat.ts create mode 100644 packages/core/src/gateway/features/hosted-web-search/discovery.ts create mode 100644 packages/core/src/gateway/features/hosted-web-search/evidence.ts create mode 100644 packages/core/src/gateway/features/hosted-web-search/index.ts create mode 100644 packages/core/src/gateway/features/hosted-web-search/request-transform.ts create mode 100644 packages/core/src/gateway/features/hosted-web-search/response-transform.ts create mode 100644 packages/core/src/gateway/features/hosted-web-search/sse.ts create mode 100644 packages/core/src/gateway/features/model-discovery.ts create mode 100644 packages/core/src/gateway/http/body.ts create mode 100644 packages/core/src/gateway/http/io.ts create mode 100644 packages/core/src/gateway/http/request-handler.ts create mode 100644 packages/core/src/gateway/internal/clock.ts create mode 100644 packages/core/src/gateway/internal/collections.ts create mode 100644 packages/core/src/gateway/internal/shared.ts create mode 100644 packages/core/src/gateway/internal/value.ts create mode 100644 packages/core/src/gateway/limits/window-limiter.ts create mode 100644 packages/core/src/gateway/request/pipeline.ts create mode 100644 packages/core/src/gateway/upstream/executor.ts create mode 100644 packages/core/src/gateway/upstream/retry-policy.ts create mode 100644 packages/core/src/mcp/fusion-config.ts create mode 100644 packages/core/src/observability/raw-trace-sync.ts create mode 100644 packages/core/src/providers/credential-pool.ts create mode 100644 packages/core/src/providers/oauth-plugin.ts create mode 100644 packages/core/src/providers/runtime-topology.ts create mode 100644 packages/core/src/routing/model-resolution.ts create mode 100644 packages/core/src/routing/protocol-endpoints.ts create mode 100644 tests/main/gateway-service-architecture.test.mjs create mode 100644 tests/renderer/profiles.test.tsx diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index c6db75c2..651fd5fb 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1,6 +1,7 @@ export type AppInfo = { appConfigDbFile: string; apiKeysDbFile: string; + chatgptAppPath?: string; configDir: string; configFile: string; dataDir: string; diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts new file mode 100644 index 00000000..d96f814c --- /dev/null +++ b/packages/core/src/gateway/application/gateway-service.ts @@ -0,0 +1,265 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { ApiKeyConfig, AppConfig, GatewayStatus } from "@ccr/core/contracts/app"; +import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels } from "@ccr/core/contracts/app"; +import { backendService } from "@ccr/core/plugins/backend-service"; +import { getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch"; +import { pluginService } from "@ccr/core/plugins/service"; +import { proxyService } from "@ccr/core/proxy/service"; +import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; +import { writeCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-writer"; +import { closeServer, formatError } from "@ccr/core/gateway/http/io"; +import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync"; +import { assertLoopbackCoreHost, endpoint, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor"; +import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared"; +import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline"; +import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler"; + + +class GatewayService { + private readonly requestHandler = new GatewayHttpRequestHandler({ + getBrowserAutomationMcpIntegration: () => this.browserAutomationMcpIntegration, + getConfig: () => this.config, + getPlugin: () => this.plugin, + getStatus: () => ({ + coreEndpoint: this.status.coreEndpoint, + coreManagedExternally: this.status.coreManagedExternally, + endpoint: this.status.endpoint, + state: this.status.state + }), + handleRawTraceSync: (request, response) => this.rawTraceSynchronizer.handle(request, response), + proxyRequest: (request, response, path, apiKey) => this.proxyRequest(request, response, path, apiKey) + }); + + private readonly requestPipeline = new GatewayRequestPipeline({ + getBrowserWebSearchMcpIntegration: () => this.browserWebSearchMcpIntegration, + getConfig: () => this.config, + getCoreAuthToken: () => this.coreAuthToken, + getPlugin: () => this.plugin, + getStatus: () => ({ coreEndpoint: this.status.coreEndpoint, endpoint: this.status.endpoint }), + takePendingRawTraceUpdate: (requestId) => this.rawTraceSynchronizer.take(requestId) + }); + + private browserAutomationMcpIntegration?: BrowserAutomationMcpIntegration; + private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration; + private child?: ChildProcess; + private config?: AppConfig; + private coreAuthToken = ""; + private plugin?: ClaudeCodeRouterPlugin; + private readonly rawTraceSynchronizer = new RawTraceSynchronizer(); + private server?: Server; + private status: GatewayStatus = { + coreEndpoint: "", + endpoint: "", + generatedConfigFile: "", + networkEndpoints: [], + state: "stopped" + }; + + setBrowserWebSearchMcpIntegration(integration: BrowserWebSearchMcpIntegration): void { + this.browserWebSearchMcpIntegration = integration; + } + + setBrowserAutomationMcpIntegration(integration: BrowserAutomationMcpIntegration): void { + this.browserAutomationMcpIntegration = integration; + } + + async start(config: AppConfig): Promise { + const coreHostError = loopbackCoreHostError(config.gateway.coreHost); + if (coreHostError) { + this.status = { + ...this.getStatus(), + lastError: coreHostError, + state: "error" + }; + return this.status; + } + await this.stop(); + this.config = config; + this.coreAuthToken = generateCoreGatewayAuthToken(); + this.plugin = new ClaudeCodeRouterPlugin(config); + this.status = { + coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), + endpoint: endpoint(config.gateway.host, config.gateway.port), + generatedConfigFile: config.gateway.generatedConfigFile, + networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port), + state: "starting" + }; + + try { + await pluginService.start(config); + const shouldRunServer = shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes(); + const shouldRunGateway = shouldRunGatewayRuntime(config); + if (shouldRunGateway && !hasAvailableGatewayModels(config)) { + throw new Error(NO_AVAILABLE_GATEWAY_MODELS_MESSAGE); + } + if (!shouldRunServer) { + await pluginService.stop(); + await backendService.stopAll(); + this.coreAuthToken = ""; + this.status = { + ...this.status, + state: "stopped" + }; + return this.status; + } + + await this.listen(config); + if (this.server) { + const proxyStatus = await proxyService.attach(config, this.server); + if (proxyStatus.state === "error" && !config.gateway.enabled) { + throw new Error(proxyStatus.lastError || "Proxy service failed to start."); + } + } + + if (shouldRunGateway) { + await writeCoreGatewayConfig(config, this.rawTraceSynchronizer.token, this.coreAuthToken, 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}`); + } + await proxyService.refreshUpstreamProxyFromCurrentSystem(); + const runtimeId = randomUUID(); + const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config); + this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, this.coreAuthToken); + const managedChild = this.child; + writeManagedCoreGatewayMarker(config, this.child, runtimeId); + this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`)); + this.child.stderr?.on("data", (chunk) => console.warn(`[gateway] ${chunk.toString().trimEnd()}`)); + this.child.on("exit", (code, signal) => { + void this.handleCoreGatewayExit(managedChild, code, signal); + }); + } + + this.status = { + ...this.status, + coreManagedExternally: this.status.coreManagedExternally, + lastStartedAt: new Date().toISOString(), + pid: this.child?.pid, + state: "running" + }; + return this.status; + } catch (error) { + await this.stop(); + this.status = { + ...this.status, + lastError: formatError(error), + state: "error" + }; + return this.status; + } + } + + async stop(options: GatewayStopOptions = {}): Promise { + const child = this.child; + const config = this.config; + this.child = undefined; + this.coreAuthToken = ""; + if (child && !child.killed) { + child.kill(); + } + removeManagedCoreGatewayMarker(config); + + const server = this.server; + this.server = undefined; + if (server) { + await closeServer(server); + } + + 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)}`); + }); + await this.browserAutomationMcpIntegration?.stopBrowserAutomationMcpServer().catch((error) => { + console.warn(`[gateway] Failed to stop browser automation MCP: ${formatError(error)}`); + }); + + this.status = { + ...this.status, + coreManagedExternally: undefined, + pid: undefined, + state: "stopped" + }; + return this.getStatus(); + } + + getStatus(): GatewayStatus { + return { + ...this.status, + networkEndpoints: this.config + ? gatewayNetworkEndpoints(this.config.gateway.host, this.config.gateway.port) + : this.status.networkEndpoints + }; + } + + updateConfig(config: AppConfig): void { + assertLoopbackCoreHost(config.gateway.coreHost); + this.config = config; + this.plugin = new ClaudeCodeRouterPlugin(config); + proxyService.updateConfig(config); + this.status = { + ...this.status, + coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), + endpoint: endpoint(config.gateway.host, config.gateway.port), + generatedConfigFile: config.gateway.generatedConfigFile, + networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port) + }; + } + + private async listen(config: AppConfig): Promise { + this.server = createServer((request, response) => { + if (proxyService.shouldHandleHttpRequest(request)) { + void proxyService.handleHttpRequest(request, response).catch((error) => { + response.writeHead(502, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: formatError(error) } })); + }); + return; + } + + void this.handleRequest(request, response).catch((error) => { + response.writeHead(502, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: formatError(error) } })); + }); + }); + + await new Promise((resolve, reject) => { + this.server?.once("error", reject); + this.server?.listen(config.gateway.port, config.gateway.host, () => { + this.server?.off("error", reject); + resolve(); + }); + }); + } + + private async handleCoreGatewayExit(child: ChildProcess, code: number | null, signal: NodeJS.Signals | null): Promise { + if (this.child !== child || this.status.state === "stopped") { + return; + } + removeManagedCoreGatewayMarker(this.config); + this.status = { + ...this.status, + coreManagedExternally: undefined, + lastError: `Core gateway exited with ${signal ?? code ?? "unknown status"}`, + pid: undefined, + state: "error" + }; + } + + private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + return this.requestHandler.handleRequest(request, response); + } + + private async proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig): Promise { + return this.requestPipeline.proxyRequest(request, response, path, apiKey); + } + +} + + +export const gatewayService = new GatewayService(); diff --git a/packages/core/src/gateway/auth/api-key-authorizer.ts b/packages/core/src/gateway/auth/api-key-authorizer.ts new file mode 100644 index 00000000..8fc67105 --- /dev/null +++ b/packages/core/src/gateway/auth/api-key-authorizer.ts @@ -0,0 +1,132 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; +import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store"; +import { formatError, readAuthToken, readRemoteControlQueryAuthToken, sendJson } from "@ccr/core/gateway/http/io"; +import { estimateLimitUsage, limitRules, readWindowCounter } from "@ccr/core/gateway/limits/window-limiter"; +import type { ApiKeyAuthorizationResult, ApiKeyLimitRule, ApiKeyLimitUsage } from "@ccr/core/gateway/internal/shared"; + +const persistedApiKeyCacheTtlMs = 1000; +let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined; + +export async function authorize( + request: IncomingMessage, + response: ServerResponse, + config: AppConfig +): Promise { + let apiKeys = await configuredApiKeys(config); + if (apiKeys.length === 0) { + sendJson(response, 403, { + error: { + message: "CCR API key is not initialized. Save a gateway API key or restart CCR to generate one." + } + }); + return { ok: false }; + } + + const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request); + let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; + if (!apiKey && token) { + apiKeys = await configuredApiKeys(config, { refresh: true }); + apiKey = apiKeys.find((item) => item.key === token); + } + if (apiKey) { + if (isApiKeyExpired(apiKey)) { + sendJson(response, 401, { error: { message: "API key is expired." } }); + return { ok: false }; + } + return { ok: true, apiKey }; + } + + sendJson(response, 401, { error: { message: token ? "Invalid API key." : "API key is missing." } }); + return { ok: false }; +} + +export function reserveApiKeyLimits( + apiKey: ApiKeyConfig | undefined, + request: IncomingMessage, + response: ServerResponse, + requestBody: Buffer +): boolean { + if (!apiKey?.limits) return true; + + const usage = estimateLimitUsage(request.method ?? "GET", requestBody); + const rules = apiKeyLimitRules(apiKey, usage); + const now = Date.now(); + const checks = rules.map((rule) => { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + return { + counterKey: ["api-key", apiKey.id, rule.name, rule.metric, rule.windowMs, windowStart].join("|"), + rule, + windowStart + }; + }); + + for (const check of checks) { + const counter = readWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now); + if (counter.value + check.rule.requested > check.rule.limit) { + sendJson(response, 429, { + error: { + code: "rate_limit_exceeded", + message: `API key ${check.rule.name} limit exceeded.`, + details: { + limit: check.rule.limit, + limit_name: check.rule.name, + metric: check.rule.metric, + requested: check.rule.requested, + used: counter.value, + window_ms: check.rule.windowMs + } + } + }); + return false; + } + } + + for (const check of checks) { + readWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now).value += check.rule.requested; + } + return true; +} + +async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise { + const persistedApiKeys = await loadPersistedApiKeysCached(options); + const values = [ + ...persistedApiKeys, + ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []), + ...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : []) + ]; + const seen = new Set(); + const result: ApiKeyConfig[] = []; + for (const value of values) { + const key = value?.key?.trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + result.push({ ...value, key }); + } + return result; +} + +async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise { + const now = Date.now(); + if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) { + return persistedApiKeyCache.values; + } + try { + const values = await loadPersistedApiKeys(); + persistedApiKeyCache = { loadedAt: now, values }; + return values; + } catch (error) { + console.warn(`[gateway] Failed to load persisted API keys: ${formatError(error)}`); + return []; + } +} + +function isApiKeyExpired(apiKey: ApiKeyConfig): boolean { + if (!apiKey.expiresAt) return false; + const expiresAt = Date.parse(apiKey.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= Date.now(); +} + +function apiKeyLimitRules(apiKey: ApiKeyConfig, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { + return limitRules(apiKey.limits, usage); +} diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts index 63d97088..b4214ceb 100644 --- a/packages/core/src/gateway/claude-code-router-plugin.ts +++ b/packages/core/src/gateway/claude-code-router-plugin.ts @@ -308,7 +308,7 @@ function resolveBuiltInAgentRouteDecision( if (!builtInAgentRouteMatches(request, config, agent)) { continue; } - const target = modelRegistry.resolve(resolveBuiltInAgentRouteTarget(config, agent)); + const target = modelRegistry.resolve(resolveBuiltInAgentRouteTarget(request, config, agent)); if (!target) { continue; } @@ -337,22 +337,43 @@ function builtInAgentRouteMatches( if (config.Router.builtInRules?.[agent]?.enabled === false) { return false; } - if (!resolveBuiltInAgentProfile(config, agent)) { + if (!resolveBuiltInAgentProfile(request, config, agent)) { return false; } const userAgent = readRequestHeader(request.headers, "user-agent")?.toLowerCase() ?? ""; return userAgent.includes(builtInAgentUserAgentNeedle(agent)); } -function resolveBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltInAgentRuleId) { +function resolveBuiltInAgentProfile( + request: MutableRequestLike, + config: AppConfig, + agent: RouterBuiltInAgentRuleId +) { if (config.profile.enabled === false) { return undefined; } - return config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent); + const authenticatedApiKeyId = readRequestHeader(request.headers, "x-auth-api-key-id")?.trim(); + if (!authenticatedApiKeyId) { + return undefined; + } + return config.profile.profiles.find((profile) => + profile.enabled && + profile.agent === agent && + profileApiKeyId(profile.id || profile.name || profile.agent) === authenticatedApiKeyId + ); } -function resolveBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string | undefined { - return normalizeRouteSelector(resolveBuiltInAgentProfile(config, agent)?.model); +function resolveBuiltInAgentRouteTarget( + request: MutableRequestLike, + config: AppConfig, + agent: RouterBuiltInAgentRuleId +): string | undefined { + return normalizeRouteSelector(resolveBuiltInAgentProfile(request, config, agent)?.model); +} + +function profileApiKeyId(value: string): string { + const profileId = value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return `profile:${profileId || "profile"}`; } function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string { diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts new file mode 100644 index 00000000..90b73bd7 --- /dev/null +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -0,0 +1,413 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol } from "@ccr/core/contracts/app"; +import { codexDefaultBaseUrl, readCodexAuth, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/service"; +import { grokAccessTokenExpired } from "@ccr/core/agents/local-providers/grok"; +import { pluginService } from "@ccr/core/plugins/service"; +import { normalizeRouteSelector, providerRuntimeId } from "@ccr/core/routing/model-registry"; +import { isRecord, stringValue } from "@ccr/core/gateway/internal/value"; +import { fusionBuiltinToolArtifacts, fusionToolFallbackMcpServer, normalizeFusionWebSearchProfileToolName, toolHubMcpServer, withCodexCompatibleVirtualModelProfiles, withFusionVirtualModelAliases, withFusionWebSearchToolInstructions } from "@ccr/core/mcp/fusion-config"; +import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery"; +import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology"; +import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync"; +import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor"; +import { claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared"; +import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared"; +import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; +import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin"; +import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution"; + + +export async function compileCoreGatewayConfig( + config: AppConfig, + rawTraceSyncToken: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise> { + const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); + const configuredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins([ + ...(config.providerPlugins ?? []).filter(providerPluginEnabled), + ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) + ]); + const providerPlugins = await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins)); + const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); + const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ + ...(config.virtualModelProfiles ?? []), + ...pluginService.getVirtualModelProfiles() + ])), config); + const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); + const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + const providers = [ + ...config.Providers + .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) + .filter((provider): provider is CoreGatewayProvider => Boolean(provider)), + ...builtinToolArtifacts.providers + ]; + const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {}; + const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : []; + const externalMcpServers = [ + ...pluginMcpServers, + ...(config.agent?.mcpServers ?? []), + ...(config.toolHub?.mcpServers ?? []) + ]; + const toolHubServer = toolHubMcpServer(config, externalMcpServers); + const mcpServers = [ + ...builtinToolArtifacts.mcpServers, + ...(toolHubServer ? [toolHubServer] : externalMcpServers) + ]; + const fallbackMcpServer = fusionToolFallbackMcpServer(virtualModelProfiles, [ + ...builtinToolArtifacts.mcpServers, + ...externalMcpServers + ]); + if (fallbackMcpServer) { + mcpServers.push(fallbackMcpServer); + } + return { + ...pluginCoreGatewayConfig, + auth: { + enabled: true, + mode: "static_api_key", + required: true, + staticApiKeys: { + keyBearerOnly: false, + keyEnv: coreGatewayAuthTokenEnv, + keyHeader: coreGatewayAuthHeader + } + }, + billing: { + enabled: true + }, + billingQueue: { + enabled: false + }, + billingWebhook: { + enabled: false + }, + bodyLimitBytes: 50 * 1024 * 1024, + host: config.gateway.coreHost, + mcpGateway: { + enabled: false + }, + port: config.gateway.corePort, + upstreamTimeoutMs: Number(config.API_TIMEOUT_MS) || 0, + agent: { + ...pluginAgentConfig, + mcpServers + }, + rawTrace: buildRawTraceConfig(config, rawTraceSyncToken), + providerPlugins, + providers, + virtualModelProfiles + }; +} + + +function providerPluginEnabled(plugin: unknown): boolean { + return !isRecord(plugin) || plugin.enabled !== false; +} + + +export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] { + return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config)); +} + + +function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppConfig): unknown { + if (!isRecord(profile)) { + return profile; + } + + let nextProfile: Record | undefined; + const baseModel = isRecord(profile.baseModel) ? profile.baseModel : undefined; + const fixedModel = stringValue(baseModel?.fixedModel); + const rewrittenFixedModel = fixedModel + ? rewriteModelSelectorForCoreGatewayProfile(fixedModel, config, "anthropic_messages") + : undefined; + if (baseModel && rewrittenFixedModel && rewrittenFixedModel !== fixedModel) { + nextProfile = { + ...profile, + baseModel: { + ...baseModel, + fixedModel: rewrittenFixedModel + } + }; + } + + const sourceProfile = nextProfile ?? profile; + const metadata = isRecord(sourceProfile.metadata) ? sourceProfile.metadata : undefined; + const fusionVision = isRecord(metadata?.fusionVision) ? metadata.fusionVision : undefined; + const visionBaseUrl = stringValue(fusionVision?.baseUrl); + const visionSelectorField = stringValue(fusionVision?.modelSelector) ? "modelSelector" : stringValue(fusionVision?.model) ? "model" : undefined; + const visionSelector = visionSelectorField ? stringValue(fusionVision?.[visionSelectorField]) : undefined; + const rewrittenVisionSelector = fusionVision && !visionBaseUrl && visionSelector + ? rewriteModelSelectorForCoreGatewayProfile(visionSelector, config, "openai_chat_completions") + : undefined; + + if (metadata && fusionVision && visionSelectorField && rewrittenVisionSelector && rewrittenVisionSelector !== visionSelector) { + nextProfile = { + ...sourceProfile, + metadata: { + ...metadata, + fusionVision: { + ...fusionVision, + [visionSelectorField]: rewrittenVisionSelector + } + } + }; + } + + const profileAfterVision = nextProfile ?? profile; + const profileAfterWebSearchToolName = normalizeFusionWebSearchProfileToolName(profileAfterVision) ?? profileAfterVision; + return withFusionWebSearchToolInstructions(profileAfterWebSearchToolName) ?? profileAfterWebSearchToolName; +} + + +function rewriteModelSelectorForCoreGatewayProfile( + model: string, + config: AppConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized) { + return undefined; + } + + const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; + const selector = + resolveConfiguredProviderModelSelector(publicModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(publicModel, config); + if (!selector) { + return publicModel; + } + + const providerName = coreGatewayProviderSelectorName(selector.provider, clientProtocol); + return providerName ? `${providerName}/${selector.model}` : publicModel; +} + + +function coreGatewayProviderSelectorName( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const capability = providerCapabilityForClientProtocol(provider, clientProtocol); + const explicitCapabilities = normalizedProviderCapabilities(provider); + const protocol = capability?.type ?? (explicitCapabilities.length === 0 ? providerProtocolForClientProtocol(provider, clientProtocol) : undefined); + if (!protocol) { + return undefined; + } + + const credentials = sortProviderCredentialsForConfig(activeProviderCredentials(provider)); + if (credentials.length > 0) { + return providerCredentialInternalName(provider, protocol, credentials[0]); + } + + return capability ? providerCapabilityInternalName(provider, protocol) : providerRuntimeId(provider); +} + + +function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { + const codexAuth = readCodexAuth(); + return providerPlugins.map((plugin) => { + if (!isLocalCodexOauthProviderPlugin(plugin)) { + return plugin; + } + + const codexOauth = plugin.codexOauth; + const nextCodexOauth = { + ...codexOauth, + ...(!hasOwn(codexOauth, "accountId") && !hasOwn(codexOauth, "account_id") && codexAuth?.accountId + ? { accountId: codexAuth.accountId } + : {}) + }; + const nextPlugin: Record = { + ...plugin, + codexOauth: nextCodexOauth, + request: withCodexBackendRequestTransform(plugin.request) + }; + + if (codexAuth?.isFedrampAccount) { + const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; + const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; + nextPlugin.auth = { + ...currentAuth, + headers: { + ...currentHeaders, + "X-OpenAI-Fedramp": "true" + } + }; + } + + return nextPlugin; + }); +} + + +async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise { + const grokAuth = await resolveGrokAuth().catch(() => readGrokAuth()); + if (!grokAuth?.accessToken || grokAccessTokenExpired(grokAuth)) { + return providerPlugins; + } + + return providerPlugins.map((plugin) => { + if (!isLocalGrokOauthProviderPlugin(plugin)) { + return plugin; + } + const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; + const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; + return { + ...plugin, + auth: { + ...currentAuth, + headers: { + ...currentHeaders, + authorization: `Bearer ${grokAuth.accessToken}` + } + } + }; + }); +} + + +function codexOauthLocalProviderNames(providerPlugins: unknown[]): Set { + const names = new Set(); + for (const plugin of providerPlugins) { + if (!isLocalCodexOauthProviderPlugin(plugin)) { + continue; + } + addProviderNameVariants(names, stringValue(plugin.providerName)); + } + return names; +} + + +function withCodexOauthProviderBaseUrl( + provider: GatewayProviderConfig, + codexOauthProviderNames: Set +): GatewayProviderConfig { + if (!codexOauthProviderNames.has(provider.name)) { + return provider; + } + + const protocol = + normalizeProviderProtocol(provider.type) ?? + normalizeProviderProtocol(provider.provider) ?? + inferProtocol(provider); + if (protocol !== "openai_responses") { + return provider; + } + + const capabilities = Array.isArray(provider.capabilities) + ? provider.capabilities.map((capability) => { + const capabilityProtocol = normalizeProviderProtocol(capability.type); + if (capabilityProtocol !== "openai_responses") { + return capability; + } + return { + ...capability, + baseUrl: codexDefaultBaseUrl + }; + }) + : provider.capabilities; + + return { + ...provider, + api_base_url: codexDefaultBaseUrl, + baseUrl: codexDefaultBaseUrl, + baseurl: codexDefaultBaseUrl, + capabilities + }; +} + + +function isLocalCodexOauthProviderPlugin(value: unknown): value is Record & { codexOauth: Record } { + if (!isRecord(value) || !isRecord(value.codexOauth)) { + return false; + } + const key = stringValue(value.key)?.toLowerCase() ?? ""; + return key.startsWith("ccr-local-agent-") && key.includes("codex-oauth"); +} + + +export function normalizeClaudeCodeOauthProviderPlugins(providerPlugins: unknown[]): unknown[] { + return providerPlugins.map((plugin) => { + if (!isLocalClaudeCodeOauthProviderPlugin(plugin)) { + return plugin; + } + + const auth = isRecord(plugin.auth) ? plugin.auth : {}; + const headers = isRecord(auth.headers) ? auth.headers : {}; + const configuredBeta = Object.entries(headers) + .find(([name]) => name.trim().toLowerCase() === claudeCodeOauthBetaHeader)?.[1]; + const defaultBeta = mergeAnthropicBetaValues( + configuredAnthropicBetaDefault(configuredBeta), + claudeCodeOauthRequiredBeta + ); + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).filter(([name]) => name.trim().toLowerCase() !== claudeCodeOauthBetaHeader) + ); + + return { + ...plugin, + auth: { + ...auth, + headers: { + ...normalizedHeaders, + [claudeCodeOauthBetaHeader]: { + default: defaultBeta, + from: `request.headers.${claudeCodeOauthBetaHeader}` + } + } + } + }; + }); +} + + +function configuredAnthropicBetaDefault(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (!isRecord(value)) { + return undefined; + } + return stringValue(value.default); +} + + +function isLocalGrokOauthProviderPlugin(value: unknown): value is Record { + if (!isRecord(value)) { + return false; + } + const key = stringValue(value.key)?.toLowerCase() ?? ""; + return key.startsWith("ccr-local-agent-") && key.includes("grok-cli-oauth"); +} + + +function withCodexBackendRequestTransform(request: unknown): Record { + const currentRequest = isRecord(request) ? request : {}; + const bodyRemove = Array.isArray(currentRequest.bodyRemove) + ? currentRequest.bodyRemove.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) + : []; + return { + ...currentRequest, + bodyRemove: uniqueStrings([...bodyRemove, "max_output_tokens"]) + }; +} + + +function addProviderNameVariants(names: Set, providerName: string | undefined): void { + if (!providerName) { + return; + } + names.add(providerName); + const capabilitySeparatorIndex = providerName.indexOf("::"); + if (capabilitySeparatorIndex > 0) { + names.add(providerName.slice(0, capabilitySeparatorIndex)); + } +} + + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} diff --git a/packages/core/src/gateway/core-runtime/config-writer.ts b/packages/core/src/gateway/core-runtime/config-writer.ts new file mode 100644 index 00000000..cfc7310f --- /dev/null +++ b/packages/core/src/gateway/core-runtime/config-writer.ts @@ -0,0 +1,45 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { AppConfig } from "@ccr/core/contracts/app"; +import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler"; +import { assertLoopbackCoreHost } from "@ccr/core/gateway/core-runtime/supervisor"; +import { + privateDirMode, + privateFileMode, + type BrowserWebSearchMcpIntegration +} from "@ccr/core/gateway/internal/shared"; + +export async function writeCoreGatewayConfig( + config: AppConfig, + rawTraceSyncToken: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise { + assertLoopbackCoreHost(config.gateway.coreHost); + mkdirSync(dirname(config.gateway.generatedConfigFile), { + mode: privateDirMode, + recursive: true + }); + + const payload = await compileCoreGatewayConfig( + config, + rawTraceSyncToken, + coreAuthToken, + browserWebSearchMcpIntegration + ); + writePrivateTextFile( + config.gateway.generatedConfigFile, + `${JSON.stringify(payload, null, 2)}\n` + ); +} + +function writePrivateTextFile(file: string, content: string): void { + writeFileSync(file, content, { encoding: "utf8", mode: privateFileMode }); + if (process.platform !== "win32") { + try { + chmodSync(file, privateFileMode); + } catch { + // Best effort for filesystems that do not support chmod. + } + } +} diff --git a/packages/core/src/gateway/core-runtime/supervisor.ts b/packages/core/src/gateway/core-runtime/supervisor.ts new file mode 100644 index 00000000..f2677606 --- /dev/null +++ b/packages/core/src/gateway/core-runtime/supervisor.ts @@ -0,0 +1,525 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { networkInterfaces } from "node:os"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join as pathJoin, resolve as pathResolve } from "node:path"; +import type { AppConfig, GatewayNetworkEndpoint } from "@ccr/core/contracts/app"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; +import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value"; +import { formatError, readHeader } from "@ccr/core/gateway/http/io"; +import { coreGatewayAuthHeader, coreGatewayAuthTokenEnv, gatewayEntryOverrideEnv, gatewayPackageCandidates, gatewayRuntimeMarkerFile, requireFromHere } from "@ccr/core/gateway/internal/shared"; +import type { CoreGatewayHealth, ManagedGatewayRuntimeMarker } from "@ccr/core/gateway/internal/shared"; +import { delay } from "@ccr/core/gateway/internal/clock"; + + +export function spawnGatewayProcess(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): ChildProcess { + const gatewayEntry = resolveGatewayEntry(); + const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile(config, upstreamProxyUrl) : undefined; + const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken); + const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayEntry] : [gatewayEntry]; + return spawn(process.execPath, args, { + cwd: dirname(config.gateway.generatedConfigFile), + env, + stdio: ["ignore", "pipe", "pipe"] + }); +} + + +function resolveGatewayEntry(): string { + const override = process.env[gatewayEntryOverrideEnv]?.trim(); + if (override) { + const entry = pathResolve(override); + if (!existsSync(entry)) { + throw new Error(`${gatewayEntryOverrideEnv} points to a missing gateway entry: ${entry}`); + } + return entry; + } + + const bundledEntry = resolveBundledGatewayEntry(); + if (bundledEntry) { + return bundledEntry; + } + + for (const packageName of gatewayPackageCandidates) { + try { + return requireFromHere.resolve(packageName); + } catch { + // Try the next known package name. + } + } + return requireFromHere.resolve(gatewayPackageCandidates[0]); +} + + +function resolveBundledGatewayEntry(): string | undefined { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return [ + pathJoin(__dirname, "next-ai-gateway.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "next-ai-gateway.js"), + pathJoin(resourcesPath, "app", "dist", "main", "next-ai-gateway.js") + ] + : []) + ].find((candidate) => existsSync(candidate)); +} + + +function resolveUndiciProxyAgentModule(): string { + const bundled = resolveBundledUndiciProxyAgentModule(); + if (bundled) { + return bundled; + } + + try { + return requireFromHere.resolve("undici"); + } catch (error) { + throw new Error(`Unable to resolve undici ProxyAgent module for gateway proxy preload: ${formatError(error)}`); + } +} + + +function resolveBundledUndiciProxyAgentModule(): string | undefined { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return [ + pathJoin(__dirname, "undici-proxy-agent.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "undici-proxy-agent.js"), + pathJoin(resourcesPath, "app", "dist", "main", "undici-proxy-agent.js") + ] + : []) + ].find((candidate) => existsSync(candidate)); +} + + +function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + AUTH_ENABLED: "true", + AUTH_MODE: "static_api_key", + AUTH_REQUIRED: "true", + AUTH_STATIC_API_KEY_BEARER_ONLY: "false", + AUTH_STATIC_API_KEY_ENV: coreGatewayAuthTokenEnv, + AUTH_STATIC_API_KEY_HEADER: coreGatewayAuthHeader, + CCR_GATEWAY_RUNTIME_ID: runtimeId, + [coreGatewayAuthTokenEnv]: coreAuthToken, + ELECTRON_RUN_AS_NODE: "1", + GATEWAY_CONFIG_PATH: config.gateway.generatedConfigFile, + HOST: config.gateway.coreHost, + 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; + } + + env.HTTP_PROXY = upstreamProxyUrl; + env.HTTPS_PROXY = upstreamProxyUrl; + env.ALL_PROXY = upstreamProxyUrl; + env.http_proxy = upstreamProxyUrl; + env.https_proxy = upstreamProxyUrl; + env.all_proxy = upstreamProxyUrl; + env.CCR_UPSTREAM_PROXY_URL = upstreamProxyUrl; + env.CCR_UNDICI_MODULE = resolveUndiciProxyAgentModule(); + return env; +} + + +function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: string): string { + const file = pathJoin(dirname(config.gateway.generatedConfigFile), "gateway-proxy-preload.cjs"); + writeFileSync( + file, + [ + "\"use strict\";", + "const up = process.env.CCR_UPSTREAM_PROXY_URL;", + "const um = process.env.CCR_UNDICI_MODULE;", + "if (up && um) {", + " const { ProxyAgent } = require(um);", + " const agent = new ProxyAgent(up);", + " const realFetch = globalThis.fetch.bind(globalThis);", + " const raw = (process.env.NO_PROXY || process.env.no_proxy || '').toLowerCase();", + " const byp = raw.split(',').map((s) => s.trim()).filter(Boolean);", + " const norm = (h) => h.replace(/^\\[/, '').replace(/\\]$/, '').replace(/\\.$/, '');", + " const isLP = (h) => h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0.0.0.0' || h.startsWith('127.');", + " const shouldBypass = (input) => {", + " let h;", + " try {", + " const u = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input && input.url ? input.url : String(input));", + " h = norm(u.hostname);", + " } catch { return true; }", + " if (!h) return false;", + " if (isLP(h)) return true;", + " return byp.some((p) => {", + " if (p === '*') return true;", + " const s = p.split(':');", + " const ph = norm(s[0]);", + " if (s.length === 2 && s[1]) {", + " if (h !== ph) return false;", + " try { return new URL(input).port === s[1]; } catch { return false; }", + " }", + " if (ph.startsWith('*.')) return h.endsWith(ph.slice(1));", + " if (ph.startsWith('.')) return h.endsWith(ph) || h === ph.slice(1);", + " return h === ph;", + " });", + " };", + " const patched = function(input, init) {", + " if (init && init.dispatcher) return realFetch(input, init);", + " if (shouldBypass(input)) return realFetch(input, init);", + " return realFetch(input, Object.assign({}, init, { dispatcher: agent }));", + " };", + " if (Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.writable) {", + " globalThis.fetch = patched;", + " }", + "}" + ].join("\n"), + "utf8" + ); + return file; +} + + +function mergeNoProxy(current: string | undefined, values: string[]): string { + const merged = new Set(); + for (const value of [...(current || "").split(","), ...values]) { + const trimmed = value.trim(); + if (trimmed) { + merged.add(trimmed); + } + } + return [...merged].join(","); +} + + +export function endpoint(host: string, port: number): string { + const endpointHost = host === "0.0.0.0" ? "127.0.0.1" : host; + return `http://${endpointHost}:${port}`; +} + + +export function gatewayNetworkEndpoints(host: string, port: number): GatewayNetworkEndpoint[] { + const normalizedHost = normalizeBindHost(host); + const lanAddresses = physicalLanAddresses(); + const addresses = isWildcardBindHost(normalizedHost) + ? lanAddresses + : lanAddresses.filter((entry) => entry.address === normalizedHost); + + return addresses.map((entry) => ({ + address: entry.address, + endpoint: endpoint(entry.address, port), + interfaceName: entry.interfaceName + })); +} + + +function physicalLanAddresses(): Array<{ address: string; interfaceName: string }> { + const seen = new Set(); + const result: Array<{ address: string; interfaceName: string }> = []; + + for (const [interfaceName, entries] of Object.entries(networkInterfaces())) { + if (!entries || isVirtualNetworkInterface(interfaceName)) { + continue; + } + + for (const entry of entries) { + if (entry.internal || entry.family !== "IPv4" || !isPrivateIpv4(entry.address)) { + continue; + } + + const key = `${interfaceName}:${entry.address}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + result.push({ address: entry.address, interfaceName }); + } + } + + return result.sort((left, right) => + left.interfaceName.localeCompare(right.interfaceName) || + left.address.localeCompare(right.address, undefined, { numeric: true }) + ); +} + + +function normalizeBindHost(host: string): string { + return host.trim().replace(/^\[|\]$/g, "").toLowerCase(); +} + + +function isLoopbackBindHost(host: string): boolean { + const normalized = normalizeBindHost(host).replace(/\.$/, ""); + return normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "0:0:0:0:0:0:0:1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized); +} + + +function isWildcardBindHost(host: string): boolean { + return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; +} + + +function isPrivateIpv4(address: string): boolean { + const parts = address.split(".").map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + + return parts[0] === 10 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168); +} + + +function isVirtualNetworkInterface(interfaceName: string): boolean { + const normalized = interfaceName.toLowerCase(); + return [ + /^lo\d*$/, + /^awdl\d*$/, + /^llw\d*$/, + /^utun\d*$/, + /^gif\d*$/, + /^stf\d*$/, + /^bridge\d*$/, + /^br-/, + /^docker/, + /^veth/, + /^vmnet/, + /^vbox/, + /^tun\d*$/, + /^tap\d*$/, + /^wg\d*$/, + /\bloopback\b/, + /\bvirtual\b/, + /\bvirtualbox\b/, + /\bvmware\b/, + /\bhyper-v\b/, + /\bvethernet\b/, + /\bwsl\b/, + /\btunnel\b/, + /\btailscale\b/, + /\bzerotier\b/, + /\bwireguard\b/, + /\bhamachi\b/, + /\bparallels\b/, + /\bvpn\b/ + ].some((pattern) => pattern.test(normalized)); +} + + +export async function stopPreviousManagedCoreGateway(config: AppConfig, coreEndpoint: string): Promise { + const marker = readManagedCoreGatewayMarker(config); + const markerRuntimeId = stringValue(marker?.runtimeId); + const pid = numberValue(marker?.pid); + if (!markerRuntimeId || !pid) { + return; + } + + const health = await readCoreGatewayHealth(coreEndpoint); + if (health?.runtimeId !== markerRuntimeId) { + return; + } + + if (!isProcessAlive(pid)) { + removeManagedCoreGatewayMarker(config); + return; + } + + try { + process.kill(pid, "SIGTERM"); + } catch { + removeManagedCoreGatewayMarker(config); + return; + } + + if (await waitForCoreGatewayStop(coreEndpoint)) { + removeManagedCoreGatewayMarker(config); + return; + } + + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process may have exited between the health check and SIGKILL. + } + await waitForCoreGatewayStop(coreEndpoint); + removeManagedCoreGatewayMarker(config); +} + + +function readManagedCoreGatewayMarker(config: AppConfig): ManagedGatewayRuntimeMarker | undefined { + const file = managedCoreGatewayMarkerPath(config); + if (!existsSync(file)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + + +export function writeManagedCoreGatewayMarker(config: AppConfig, child: ChildProcess, runtimeId: string): void { + if (!child.pid) { + return; + } + try { + writeFileSync( + managedCoreGatewayMarkerPath(config), + `${JSON.stringify( + { + generatedConfigFile: config.gateway.generatedConfigFile, + gatewayEntry: resolveGatewayEntry(), + pid: child.pid, + runtimeId, + startedAt: new Date().toISOString() + }, + null, + 2 + )}\n`, + "utf8" + ); + } catch (error) { + console.warn(`[gateway] Failed to write gateway runtime marker: ${formatError(error)}`); + } +} + + +export function removeManagedCoreGatewayMarker(config: AppConfig | undefined): void { + if (!config) { + return; + } + try { + rmSync(managedCoreGatewayMarkerPath(config), { force: true }); + } catch (error) { + console.warn(`[gateway] Failed to remove gateway runtime marker: ${formatError(error)}`); + } +} + + +function managedCoreGatewayMarkerPath(config: AppConfig): string { + return pathJoin(dirname(config.gateway.generatedConfigFile), gatewayRuntimeMarkerFile); +} + + +async function waitForCoreGatewayStop(coreEndpoint: string): Promise { + for (let index = 0; index < 20; index += 1) { + if (!(await isCoreGatewayHealthy(coreEndpoint))) { + return true; + } + await delay(100); + } + return false; +} + + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + + +export function assertLoopbackCoreHost(host: string): void { + const error = loopbackCoreHostError(host); + if (error) { + throw new Error(error); + } +} + + +export function loopbackCoreHostError(host: string): string | undefined { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "::1" + ? undefined + : "Core gateway host must be 127.0.0.1 or ::1."; +} + + +export function generateCoreGatewayAuthToken(): string { + return randomBytes(32).toString("base64url"); +} + + +export async function isCoreGatewayHealthy(coreEndpoint: string): Promise { + const health = await readCoreGatewayHealth(coreEndpoint); + return health?.status === "ok"; +} + + +async function readCoreGatewayHealth(coreEndpoint: string): Promise { + if (!coreEndpoint) { + return undefined; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 500); + try { + const healthUrl = new URL("/health", coreEndpoint); + const response = await fetchWithSystemProxy(healthUrl, { signal: controller.signal }); + if (!response.ok) { + return undefined; + } + const body = await response.json().catch(() => undefined); + if (!isRecord(body)) { + return undefined; + } + return { + runtimeId: stringValue(body.runtimeId), + status: stringValue(body.status) + }; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + + +export function shouldRunUnifiedServer(config: AppConfig): boolean { + return config.gateway.enabled || config.proxy.enabled; +} + + +export function shouldRunGatewayRuntime(config: AppConfig): boolean { + return config.gateway.enabled || (config.proxy.enabled && config.proxy.mode === "gateway"); +} + + +export function shouldServeGatewayRequest(config: AppConfig, request: IncomingMessage): boolean { + if (config.gateway.enabled) { + return true; + } + return config.proxy.enabled && config.proxy.mode === "gateway" && readHeader(request.headers["x-ccr-proxy-mode"]) === "gateway"; +} + + +export function applyCors(response: ServerResponse, config?: AppConfig): void { + const origin = config ? endpoint(config.gateway.host, config.gateway.port) : "*"; + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, Last-Event-ID, Anthropic-Version, Anthropic-Beta, Mcp-Session-Id, MCP-Protocol-Version"); + response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); + response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); +} diff --git a/packages/core/src/gateway/features/codex-patch-bridge.ts b/packages/core/src/gateway/features/codex-patch-bridge.ts new file mode 100644 index 00000000..a08d6efe --- /dev/null +++ b/packages/core/src/gateway/features/codex-patch-bridge.ts @@ -0,0 +1,460 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { IncomingHttpHeaders } from "node:http"; +import { Readable, Transform } from "node:stream"; +import type { AppConfig } from "@ccr/core/contracts/app"; +import { normalizeRouteSelector } from "@ccr/core/routing/model-registry"; +import { isRecord, rawStringValue, stringValue } from "@ccr/core/gateway/internal/value"; +import { readHeader } from "@ccr/core/gateway/http/io"; +import { codexPatchBridgeInstructionText, codexPatchBridgeShellToolGuidance, virtualApplyPatchLarkGrammar, virtualApplyPatchToolName } from "@ccr/core/gateway/internal/shared"; +import { parseJsonObjectSafe } from "@ccr/core/gateway/http/body"; +import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints"; + + +export function prepareCodexApplyPatchBridgeRequest(input: { + body?: Buffer; + config: AppConfig; + headers: IncomingHttpHeaders; + method: string; + path: string; + routedModel?: string; +}): { body: Buffer; diagnostic: string } | undefined { + if (!codexApplyPatchBridgeEnabled(input.config, input.headers, input.method, input.path)) { + return undefined; + } + const parsedBody = parseJsonObjectSafe(input.body); + if (!parsedBody) { + return undefined; + } + const model = input.routedModel || stringValue(parsedBody.model); + if (!codexPatchBridgeModelEligible(model)) { + return undefined; + } + const transformed = transformCodexApplyPatchBridgeRequestBody(parsedBody); + if (!transformed.changed) { + return undefined; + } + return { + body: Buffer.from(`${JSON.stringify(transformed.body)}\n`, "utf8"), + diagnostic: `${model ?? "unknown"}:${transformed.changedParts.join(",")}` + }; +} + + +export function transformCodexApplyPatchBridgeRequestBody(body: Record): { + body: Record; + changed: boolean; + changedParts: string[]; +} { + const next = { ...body }; + const changedParts: string[] = []; + const tools = transformCodexApplyPatchBridgeTools(body.tools); + if (tools.changed) { + next.tools = tools.value; + changedParts.push("tools"); + const instructions = transformCodexApplyPatchBridgeInstructions(body.instructions); + if (instructions.changed) { + next.instructions = instructions.value; + changedParts.push("instructions"); + } + const input = transformCodexApplyPatchBridgeInput(body.input); + if (input.changed) { + next.input = input.value; + changedParts.push("input"); + } + } + return { + body: next, + changed: changedParts.length > 0, + changedParts + }; +} + + +function transformCodexApplyPatchBridgeTools(value: unknown): { value: unknown; changed: boolean } { + if (!Array.isArray(value)) { + return { value, changed: false }; + } + const hasApplyPatchTool = value.some((tool) => isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch"); + if (!hasApplyPatchTool) { + return { value, changed: false }; + } + let changed = false; + const tools = value.map((tool) => { + if (isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch") { + changed = true; + return virtualApplyPatchToolSpec(); + } + const shellTool = transformCodexPatchBridgeShellTool(tool); + if (shellTool.changed) { + changed = true; + return shellTool.value; + } + return tool; + }); + return { value: tools, changed }; +} + + +function transformCodexApplyPatchBridgeInstructions(value: unknown): { value: unknown; changed: boolean } { + const text = rawStringValue(value); + if (text === undefined) { + return value === undefined + ? { value: codexPatchBridgeInstructionText, changed: true } + : { value, changed: false }; + } + if (text.includes(codexPatchBridgeInstructionText)) { + return { value, changed: false }; + } + return { + value: `${text.trimEnd()}\n\n${codexPatchBridgeInstructionText}`, + changed: true + }; +} + + +function transformCodexPatchBridgeShellTool(value: unknown): { value: unknown; changed: boolean } { + if (!isRecord(value) || value.type !== "function") { + return { value, changed: false }; + } + const name = stringValue(value.name); + if (name !== "exec_command" && name !== "write_stdin") { + return { value, changed: false }; + } + let changed = false; + const next: Record = { ...value }; + const description = rawStringValue(value.description) ?? ""; + if (!description.includes(codexPatchBridgeShellToolGuidance)) { + next.description = description + ? `${description} ${codexPatchBridgeShellToolGuidance}` + : codexPatchBridgeShellToolGuidance; + changed = true; + } + if (name === "exec_command") { + const parameters = transformCodexPatchBridgeExecCommandParameters(value.parameters); + if (parameters.changed) { + next.parameters = parameters.value; + changed = true; + } + } + return { value: changed ? next : value, changed }; +} + + +function transformCodexPatchBridgeExecCommandParameters(value: unknown): { value: unknown; changed: boolean } { + if (!isRecord(value) || !isRecord(value.properties) || !isRecord(value.properties.cmd)) { + return { value, changed: false }; + } + const cmd = value.properties.cmd; + const description = rawStringValue(cmd.description) ?? ""; + if (description.includes(codexPatchBridgeShellToolGuidance)) { + return { value, changed: false }; + } + return { + value: { + ...value, + properties: { + ...value.properties, + cmd: { + ...cmd, + description: description + ? `${description} ${codexPatchBridgeShellToolGuidance}` + : codexPatchBridgeShellToolGuidance + } + } + }, + changed: true + }; +} + + +function transformCodexApplyPatchBridgeInput(value: unknown): { value: unknown; changed: boolean } { + if (!Array.isArray(value)) { + return { value, changed: false }; + } + const applyPatchCallIds = new Set(); + for (const item of value) { + if (isRecord(item) && item.type === "custom_tool_call" && item.name === "apply_patch") { + const callId = stringValue(item.call_id); + if (callId) { + applyPatchCallIds.add(callId); + } + } + } + let changed = false; + const items = value.map((item) => { + const transformed = transformCodexApplyPatchBridgeInputItem(item, applyPatchCallIds); + changed ||= transformed.changed; + return transformed.value; + }); + return { value: items, changed }; +} + + +function transformCodexApplyPatchBridgeInputItem(value: unknown, applyPatchCallIds: Set): { value: unknown; changed: boolean } { + if (!isRecord(value)) { + return { value, changed: false }; + } + if (value.type === "custom_tool_call" && value.name === "apply_patch") { + const { input: patchInput, name: _name, type: _type, ...rest } = value; + return { + value: { + ...rest, + type: "function_call", + name: virtualApplyPatchToolName, + arguments: JSON.stringify({ patch: rawStringValue(patchInput) ?? "" }) + }, + changed: true + }; + } + if ( + value.type === "custom_tool_call_output" && + (applyPatchCallIds.has(stringValue(value.call_id) ?? "") || value.name === "apply_patch") + ) { + const { name: _name, type: _type, ...rest } = value; + return { + value: { + ...rest, + type: "function_call_output" + }, + changed: true + }; + } + return { value, changed: false }; +} + + +function virtualApplyPatchToolSpec(): Record { + return { + type: "function", + name: virtualApplyPatchToolName, + description: [ + "Edit files by returning exactly one complete apply_patch patch.", + "The patch field must be raw patch grammar text starting with *** Begin Patch and ending with *** End Patch.", + "Do not wrap the patch in JSON, markdown fences, shell commands, cat, sed, perl, or python.", + "The patch field must match this Lark grammar:", + virtualApplyPatchLarkGrammar + ].join("\n\n"), + strict: true, + parameters: { + type: "object", + additionalProperties: false, + required: ["patch"], + properties: { + patch: { + type: "string", + description: [ + "Raw apply_patch grammar text matching this Lark grammar:", + virtualApplyPatchLarkGrammar + ].join("\n\n") + } + } + } + }; +} + + +function codexApplyPatchBridgeEnabled(config: AppConfig, headers: IncomingHttpHeaders, method: string, path: string): boolean { + const codexRule = config.Router.builtInRules?.codex; + return (method || "GET").toUpperCase() === "POST" && + requestProtocolForPath(path) === "openai_responses" && + isCodexUserAgent(headers) && + codexRule?.enabled !== false; +} + + +function isCodexUserAgent(headers: IncomingHttpHeaders): boolean { + return readHeader(headers["user-agent"])?.toLowerCase().includes("codex") ?? false; +} + + +function codexPatchBridgeModelEligible(model: string | undefined): boolean { + const modelName = modelNameForPatchBridge(model); + return Boolean(modelName) && !modelName.toLowerCase().includes("gpt"); +} + + +function modelNameForPatchBridge(model: string | undefined): string { + const normalized = normalizeRouteSelector(model) ?? ""; + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; +} + + +export function codexApplyPatchBridgeResponseStream(input: Readable, headers: Headers): Readable { + const contentType = headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("text/event-stream")) { + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + transformSseChunk(this, chunk); + callback(); + }, + flush(callback) { + flushSseTransform(this); + callback(); + } + })); + } + if (contentType.includes("application/json")) { + 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 raw = Buffer.concat(chunks).toString("utf8"); + try { + const parsed = JSON.parse(raw); + const transformed = transformCodexApplyPatchBridgeResponseValue(parsed); + this.push(Buffer.from(`${JSON.stringify(transformed.value)}\n`, "utf8")); + } catch { + this.push(Buffer.from(raw, "utf8")); + } + callback(); + } + })); + } + return input; +} + + +export function transformCodexApplyPatchBridgeResponseValue(value: unknown): { value: unknown; changed: boolean } { + if (!isRecord(value)) { + return { value, changed: false }; + } + let changed = false; + const next = { ...value }; + if (isRecord(value.item)) { + const item = transformVirtualApplyPatchFunctionCall(value.item, value.type === "response.output_item.added"); + if (item.changed) { + next.item = item.value; + changed = true; + } + } + if (Array.isArray(value.output)) { + const output = transformCodexApplyPatchBridgeResponseItems(value.output); + if (output.changed) { + next.output = output.value; + changed = true; + } + } + if (isRecord(value.response) && Array.isArray(value.response.output)) { + const output = transformCodexApplyPatchBridgeResponseItems(value.response.output); + if (output.changed) { + next.response = { + ...value.response, + output: output.value + }; + changed = true; + } + } + const item = transformVirtualApplyPatchFunctionCall(next, false); + if (item.changed) { + return item; + } + return { value: next, changed }; +} + + +function transformCodexApplyPatchBridgeResponseItems(items: unknown[]): { value: unknown[]; changed: boolean } { + let changed = false; + const value = items.map((item) => { + const transformed = isRecord(item) + ? transformVirtualApplyPatchFunctionCall(item, false) + : { value: item, changed: false }; + changed ||= transformed.changed; + return transformed.value; + }); + return { value, changed }; +} + + +function transformVirtualApplyPatchFunctionCall(item: Record, allowEmptyInput: boolean): { value: unknown; changed: boolean } { + if (item.type !== "function_call" || item.name !== virtualApplyPatchToolName) { + return { value: item, changed: false }; + } + const patch = patchInputFromVirtualApplyPatchArguments(item.arguments); + if (patch === undefined && !allowEmptyInput) { + return { value: item, changed: false }; + } + const { arguments: _arguments, name: _name, type: _type, ...rest } = item; + return { + value: { + ...rest, + type: "custom_tool_call", + name: "apply_patch", + input: patch ?? "" + }, + changed: true + }; +} + + +function patchInputFromVirtualApplyPatchArguments(value: unknown): string | undefined { + if (isRecord(value)) { + return rawStringValue(value.patch); + } + const text = rawStringValue(value); + if (text === undefined) { + return undefined; + } + try { + const parsed = JSON.parse(text); + return isRecord(parsed) ? rawStringValue(parsed.patch) : undefined; + } catch { + return undefined; + } +} + + +function transformSseChunk(stream: Transform, chunk: Buffer | string): void { + const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string }; + state.__ccrCodexPatchBridgeSsePending = (state.__ccrCodexPatchBridgeSsePending ?? "") + chunk.toString(); + while (state.__ccrCodexPatchBridgeSsePending) { + const match = /\r?\n\r?\n/.exec(state.__ccrCodexPatchBridgeSsePending); + if (!match || match.index === undefined) { + break; + } + const block = state.__ccrCodexPatchBridgeSsePending.slice(0, match.index); + const delimiter = match[0]; + state.__ccrCodexPatchBridgeSsePending = state.__ccrCodexPatchBridgeSsePending.slice(match.index + delimiter.length); + stream.push(transformCodexApplyPatchBridgeSseEvent(block) + delimiter); + } +} + + +function flushSseTransform(stream: Transform): void { + const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string }; + if (state.__ccrCodexPatchBridgeSsePending) { + stream.push(transformCodexApplyPatchBridgeSseEvent(state.__ccrCodexPatchBridgeSsePending)); + state.__ccrCodexPatchBridgeSsePending = ""; + } +} + + +export function transformCodexApplyPatchBridgeSseEvent(block: string): string { + const lines = block.split(/\r?\n/g); + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (!data || data === "[DONE]") { + return block; + } + try { + const parsed = JSON.parse(data); + const transformed = transformCodexApplyPatchBridgeResponseValue(parsed); + if (!transformed.changed) { + return block; + } + const event = stringValue((transformed.value as Record).type) || stringValue(parsed.type); + return [ + event ? `event: ${event}` : undefined, + `data: ${JSON.stringify(transformed.value)}` + ].filter(Boolean).join("\n"); + } catch { + return block; + } +} diff --git a/packages/core/src/gateway/features/cursor-compat.ts b/packages/core/src/gateway/features/cursor-compat.ts new file mode 100644 index 00000000..5ce05b78 --- /dev/null +++ b/packages/core/src/gateway/features/cursor-compat.ts @@ -0,0 +1,205 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { AppConfig } from "@ccr/core/contracts/app"; +import { isRecord, stringValue } from "@ccr/core/gateway/internal/value"; +import { parseJsonObject } from "@ccr/core/gateway/http/io"; +import type { CursorOpenAICompatContext, CursorOpenAICompatPreparation } from "@ccr/core/gateway/internal/shared"; + +let warnedMissingCursorOpenAICompatContext = false; + + +export function prepareCursorOpenAICompatChatBody( + config: AppConfig, + client: string | undefined, + method: string, + path: string, + requestBody: Buffer +): CursorOpenAICompatPreparation | undefined { + if ((method || "GET").toUpperCase() !== "POST" || !isOpenAICompatChatCompletionsPath(path) || client !== "Cursor") { + return undefined; + } + + let body: Record; + try { + body = parseJsonObject(requestBody); + } catch { + return undefined; + } + if (!isSimplifiedCursorOpenAICompatChat(body)) { + return undefined; + } + + const context = readCursorOpenAICompatContext(config); + let changed = false; + if (context.systemPrompt) { + body.messages = [ + { content: context.systemPrompt, role: "system" }, + ...(Array.isArray(body.messages) ? body.messages : []) + ]; + changed = true; + } + if (context.tools.length > 0) { + body.tools = context.tools; + changed = true; + } + if (context.toolChoice !== undefined && context.tools.length > 0) { + body.tool_choice = context.toolChoice; + changed = true; + } + + if (!changed) { + if (!warnedMissingCursorOpenAICompatContext) { + warnedMissingCursorOpenAICompatContext = true; + console.warn( + "[gateway] Cursor sent an OpenAI-compatible chat request with only user messages and no system/tools. " + + "Configure plugins[].id=\"cursor-proxy\" config.systemPrompt/config.tools to inject fallback context, " + + "or route Cursor native Agent traffic through the proxy." + ); + } + return { diagnostic: "simplified-missing-context" }; + } + + return { + body: Buffer.from(`${JSON.stringify(body)}\n`, "utf8"), + diagnostic: "fallback-injected" + }; +} + + +function isOpenAICompatChatCompletionsPath(path: string): boolean { + return path === "/chat/completions" || + path === "/v1/chat/completions" || + path.endsWith("/chat/completions"); +} + + +function isSimplifiedCursorOpenAICompatChat(body: Record): boolean { + if (body.system !== undefined || body.systemPrompt !== undefined || body.instructions !== undefined) { + return false; + } + if (Array.isArray(body.tools) && body.tools.length > 0) { + return false; + } + if (!Array.isArray(body.messages) || body.messages.length === 0) { + return false; + } + return body.messages.every((message) => + isRecord(message) && + stringValue(message.role)?.toLowerCase() === "user" + ); +} + + +function readCursorOpenAICompatContext(config: AppConfig): CursorOpenAICompatContext { + const plugin = config.plugins.find((item) => item.enabled !== false && item.id === "cursor-proxy"); + const pluginConfig = isRecord(plugin?.config) ? plugin.config : {}; + return { + systemPrompt: + stringValue(pluginConfig.systemPrompt) || + stringValue(pluginConfig.openaiSystemPrompt) || + stringValue(pluginConfig.defaultSystemPrompt), + toolChoice: normalizeCursorToolChoice( + pluginConfig.toolChoice ?? pluginConfig.openaiToolChoice ?? pluginConfig.defaultToolChoice + ), + tools: normalizeCursorTools(pluginConfig.tools ?? pluginConfig.openaiTools ?? pluginConfig.defaultTools) + }; +} + + +function normalizeCursorTools(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value.map(normalizeCursorTool).filter((tool): tool is Record => Boolean(tool)); + } + if (isRecord(value)) { + if (Array.isArray(value.tools) || isRecord(value.tools)) { + return normalizeCursorTools(value.tools); + } + return Object.entries(value) + .map(([name, item]) => normalizeCursorTool(isRecord(item) ? { ...item, name: stringValue(item.name) || name } : { description: stringValue(item), name })) + .filter((tool): tool is Record => Boolean(tool)); + } + return []; +} + + +function normalizeCursorTool(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const type = stringValue(value.type); + if (type && type.toLowerCase().startsWith("web_search")) { + return { ...value, type }; + } + + const fn = isRecord(value.function) ? value.function : value; + const name = + stringValue(fn.name) || + stringValue(value.name) || + stringValue(value.toolName) || + stringValue(value.functionName); + if (!name) { + return undefined; + } + return { + function: compactRecord({ + description: stringValue(fn.description) || stringValue(value.description), + name, + parameters: normalizeCursorToolParameters( + fn.parameters ?? + value.parameters ?? + fn.input_schema ?? + value.input_schema ?? + fn.inputSchema ?? + value.inputSchema ?? + fn.schema ?? + value.schema + ) + }), + type: "function" + }; +} + + +function normalizeCursorToolParameters(value: unknown): Record { + if (isRecord(value)) { + return value; + } + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown; + if (isRecord(parsed)) { + return parsed; + } + } catch { + // Fall through to an empty object schema. + } + } + return { properties: {}, type: "object" }; +} + + +function normalizeCursorToolChoice(value: unknown): unknown { + if (typeof value === "string" && value.trim()) { + const normalized = value.trim().toLowerCase(); + if (normalized === "auto" || normalized === "none" || normalized === "required") { + return normalized; + } + return { function: { name: value.trim() }, type: "function" }; + } + if (!isRecord(value)) { + return undefined; + } + const type = stringValue(value.type); + if (type && ["auto", "none", "required"].includes(type.toLowerCase())) { + return type.toLowerCase(); + } + const fn = isRecord(value.function) ? value.function : value; + const name = stringValue(fn.name) || stringValue(value.name) || stringValue(value.toolName); + return name ? { function: { name }, type: "function" } : undefined; +} + + +function compactRecord(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} diff --git a/packages/core/src/gateway/features/hosted-web-search/discovery.ts b/packages/core/src/gateway/features/hosted-web-search/discovery.ts new file mode 100644 index 00000000..bbe05a1d --- /dev/null +++ b/packages/core/src/gateway/features/hosted-web-search/discovery.ts @@ -0,0 +1,526 @@ +import type { AppConfig, GatewayProviderProtocol } from "@ccr/core/contracts/app"; +import { isRecord, numberValue, stringListValue, stringValue } from "@ccr/core/gateway/internal/value"; +import { normalizeCoreGatewayVirtualModelProfiles } from "@ccr/core/gateway/core-runtime/config-compiler"; +import { fusionModelNameFromSelector, readFusionWebSearchConfig, withCodexCompatibleVirtualModelProfiles, withFusionVirtualModelAliases } from "@ccr/core/mcp/fusion-config"; +import type { AnthropicWebSearchProtocolContext, BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, ClaudeCodeWebSearchContinuationContext, HostedWebSearchProtocolContext } from "@ccr/core/gateway/internal/shared"; +import { clampNumber, uniqueStrings } from "@ccr/core/gateway/internal/collections"; +import { queryMatchScore } from "@ccr/core/gateway/features/hosted-web-search/evidence"; + + + +function hasAnthropicHostedWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some(isAnthropicHostedWebSearchTool); +} + + + +export 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; + }); +} + + + +export function isAnthropicHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return anthropicHostedWebSearchType(stringValue(tool.type)); +} + + + +export function isOpenAiHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return openAiHostedWebSearchType(stringValue(tool.type)); +} + + + +export 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; +} + + + +export 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; +} + + + +export 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); +} + + + +export 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()) + .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] : []); +} + + + +export 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 []; + } + 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) : ""; + for (const candidate of fusionWebSearchToolCandidates(config)) { + if (!normalizedModel || candidate.aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { + return candidate.toolName; + } + } + return undefined; +} + + + +function fusionWebSearchToolCandidates(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; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (!webSearchConfig?.toolName) { + 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))); + candidates.push({ aliases, toolName: webSearchConfig.toolName }); + } + return candidates; +} + + + +export 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] : []; +} + + + +export 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 +): 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; + }; +} + diff --git a/packages/core/src/gateway/features/hosted-web-search/evidence.ts b/packages/core/src/gateway/features/hosted-web-search/evidence.ts new file mode 100644 index 00000000..d74d62b3 --- /dev/null +++ b/packages/core/src/gateway/features/hosted-web-search/evidence.ts @@ -0,0 +1,607 @@ +import { randomBytes } from "node:crypto"; +import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value"; +import type { BrowserWebSearchProtocolRecord, BrowserWebSearchProtocolResult } from "@ccr/core/gateway/internal/shared"; +import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; +import { sseEventFromValue } from "@ccr/core/gateway/features/hosted-web-search/sse"; +import type { ParsedSseEvent } from "@ccr/core/gateway/features/hosted-web-search/sse"; + + + +export 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); +} + + + +export function normalizeSearchComparisonText(value: string): string { + return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim(); +} + + + +export 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"; + }); +} + + + +export 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()); + }); +} + + + +export 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) { + const block = content[index]; + if (!isRecord(block) || stringValue(block.type) !== "thinking") { + break; + } + index += 1; + } + return index; +} + + + +export 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; +} + + + +export 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; +} + + + +export function sseEventsContainAnthropicWebSearchBlocks(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + return sseEventContainsAnthropicWebSearchBlock(event); + }); +} + + + +export function sseEventsContainVisibleText(events: ParsedSseEvent[]): boolean { + return events.some(sseEventContainsVisibleText); +} + + + +export 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"; +} + + + +export 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()); +} + + + +export function sseEventsContainAnthropicClientToolUse(events: ParsedSseEvent[]): boolean { + return events.some(sseEventContainsAnthropicClientToolUse); +} + + + +export 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"; +} + + + +export 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; +} + + + +export function sseEventIsAnthropicMessageEnd(event: ParsedSseEvent): boolean { + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return type === "message_delta" || type === "message_stop"; +} + + + +export 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" + }) + ]; +} + + + +export function updateAnthropicWebSearchSseUsage( + event: ParsedSseEvent, + searchCount: number, + didSynthesizeAnswer: boolean, + hasClientToolUse: 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 (isRecord(delta) && shouldEndAnthropicHostedWebSearchTurn(delta.stop_reason, didSynthesizeAnswer, hasClientToolUse)) { + nextData.delta = { ...delta, stop_reason: "end_turn" }; + } + return { + ...event, + data: nextData + }; +} + + + +export 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"); +} + + + +export 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); + 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 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; + } + 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, "\\$&"); +} + + + +export 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 = anthropicWebSearchResultSnippet(result); + return { + encrypted_content: "", + ...(snippet ? { snippet: snippet.slice(0, 1_200) } : {}), + title: result.title, + type: "web_search_result", + url: result.url + }; +} + + + +function anthropicWebSearchResultSnippet(result: BrowserWebSearchProtocolResult): string | undefined { + const parts = [ + result.snippet ? `Search snippet: ${sanitizeWebSearchEvidenceText(result.snippet)}` : "", + result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" + ].filter(Boolean); + return parts.length > 0 ? parts.join("\n") : undefined; +} + + + +export function sanitizeAnthropicToolUseId(value: string): string { + return value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24) || randomBytes(8).toString("hex"); +} + diff --git a/packages/core/src/gateway/features/hosted-web-search/index.ts b/packages/core/src/gateway/features/hosted-web-search/index.ts new file mode 100644 index 00000000..47ac1420 --- /dev/null +++ b/packages/core/src/gateway/features/hosted-web-search/index.ts @@ -0,0 +1,4 @@ +/** Public facade for the hosted web-search protocol bridge. */ +export { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, prepareAnthropicWebSearchProtocolRequestBody, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody } from "@ccr/core/gateway/features/hosted-web-search/request-transform"; +export { hostedWebSearchProtocolResponseStream, transformAnthropicWebSearchProtocolResponseValue, transformAnthropicWebSearchProtocolSseText, transformGeminiHostedWebSearchResponseValue, transformGeminiHostedWebSearchSseText, transformOpenAiChatHostedWebSearchResponseValue, transformOpenAiChatHostedWebSearchSseText, transformOpenAiResponsesHostedWebSearchResponseValue, transformOpenAiResponsesHostedWebSearchSseText } from "@ccr/core/gateway/features/hosted-web-search/response-transform"; +export { extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/discovery"; diff --git a/packages/core/src/gateway/features/hosted-web-search/request-transform.ts b/packages/core/src/gateway/features/hosted-web-search/request-transform.ts new file mode 100644 index 00000000..862d2cf1 --- /dev/null +++ b/packages/core/src/gateway/features/hosted-web-search/request-transform.ts @@ -0,0 +1,456 @@ +import type { AppConfig } from "@ccr/core/contracts/app"; +import { isRecord, rawStringValue, stringValue } from "@ccr/core/gateway/internal/value"; +import type { AnthropicWebSearchProtocolContext, BrowserWebSearchProtocolRecord, ClaudeCodeWebSearchContinuationContext, HostedWebSearchProtocolContext } from "@ccr/core/gateway/internal/shared"; +import { parseJsonObjectSafe } from "@ccr/core/gateway/http/body"; +import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; +import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints"; +import { claudeCodeWebSearchToolResultTexts, extractAnthropicWebSearchQueryHint, extractClaudeCodeWebSearchToolResultQuery, extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hasHostedWebSearchDeclaration, isAnthropicHostedWebSearchTool, isOpenAiHostedWebSearchTool, openAiToolChoiceNamesWebSearch, readHostedWebSearchMaxUses } from "@ccr/core/gateway/features/hosted-web-search/discovery"; +import { normalizeSearchComparisonText } from "@ccr/core/gateway/features/hosted-web-search/evidence"; + + + +export 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 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[], + 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"); +} + + + +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 } : {}; + 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 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; + 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}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" + ].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 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) { + 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 ? "..." : ""}`; +} + diff --git a/packages/core/src/gateway/features/hosted-web-search/response-transform.ts b/packages/core/src/gateway/features/hosted-web-search/response-transform.ts new file mode 100644 index 00000000..6c184ee6 --- /dev/null +++ b/packages/core/src/gateway/features/hosted-web-search/response-transform.ts @@ -0,0 +1,1246 @@ +import { Readable, Transform } from "node:stream"; +import type { GatewayProviderProtocol } from "@ccr/core/contracts/app"; +import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value"; +import { formatError } from "@ccr/core/gateway/http/io"; +import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, HostedWebSearchProtocolContext } from "@ccr/core/gateway/internal/shared"; +import { selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/discovery"; +import { parseSseEventBlock, parseSseEvents, serializeSseEvent, shiftSseContentBlockIndex, sseEventFromValue } from "@ccr/core/gateway/features/hosted-web-search/sse"; +import type { ParsedSseEvent } from "@ccr/core/gateway/features/hosted-web-search/sse"; +import { anthropicSseTextBlockStartIndex, anthropicWebSearchProtocolBlocks, anthropicWebSearchSseEventsForBlock, mergeAnthropicWebSearchUsage, responseValueContainsAnthropicClientToolUse, responseValueContainsAnthropicWebSearchBlocks, responseValueContainsVisibleText, sanitizeAnthropicToolUseId, shouldEndAnthropicHostedWebSearchTurn, sseEventContainsAnthropicClientToolUse, sseEventContainsAnthropicWebSearchBlock, sseEventContainsVisibleText, sseEventIsAnthropicMessageEnd, sseEventsContainAnthropicClientToolUse, sseEventsContainAnthropicWebSearchBlocks, sseEventsContainVisibleText, synthesizeWebSearchAnswer, updateAnthropicWebSearchSseUsage, webSearchProtocolInsertIndex } from "@ccr/core/gateway/features/hosted-web-search/evidence"; + + + +export 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")) { + if (context.protocol === "anthropic_messages") { + return anthropicHostedWebSearchProtocolSseStream(input, context, integration); + } + return hostedWebSearchProtocolSseStream(input, context, integration); + } + 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 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[], + 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 hasClientToolUse = responseValueContainsAnthropicClientToolUse(value); + const answer = hasClientToolUse || 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) } : {}), + ...(shouldEndAnthropicHostedWebSearchTurn(value.stop_reason, Boolean(answer), hasClientToolUse) ? { 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 hasClientToolUse = sseEventsContainAnthropicClientToolUse(events); + const answer = hasClientToolUse || 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), 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`; +} + + + +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 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); + 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 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; + } + 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 sseEventIsDone(event: ParsedSseEvent): boolean { + return Boolean(event.raw?.includes("[DONE]")); +} + diff --git a/packages/core/src/gateway/features/hosted-web-search/sse.ts b/packages/core/src/gateway/features/hosted-web-search/sse.ts new file mode 100644 index 00000000..7c22bb85 --- /dev/null +++ b/packages/core/src/gateway/features/hosted-web-search/sse.ts @@ -0,0 +1,78 @@ +import { isRecord, stringValue } from "@ccr/core/gateway/internal/value"; + + + +export type ParsedSseEvent = { + data?: unknown; + event?: string; + raw?: string; +}; + + + +export function parseSseEvents(body: string): ParsedSseEvent[] { + return body + .split(/\r?\n\r?\n/g) + .filter((block) => block.trim()) + .map(parseSseEventBlock); +} + + + +export 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 }; + } +} + + + +export 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 + } + }; +} + + + +export function sseEventFromValue(data: Record): ParsedSseEvent { + return { + data, + event: stringValue(data.type) + }; +} + + + +export 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"); +} + diff --git a/packages/core/src/gateway/features/model-discovery.ts b/packages/core/src/gateway/features/model-discovery.ts new file mode 100644 index 00000000..20fd070d --- /dev/null +++ b/packages/core/src/gateway/features/model-discovery.ts @@ -0,0 +1,511 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { IncomingHttpHeaders } from "node:http"; +import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; +import { CLAUDE_APP_FALLBACK_MODEL, buildClaudeAppGatewayModelRoutes, inferClaudeAppGatewayTargetModel, resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes"; +import { normalizeRouteSelector } from "@ccr/core/routing/model-registry"; +import { findModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens, readCatalogCapability, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog"; +import { stringValue } from "@ccr/core/gateway/internal/value"; +import { fusionModelSelector } from "@ccr/core/mcp/fusion-config"; +import { readHeader } from "@ccr/core/gateway/http/io"; +import { claudeAppGatewayModelRouteOptions, claudeCodeOneMillionContextSuffix } from "@ccr/core/gateway/internal/shared"; +import type { ClaudeCodeDiscoverableModel } from "@ccr/core/gateway/internal/shared"; +import { parseJsonObjectSafe, serializeJsonBodyWithModel } from "@ccr/core/gateway/http/body"; +import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; + + +export function shouldServeGatewayModelsResponse(method: string, path: string): boolean { + return (method || "GET").toUpperCase() === "GET" && + normalizeGatewayPathname(path) === "/v1/models"; +} + + +export function prepareClaudeCodeDiscoveredModelRequest( + config: AppConfig, + headers: IncomingHttpHeaders, + method: string, + path: string, + body: Buffer | undefined +): { body: Buffer; diagnostic: string } | undefined { + if ( + (method || "GET").toUpperCase() !== "POST" || + normalizeGatewayPathname(path) !== "/v1/messages" || + !isClaudeCodeUserAgent(headers) + ) { + return undefined; + } + + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const rewrittenModel = resolveClaudeCodeDiscoveredModelId(model, config); + if (!parsedBody || !rewrittenModel || rewrittenModel === model) { + return undefined; + } + + return { + body: serializeJsonBodyWithModel(parsedBody, rewrittenModel), + diagnostic: `${model}->${rewrittenModel}` + }; +} + + +export function prepareClaudeAppFallbackModelRequest( + config: AppConfig, + method: string, + path: string, + body: Buffer | undefined +): { body: Buffer; diagnostic: string; routedModel: string } | undefined { + if ( + (method || "GET").toUpperCase() !== "POST" || + normalizeGatewayPathname(path) !== "/v1/messages" + ) { + return undefined; + } + + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const normalizedModel = normalizeRouteSelector(model); + if (!parsedBody || !normalizedModel) { + return undefined; + } + + const routeModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions); + const routedModel = routeModel ?? + (normalizedModel.toLowerCase() === CLAUDE_APP_FALLBACK_MODEL ? inferClaudeAppGatewayTargetModel(config) : undefined); + if (!routedModel || routedModel.toLowerCase() === normalizedModel.toLowerCase()) { + return undefined; + } + if (isConfiguredGatewayModelSelector(normalizedModel, config) && !routeModel) { + return undefined; + } + + return { + body: serializeJsonBodyWithModel(parsedBody, routedModel), + diagnostic: `${model}->${routedModel}`, + routedModel + }; +} + + +export function createGatewayModelsResponse(config: AppConfig, headers: IncomingHttpHeaders, apiKey?: ApiKeyConfig): Record { + if (isClaudeAppApiKey(apiKey) || isClaudeCodeUserAgent(headers)) { + return createClaudeAppGatewayModelsResponse(config); + } + return createOpenAICompatibleGatewayModelsResponse(config); +} + + +function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record { + const data = buildGatewayDiscoverableModelIds(config).map((id) => { + const catalogEntry = findModelCatalogEntry(id); + return { + id, + object: "model", + created: 0, + owned_by: gatewayModelOwner(id), + type: "model", + ...(catalogEntry?.displayName ? { display_name: catalogEntry.displayName } : {}) + }; + }); + + return { + object: "list", + data + }; +} + + +function createClaudeAppGatewayModelsResponse(config: AppConfig): Record { + const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); + const data = routes.map((route) => { + const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); + const catalogEntry = findModelCatalogEntry(catalogId); + const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext); + const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); + return { + id: route.id, + capabilities: createClaudeCodeModelCapabilities(catalogEntry, { + maxInputTokens, + oneMillionContext: route.oneMillionContext + }), + created_at: "1970-01-01T00:00:00Z", + display_name: route.displayName, + max_input_tokens: maxInputTokens, + max_tokens: maxOutputTokens, + type: "model" + }; + }); + + return { + data, + first_id: data[0]?.id ?? null, + has_more: false, + last_id: data[data.length - 1]?.id ?? null + }; +} + + +function createClaudeCodeModelsResponse(config: AppConfig): Record { + const models = buildClaudeCodeDiscoverableModels(config); + const data = models.map((model) => { + const claudeId = claudeCodeDiscoveryModelId(model.id); + const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id); + const catalogEntry = findModelCatalogEntry(catalogId); + const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext); + const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); + return { + id: claudeId, + capabilities: createClaudeCodeModelCapabilities(catalogEntry, { + maxInputTokens, + oneMillionContext: model.oneMillionContext + }), + created_at: "1970-01-01T00:00:00Z", + display_name: formatClaudeCodeModelDisplayName(claudeId, catalogEntry, model.oneMillionContext), + max_input_tokens: maxInputTokens, + max_tokens: maxOutputTokens, + type: "model" + }; + }); + + return { + data, + first_id: data[0]?.id ?? null, + has_more: false, + last_id: data[data.length - 1]?.id ?? null + }; +} + + +function claudeGatewayModelContextWindow(entry: ModelCatalogEntry | undefined, oneMillionContext: boolean): number { + const contextWindow = modelCatalogMaxInputTokens(entry); + if (contextWindow > 0) { + return contextWindow; + } + return oneMillionContext ? 1_000_000 : 0; +} + + +function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] { + return buildGatewayDiscoverableModelIds(config); +} + + +function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { + const baseEntries: Array<{ modelName: string; providerName: string }> = []; + for (const provider of config.Providers) { + const providerName = provider.name?.trim(); + if (!providerName || !Array.isArray(provider.models)) { + continue; + } + for (const rawModel of provider.models) { + const modelName = rawModel.trim(); + if (!modelName) { + continue; + } + baseEntries.push({ modelName, providerName }); + } + } + + const ids = baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`); + for (const profile of config.virtualModelProfiles ?? []) { + if (!isVisibleVirtualModelProfile(profile)) { + continue; + } + + for (const entry of baseEntries) { + for (const prefix of profile.match?.prefixes ?? []) { + const normalizedPrefix = prefix.trim(); + if (normalizedPrefix) { + ids.push(`${entry.providerName}/${normalizedPrefix}${entry.modelName}`); + } + } + for (const suffix of profile.match?.suffixes ?? []) { + const normalizedSuffix = suffix.trim(); + if (normalizedSuffix) { + ids.push(`${entry.providerName}/${entry.modelName}${normalizedSuffix}`); + } + } + } + + for (const alias of profile.match?.exactAliases ?? []) { + const normalizedAlias = alias.trim(); + if (!normalizedAlias) { + continue; + } + ids.push(fusionModelSelector(normalizedAlias)); + } + } + + return uniqueStrings(ids); +} + + +function gatewayModelOwner(id: string): string { + const separator = id.indexOf("/"); + return separator > 0 ? id.slice(0, separator).trim() || "ccr" : "ccr"; +} + + +function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscoverableModel[] { + const seen = new Set(); + const models: ClaudeCodeDiscoverableModel[] = []; + + const pushModel = (id: string, oneMillionContext: boolean) => { + const normalized = id.trim(); + if (!normalized) { + return; + } + const key = normalized.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + models.push({ id: normalized, oneMillionContext }); + }; + + for (const id of buildClaudeCodeDiscoverableModelIds(config)) { + pushModel(id, hasClaudeCodeOneMillionContextSuffix(id)); + const baseId = stripClaudeCodeOneMillionContextSuffix(id); + if (!hasClaudeCodeOneMillionContextSuffix(id) && findModelCatalogEntry(baseId)?.limits?.supports1MContext) { + pushModel(claudeCodeOneMillionContextModelId(baseId), true); + } + } + + return models; +} + + +function isVisibleVirtualModelProfile(profile: NonNullable[number]): boolean { + return profile.enabled !== false && + profile.materialization?.enabled !== false && + profile.materialization?.includeInGatewayModels !== false; +} + + +function resolveClaudeCodeDiscoveredModelId(model: string | undefined, config: AppConfig): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized || !normalized.toLowerCase().startsWith("claude-")) { + return undefined; + } + + if (isConfiguredGatewayModelSelector(normalized, config)) { + return undefined; + } + + const unprefixed = normalized.slice("claude-".length); + if (isConfiguredGatewayModelSelector(unprefixed, config)) { + return unprefixed; + } + + const withoutOneMillionContextSuffix = stripClaudeCodeOneMillionContextSuffix(unprefixed); + return withoutOneMillionContextSuffix !== unprefixed && + isConfiguredGatewayModelSelector(withoutOneMillionContextSuffix, config) + ? withoutOneMillionContextSuffix + : undefined; +} + + +export function resolveGatewayPublicModelId(model: string | undefined, config: AppConfig): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized || !normalized.toLowerCase().startsWith("claude-")) { + return undefined; + } + if (isConfiguredGatewayModelSelector(normalized, config)) { + return undefined; + } + return resolveClaudeCodeDiscoveredModelId(normalized, config) ?? + resolveClaudeAppGatewayRouteModel(normalized, config, claudeAppGatewayModelRouteOptions); +} + + +function isConfiguredGatewayModelSelector(model: string, config: AppConfig): boolean { + const normalized = normalizeRouteSelector(model)?.toLowerCase(); + if (!normalized) { + return false; + } + + for (const id of buildClaudeCodeDiscoverableModelIds(config)) { + if (id.toLowerCase() === normalized) { + return true; + } + } + + for (const provider of config.Providers) { + if (provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized)) { + return true; + } + } + + return false; +} + + +function claudeCodeDiscoveryModelId(value: string): string { + return value.toLowerCase().startsWith("claude-") ? value : `claude-${value}`; +} + + +function claudeCodeOneMillionContextModelId(id: string): string { + return hasClaudeCodeOneMillionContextSuffix(id) ? id : `${id}${claudeCodeOneMillionContextSuffix}`; +} + + +function hasClaudeCodeOneMillionContextSuffix(id: string): boolean { + return id.trim().toLowerCase().endsWith(claudeCodeOneMillionContextSuffix); +} + + +function stripClaudeCodeOneMillionContextSuffix(id: string): string { + return id.trim().replace(/\[1m\]$/i, "").trim(); +} + + +function formatClaudeCodeModelDisplayName( + id: string, + entry?: ModelCatalogEntry, + oneMillionContext = hasClaudeCodeOneMillionContextSuffix(id) +): string { + if (entry?.displayName) { + return oneMillionContext ? `${entry.displayName} (1M context)` : entry.displayName; + } + + const normalized = stripClaudeCodeOneMillionContextSuffix(id.replace(/^claude-/i, "")); + const model = normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized; + const words = model + .split(/[-_]+/) + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => (/^\d+$/.test(part) ? part : part.slice(0, 1).toUpperCase() + part.slice(1))); + const displayName = ["Claude", ...words].filter(Boolean).join(" "); + return oneMillionContext ? `${displayName} (1M context)` : displayName; +} + + +function createClaudeCodeModelCapabilities( + entry?: ModelCatalogEntry, + options: { maxInputTokens?: number; oneMillionContext?: boolean } = {} +): Record { + if (!entry) { + return createDefaultClaudeCodeModelCapabilities(); + } + + const capabilities = entry.capabilities ?? {}; + const inputModalities = new Set((entry.modalities?.input ?? []).map((item) => item.toLowerCase())); + const outputModalities = new Set((entry.modalities?.output ?? []).map((item) => item.toLowerCase())); + const supportsReasoning = readCatalogCapability(capabilities, "reasoning"); + const supportsImageInput = readCatalogCapability(capabilities, "imageInput") || inputModalities.has("image"); + const supportsPdfInput = readCatalogCapability(capabilities, "pdfInput") || inputModalities.has("pdf"); + const supportsStructuredOutput = + readCatalogCapability(capabilities, "structuredOutput") || + readCatalogCapability(capabilities, "nativeStructuredOutput") || + readCatalogCapability(capabilities, "responseSchema"); + const supportsCodeExecution = readCatalogCapability(capabilities, "codeExecution"); + const supportsAdaptiveThinking = readCatalogCapability(capabilities, "adaptiveThinking"); + const supportsToolUse = + readCatalogCapability(capabilities, "toolCalling") || + readCatalogCapability(capabilities, "functionCalling"); + const supportsBatch = readCatalogCapability(capabilities, "batch"); + const supportsCitations = readCatalogCapability(capabilities, "citations"); + const supportsAudioInput = readCatalogCapability(capabilities, "audioInput") || inputModalities.has("audio"); + const supportsAudioOutput = readCatalogCapability(capabilities, "audioOutput") || outputModalities.has("audio"); + const supportsVideoInput = readCatalogCapability(capabilities, "videoInput") || inputModalities.has("video"); + const maxInputTokens = options.maxInputTokens ?? modelCatalogMaxInputTokens(entry); + const supportsOneMillionContext = Boolean(entry.limits?.supports1MContext); + + return { + audio_input: { supported: supportsAudioInput }, + audio_output: { supported: supportsAudioOutput }, + batch: { supported: supportsBatch }, + citations: { supported: supportsCitations }, + code_execution: { supported: supportsCodeExecution }, + context_management: { + clear_thinking_20251015: { supported: supportsReasoning }, + clear_tool_uses_20250919: { supported: supportsToolUse }, + compact_20260112: { supported: maxInputTokens > 0 }, + max_input_tokens: maxInputTokens, + supported: maxInputTokens > 0 + }, + context_window: { + max_input_tokens: maxInputTokens, + supported: maxInputTokens > 0, + supports_1m_context: supportsOneMillionContext, + one_million_context_variant: options.oneMillionContext === true + }, + effort: { + high: { supported: supportsReasoning }, + low: { supported: supportsReasoning }, + max: { supported: supportsReasoning }, + medium: { supported: supportsReasoning }, + supported: supportsReasoning, + xhigh: { supported: supportsReasoning } + }, + image_input: { supported: supportsImageInput }, + pdf_input: { supported: supportsPdfInput }, + structured_outputs: { supported: supportsStructuredOutput }, + thinking: { + supported: supportsReasoning, + types: { + adaptive: { supported: supportsAdaptiveThinking }, + enabled: { supported: supportsReasoning } + } + }, + tool_use: { supported: supportsToolUse }, + video_input: { supported: supportsVideoInput } + }; +} + + +function createDefaultClaudeCodeModelCapabilities(): Record { + return { + batch: { supported: true }, + citations: { supported: true }, + code_execution: { supported: true }, + context_management: { + clear_thinking_20251015: { supported: true }, + clear_tool_uses_20250919: { supported: true }, + compact_20260112: { supported: true }, + supported: true + }, + effort: { + high: { supported: true }, + low: { supported: true }, + max: { supported: true }, + medium: { supported: true }, + supported: true, + xhigh: { supported: true } + }, + image_input: { supported: true }, + pdf_input: { supported: true }, + structured_outputs: { supported: true }, + thinking: { + supported: true, + types: { + adaptive: { supported: true }, + enabled: { supported: true } + } + } + }; +} + + +function normalizeGatewayPathname(path: string): string { + const normalized = path.trim().replace(/\/+$/, ""); + return normalized || "/"; +} + + +function isClaudeCodeUserAgent(headers: IncomingHttpHeaders): boolean { + const userAgent = readHeader(headers["user-agent"]); + if (!userAgent) { + return false; + } + const normalized = userAgent.toLowerCase(); + return normalized.includes("claude"); +} + + +function isClaudeAppApiKey(apiKey: ApiKeyConfig | undefined): boolean { + const name = apiKey?.name?.trim().toLowerCase(); + return name === "claude app"; +} diff --git a/packages/core/src/gateway/http/body.ts b/packages/core/src/gateway/http/body.ts new file mode 100644 index 00000000..fdeb1451 --- /dev/null +++ b/packages/core/src/gateway/http/body.ts @@ -0,0 +1,16 @@ +import { parseJsonObject } from "@ccr/core/gateway/http/io"; + +export function parseJsonObjectSafe(buffer: Buffer | undefined): Record | undefined { + if (!buffer || buffer.byteLength === 0) { + return undefined; + } + try { + return parseJsonObject(buffer); + } catch { + return undefined; + } +} + +export function serializeJsonBodyWithModel(body: Record, model: string): Buffer { + return Buffer.from(`${JSON.stringify({ ...body, model })}\n`, "utf8"); +} diff --git a/packages/core/src/gateway/http/io.ts b/packages/core/src/gateway/http/io.ts new file mode 100644 index 00000000..155a098c --- /dev/null +++ b/packages/core/src/gateway/http/io.ts @@ -0,0 +1,223 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from "node:http"; +import type { ApiKeyConfig } from "@ccr/core/contracts/app"; +import { ccrRemoteControlPathPrefix } from "@ccr/core/gateway/remote-control-service"; +import { coreGatewayAuthHeader, localObservabilityHeaderNames, proxyHeaderDenyList, responseHeaderDenyList } from "@ccr/core/gateway/internal/shared"; + + +export function inferGatewayClient(apiKey: ApiKeyConfig | undefined, headers: IncomingHttpHeaders): string | undefined { + const explicit = + readHeader(headers["x-ccr-client"]) ?? + readHeader(headers["x-client-name"]) ?? + readHeader(headers["x-forwarded-client-cert"]); + if (explicit) { + return explicit; + } + + const apiKeyClient = apiKey?.name?.trim() || apiKey?.id?.trim(); + const userAgentClient = inferClientFromUserAgent(headers); + if (readHeader(headers["x-ccr-proxy-mode"]) === "gateway") { + return userAgentClient ?? apiKeyClient; + } + return apiKeyClient ?? userAgentClient; +} + + +function inferClientFromUserAgent(headers: IncomingHttpHeaders): string | undefined { + const userAgent = readHeader(headers["user-agent"]); + if (!userAgent) { + return undefined; + } + + const normalized = userAgent.toLowerCase(); + if (normalized.includes("codex")) { + return "Codex"; + } + if (normalized.includes("@anthropic-ai/claude-code") || normalized.includes("claude-code") || normalized.includes("claude code")) { + return "Claude Code"; + } + if (normalized.includes("claude")) { + return "Claude"; + } + if (normalized.includes("curl")) { + return "curl"; + } + if (normalized.includes("python")) { + return "Python"; + } + if (normalized.includes("node")) { + return "Node.js"; + } + if (normalized.includes("chrome")) { + return "Google Chrome"; + } + if (normalized.includes("safari") && !normalized.includes("chrome")) { + return "Safari"; + } + return userAgent.split(/[ /]/)[0]?.trim() || undefined; +} + + +export function readAuthToken(headers: IncomingHttpHeaders): string | undefined { + const raw = readHeader(headers.authorization) || readHeader(headers["x-api-key"]); + if (!raw) { + return undefined; + } + return raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw; +} + + +export function readRemoteControlQueryAuthToken(request: IncomingMessage): string | undefined { + const url = new URL(request.url || "/", "http://127.0.0.1"); + if (url.pathname !== ccrRemoteControlPathPrefix && !url.pathname.startsWith(`${ccrRemoteControlPathPrefix}/`)) { + return undefined; + } + return url.searchParams.get("api_key")?.trim() || url.searchParams.get("key")?.trim() || undefined; +} + + +export function forwardHeaders(headers: IncomingHttpHeaders): Record { + const forwarded: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const normalized = key.toLowerCase(); + if (proxyHeaderDenyList.has(normalized) || value === undefined) { + continue; + } + forwarded[normalized] = Array.isArray(value) ? value.join(",") : String(value); + } + return forwarded; +} + + +export function stripLocalGatewayAuthHeaders(headers: Record): void { + delete headers.authorization; + delete headers["x-api-key"]; + delete headers["api-key"]; +} + + +export function omitLocalObservabilityHeaders(headers: Record): Record { + const forwarded = { ...headers }; + for (const name of localObservabilityHeaderNames) { + delete forwarded[name]; + } + return forwarded; +} + + +export function withCoreGatewayAuthHeader(headers: Record, token: string): Record { + if (!token) { + throw new Error("Core gateway auth token is not initialized."); + } + return { + ...headers, + [coreGatewayAuthHeader]: token + }; +} + + +export function filteredResponseHeaders(headers: Headers): Array<[string, string]> { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + if (!responseHeaderDenyList.has(key.toLowerCase())) { + entries.push([key, value]); + } + }); + return entries; +} + + +export function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + + +export function abortSignalMessage(signal: AbortSignal): string { + const reason = signal.reason as unknown; + if (reason instanceof Error && reason.message) { + return reason.message; + } + if (typeof reason === "string" && reason.trim()) { + return reason.trim(); + } + return "Upstream request was aborted."; +} + + +export function parseJsonObject(buffer: Buffer): Record { + if (buffer.length === 0) { + return {}; + } + const parsed = JSON.parse(buffer.toString("utf8")) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + throw new Error("Request body must be a JSON object."); +} + + +export function readHeader(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]?.trim(); + } + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + + +export function readRequestBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + + +export function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void { + response.writeHead(statusCode, { "content-type": "application/json" }); + response.end(`${JSON.stringify(payload)}\n`); +} + + +export function closeServer(server: Server): Promise { + return new Promise((resolve) => { + let settled = false; + let timeout: NodeJS.Timeout | undefined; + const finish = () => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + resolve(); + }; + + try { + server.closeIdleConnections?.(); + timeout = setTimeout(() => { + server.closeAllConnections?.(); + finish(); + }, 800); + server.close(() => finish()); + } catch { + finish(); + } + }); +} + + +export function shouldSendBody(method: string | undefined): boolean { + const normalized = method?.toUpperCase(); + return normalized !== "GET" && normalized !== "HEAD"; +} + + +export function shouldCaptureGatewayUsage(method: string, _path: string): boolean { + return shouldSendBody(method); +} + diff --git a/packages/core/src/gateway/http/request-handler.ts b/packages/core/src/gateway/http/request-handler.ts new file mode 100644 index 00000000..37403159 --- /dev/null +++ b/packages/core/src/gateway/http/request-handler.ts @@ -0,0 +1,168 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; +import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp"; +import { BROWSER_AUTOMATION_MCP_PATH, browserAutomationMcpEnabled } from "@ccr/core/mcp/toolhub-config"; +import { pluginService } from "@ccr/core/plugins/service"; +import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; +import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service"; +import { authorize, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; +import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; +import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; +import { applyCors, endpoint, shouldServeGatewayRequest } from "@ccr/core/gateway/core-runtime/supervisor"; +import { rawTraceSyncPath } from "@ccr/core/gateway/internal/shared"; +import type { BrowserAutomationMcpIntegration } from "@ccr/core/gateway/internal/shared"; + +export type GatewayHttpRequestHandlerDependencies = { + getBrowserAutomationMcpIntegration: () => BrowserAutomationMcpIntegration | undefined; + getConfig: () => AppConfig | undefined; + getPlugin: () => ClaudeCodeRouterPlugin | undefined; + getStatus: () => { coreEndpoint: string; coreManagedExternally?: boolean; endpoint: string; state: string }; + handleRawTraceSync: (request: IncomingMessage, response: ServerResponse) => Promise; + proxyRequest: (request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) => Promise; +}; + +export class GatewayHttpRequestHandler { + constructor(private readonly dependencies: GatewayHttpRequestHandlerDependencies) {} + + private get browserAutomationMcpIntegration() { return this.dependencies.getBrowserAutomationMcpIntegration(); } + private get config() { return this.dependencies.getConfig(); } + private get plugin() { return this.dependencies.getPlugin(); } + private get status() { return this.dependencies.getStatus(); } + private handleRawTraceSync(request: IncomingMessage, response: ServerResponse) { return this.dependencies.handleRawTraceSync(request, response); } + private proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) { return this.dependencies.proxyRequest(request, response, path, apiKey); } + + async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + applyCors(response, this.config); + + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + + if (!this.config || !this.plugin) { + sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); + return; + } + + const path = request.url ? new URL(request.url, this.status.endpoint || "http://127.0.0.1").pathname : "/"; + if (path === rawTraceSyncPath) { + if (!shouldRecordRequestLogs(this.config)) { + sendJson(response, 202, { applied: false, disabled: true, ok: true }); + return; + } + await this.handleRawTraceSync(request, response); + return; + } + + if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) { + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + await ccrRemoteControlService.handleRequest({ + endpoint: this.status.endpoint, + path, + readBody: readRequestBody, + request, + response, + sendJson + }); + return; + } + + if (path === BROWSER_AUTOMATION_MCP_PATH || path === `${BROWSER_AUTOMATION_MCP_PATH}/`) { + if (!browserAutomationMcpEnabled(this.config)) { + sendJson(response, 404, { + error: { + message: "CCR browser automation MCP is disabled." + } + }); + return; + } + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + if (!this.browserAutomationMcpIntegration) { + sendJson(response, 503, { + error: { + message: "CCR browser automation MCP is only available in the Electron desktop app." + } + }); + return; + } + await this.browserAutomationMcpIntegration.handleBrowserAutomationMcpRequest(request, response); + return; + } + + if (isNetworkCaptureMcpPath(path)) { + if (!this.config.proxy.captureNetwork) { + sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } }); + return; + } + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + await handleNetworkCaptureMcpRequest(request, response); + return; + } + + const pluginRoute = pluginService.matchGatewayRoute(request.method, path); + if (pluginRoute) { + if (pluginRoute.auth !== "none") { + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + } + await pluginService.handleGatewayRoute(pluginRoute, request, response); + return; + } + + if (!shouldServeGatewayRequest(this.config, request)) { + sendJson(response, 503, { error: { message: "Gateway runtime is disabled." } }); + return; + } + + if (path === "/health") { + sendJson(response, 200, { + core: this.status.coreEndpoint, + coreManagedExternally: this.status.coreManagedExternally || undefined, + status: this.status.state, + timestamp: new Date().toISOString() + }); + return; + } + + if (path === "/") { + sendJson(response, 200, { + core: "next-ai-gateway", + endpoints: ["POST /mcp", "POST /v1/messages", "POST /v1/messages/count_tokens", "GET /v1/models"], + name: "claude-code-router", + plugin: "claude-code-router", + wrapperPlugins: this.config.plugins.filter((plugin) => plugin.enabled !== false).map((plugin) => plugin.id) + }); + return; + } + + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + + if (request.method === "POST" && path === "/v1/messages/count_tokens") { + const requestBody = await readRequestBody(request); + const body = parseJsonObject(requestBody); + if (!reserveApiKeyLimits(authorization.apiKey, request, response, requestBody)) { + return; + } + sendJson(response, 200, this.plugin.countTokens(body)); + return; + } + + await this.proxyRequest(request, response, path, authorization.apiKey); + } +} + diff --git a/packages/core/src/gateway/internal/clock.ts b/packages/core/src/gateway/internal/clock.ts new file mode 100644 index 00000000..59d80191 --- /dev/null +++ b/packages/core/src/gateway/internal/clock.ts @@ -0,0 +1,3 @@ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/core/src/gateway/internal/collections.ts b/packages/core/src/gateway/internal/collections.ts new file mode 100644 index 00000000..ee53a1f5 --- /dev/null +++ b/packages/core/src/gateway/internal/collections.ts @@ -0,0 +1,17 @@ +export function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(Number.isFinite(value) ? value : min))); +} + +export function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const item = value?.trim(); + if (!item || seen.has(item)) { + continue; + } + seen.add(item); + result.push(item); + } + return result; +} diff --git a/packages/core/src/gateway/internal/shared.ts b/packages/core/src/gateway/internal/shared.ts new file mode 100644 index 00000000..25f54652 --- /dev/null +++ b/packages/core/src/gateway/internal/shared.ts @@ -0,0 +1,313 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { createRequire } from "node:module"; +import type { ApiKeyConfig, GatewayMcpServerConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelFusionWebSearchProvider } from "@ccr/core/contracts/app"; +import type { ClaudeAppGatewayModelRouteOptions } from "@ccr/core/agents/claude-app/gateway-routes"; +import type { RouteModelRef } from "@ccr/core/routing/contracts"; +import { findModelCatalogEntry } from "@ccr/core/gateway/model-catalog"; + + +export type CoreGatewayProvider = { + apikey?: string; + baseurl?: string; + billing?: unknown; + extraBody?: unknown; + extraHeaders?: unknown; + models: string[]; + name: string; + type: GatewayProviderProtocol; +}; + + +export const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "brave"; + +export const fusionModelProviderName = "Fusion"; + +export const claudeCodeOneMillionContextSuffix = "[1m]"; + +export const claudeAppGatewayModelRouteOptions: ClaudeAppGatewayModelRouteOptions = { + displayName: (model) => findModelCatalogEntry(model)?.displayName, + supportsOneMillionContext: (model) => Boolean(findModelCatalogEntry(model)?.limits?.supports1MContext) +}; + + +export type ApiKeyAuthorizationResult = + | { ok: true; apiKey?: ApiKeyConfig } + | { ok: false }; + + +export type ApiKeyLimitUsage = { + imageCount: number; + totalTokens: number; +}; + + +export type ApiKeyLimitRule = { + limit: number; + metric: "images" | "requests" | "tokens"; + name: string; + requested: number; + windowMs: number; +}; + + +export type GatewayStopOptions = { + proxyRestoreTimeoutMs?: number; +}; + + +export type HostedWebSearchProtocolContext = { + maxUses?: number; + protocol: GatewayProviderProtocol; + queryHint?: string; + records?: BrowserWebSearchProtocolRecord[]; + requestId: string; + sinceMs: number; + toolName: string; +}; + + +export type AnthropicWebSearchProtocolContext = HostedWebSearchProtocolContext; + + +export type ClaudeCodeWebSearchContinuationContext = { + queryHint?: string; + sinceMs: number; + toolName: string; +}; + + +export type BrowserWebSearchMcpRegistration = { + env?: Record; + name: string; + resultCount?: number; + timeoutMs?: number; + toolName: string; +}; + + +export type BrowserWebSearchProtocolResult = { + content?: string; + diagnostics?: 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; +}; + + +export type BrowserAutomationMcpIntegration = { + handleBrowserAutomationMcpRequest: (request: IncomingMessage, response: ServerResponse) => Promise; + stopBrowserAutomationMcpServer: () => Promise; +}; + + +export type CoreGatewayHealth = { + runtimeId?: string; + status?: string; +}; + + +export type ManagedGatewayRuntimeMarker = { + generatedConfigFile?: unknown; + gatewayEntry?: unknown; + pid?: unknown; + runtimeId?: unknown; + startedAt?: unknown; +}; + + +export type ApiKeyWindowCounter = { + expiresAt: number; + value: number; + windowStart: number; +}; + + +export type RawTracePartText = { + contentType?: string; + text: string; +}; + + +export type CursorOpenAICompatContext = { + systemPrompt?: string; + toolChoice?: unknown; + tools: unknown[]; +}; + + +export type CursorOpenAICompatPreparation = { + body?: Buffer; + diagnostic: "fallback-injected" | "simplified-missing-context"; +}; + + +export type ClaudeCodeDiscoverableModel = { + id: string; + oneMillionContext: boolean; +}; + + +export type UpstreamAttempt = { + body?: Buffer; + credentialChain?: string[]; + credentialIds?: string[]; + credentialProtocol?: GatewayProviderProtocol; + headers?: Record; + index: number; + logicalProvider?: string; + model?: string; + target?: RouteModelRef; +}; + + +export type UpstreamFailedAttempt = { + credentialChain?: string[]; + credentialIds?: string[]; + delayMs?: number; + error?: string; + model?: string; + statusCode?: number; +}; + + +export type UpstreamFetchResult = { + attempt: UpstreamAttempt; + failedAttempts: UpstreamFailedAttempt[]; + response: Response; +}; + + +export type ProviderCredentialRoutingTarget = { + body?: Buffer; + model?: string; + provider: GatewayProviderConfig; + protocol: GatewayProviderProtocol; + source: "header" | "model" | "plan"; +}; + + +export class UpstreamRequestError extends Error { + readonly attempt?: UpstreamAttempt; + readonly failedAttempts: UpstreamFailedAttempt[]; + + constructor(message: string, options: { attempt?: UpstreamAttempt; cause?: unknown; failedAttempts: UpstreamFailedAttempt[] }) { + super(message); + this.name = "UpstreamRequestError"; + this.attempt = options.attempt; + this.cause = options.cause; + this.failedAttempts = options.failedAttempts; + } +} + + +export const requireFromHere = createRequire(__filename); + +export const claudeCodeOauthBetaHeader = "anthropic-beta"; + +export const claudeCodeOauthRequiredBeta = "oauth-2025-04-20"; + +export const coreGatewayAuthHeader = "x-ccr-core-auth"; + +export const coreGatewayAuthTokenEnv = "CCR_CORE_GATEWAY_AUTH_TOKEN"; + +export const clientClosedRequestStatusCode = 499; + +export const clientDisconnectMessage = "Client connection closed before response completed."; + +export const localObservabilityHeaderNames = new Set([ + "x-ccr-claude-app-model-rewrite", + "x-ccr-codex-patch-bridge", + "x-ccr-claude-model-discovery", + "x-ccr-cursor-openai-compat", + "x-ccr-logical-provider", + "x-ccr-provider-credential-chain", + "x-ccr-provider-credential-saturated" +]); + +export const proxyHeaderDenyList = new Set(["connection", coreGatewayAuthHeader, "host", "upgrade"]); + +export const responseHeaderDenyList = new Set(["connection", "content-encoding", "transfer-encoding"]); + +export const maxUsageCaptureBytes = 8 * 1024 * 1024; + +export const apiKeyLimitCounterRetentionWindows = 2; + +export const gatewayRuntimeMarkerFile = "gateway-runtime.json"; + +export const rawTraceSyncHeader = "x-ccr-raw-trace-token"; + +export const virtualApplyPatchToolName = "virtual_apply_patch"; + + +export const rawTraceSyncPath = "/__ccr/raw-trace-sync"; + +export const gatewayEntryOverrideEnv = "CCR_GATEWAY_ENTRY"; + +export const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"]; + +export const codexPatchBridgeInstructionText = [ + "When modifying files, call virtual_apply_patch.", + "Do not use exec_command or write_stdin to edit files, including shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar shell-based edits.", + "Use exec_command only for reading files, listing/searching, running builds/tests, starting servers, and other commands that are not manual file edits." +].join(" "); + +export const codexPatchBridgeShellToolGuidance = [ + "When virtual_apply_patch is available, do not use this tool to edit files.", + "Do not write files with shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar commands.", + "Use virtual_apply_patch for manual file changes." +].join(" "); + +export const virtualApplyPatchLarkGrammar = [ + "start: begin_patch hunk+ end_patch", + "begin_patch: \"*** Begin Patch\" LF", + "end_patch: \"*** End Patch\" LF?", + "", + "hunk: add_hunk | delete_hunk | update_hunk", + "add_hunk: \"*** Add File: \" filename LF add_line+", + "delete_hunk: \"*** Delete File: \" filename LF", + "update_hunk: \"*** Update File: \" filename LF change_move? change?", + "", + "filename: /(.+)/", + "add_line: \"+\" /(.*)/ LF -> line", + "", + "change_move: \"*** Move to: \" filename LF", + "change: (change_context | change_line)+ eof_line?", + "change_context: (\"@@\" | \"@@ \" /(.+)/) LF", + "change_line: (\"+\" | \"-\" | \" \") /(.*)/ LF", + "eof_line: \"*** End of File\" LF", + "", + "%import common.LF" +].join("\n"); + +export const gatewayProviderProtocolFallbackOrder: GatewayProviderProtocol[] = [ + "anthropic_messages", + "openai_chat_completions", + "openai_responses", + "gemini_generate_content", + "gemini_interactions" +]; + +export const privateDirMode = 0o700; + +export const privateFileMode = 0o600; diff --git a/packages/core/src/gateway/internal/value.ts b/packages/core/src/gateway/internal/value.ts new file mode 100644 index 00000000..fe4e64d7 --- /dev/null +++ b/packages/core/src/gateway/internal/value.ts @@ -0,0 +1,23 @@ +/** Runtime-safe readers for untyped gateway and plugin payloads. */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function rawStringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function stringListValue(value: unknown): string[] { + return Array.isArray(value) + ? value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) + : []; +} + +export function numberValue(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) ? Math.trunc(number) : undefined; +} diff --git a/packages/core/src/gateway/limits/window-limiter.ts b/packages/core/src/gateway/limits/window-limiter.ts new file mode 100644 index 00000000..130659a3 --- /dev/null +++ b/packages/core/src/gateway/limits/window-limiter.ts @@ -0,0 +1,112 @@ +import type { ApiKeyLimitConfig } from "@ccr/core/contracts/app"; +import { parseJsonObject } from "@ccr/core/gateway/http/io"; +import { isRecord } from "@ccr/core/gateway/internal/value"; +import { + type ApiKeyLimitRule, + type ApiKeyLimitUsage, + type ApiKeyWindowCounter +} from "@ccr/core/gateway/internal/shared"; + +const apiKeyLimitCounterRetentionWindows = 2; +const apiKeyLimitCounters = new Map(); + +export function limitRules(limits: ApiKeyLimitConfig | undefined, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { + if (!limits) { + return []; + } + const rules: ApiKeyLimitRule[] = []; + addLimitRule(rules, "requests", "requests", limits.windowMs ?? 60_000, limits.maxRequests, 1); + addLimitRule(rules, "rpm", "requests", 60_000, limits.rpm, 1); + addLimitRule(rules, "rph", "requests", 3_600_000, limits.rph, 1); + addLimitRule(rules, "rpd", "requests", 86_400_000, limits.rpd, 1); + addLimitRule(rules, "tpm", "tokens", 60_000, limits.tpm, usage.totalTokens); + addLimitRule(rules, "tph", "tokens", 3_600_000, limits.tph, usage.totalTokens); + addLimitRule(rules, "tpd", "tokens", 86_400_000, limits.tpd, usage.totalTokens); + addLimitRule(rules, "ipm", "images", 60_000, limits.ipm, usage.imageCount); + addLimitRule(rules, "iph", "images", 3_600_000, limits.iph, usage.imageCount); + addLimitRule(rules, "ipd", "images", 86_400_000, limits.ipd, usage.imageCount); + addLimitRule(rules, "quota", "tokens", limits.quotaWindowMs ?? 86_400_000, limits.maxTokens, usage.totalTokens); + return rules; +} + +export function readWindowCounter( + key: string, + windowStart: number, + windowMs: number, + now = Date.now() +): ApiKeyWindowCounter { + pruneExpiredCounters(now); + const existing = apiKeyLimitCounters.get(key); + if (existing && existing.windowStart === windowStart) { + return existing; + } + const fresh = { + expiresAt: windowStart + windowMs * apiKeyLimitCounterRetentionWindows, + value: 0, + windowStart + }; + apiKeyLimitCounters.set(key, fresh); + return fresh; +} + +export function estimateLimitUsage(method: string, requestBody: Buffer): ApiKeyLimitUsage { + if (method.toUpperCase() !== "POST" || requestBody.byteLength === 0) { + return { imageCount: 0, totalTokens: 0 }; + } + + const body = parseJsonObject(requestBody); + const inputCharacters = countUnknownCharacters(body.messages) + countUnknownCharacters(body.system) + countUnknownCharacters(body.tools); + const inputTokens = Math.ceil(inputCharacters / 4); + const outputTokens = readPositiveNumber(body.max_tokens) ?? readPositiveNumber(body.max_output_tokens) ?? 1024; + return { + imageCount: countImageInputs(body), + totalTokens: Math.max(1, inputTokens + outputTokens) + }; +} + +function addLimitRule( + rules: ApiKeyLimitRule[], + name: string, + metric: ApiKeyLimitRule["metric"], + windowMs: number, + limit: number | undefined, + requested: number +): void { + if (!limit || limit <= 0 || windowMs <= 0) { + return; + } + rules.push({ limit, metric, name, requested, windowMs }); +} + +function pruneExpiredCounters(now: number): void { + for (const [key, counter] of apiKeyLimitCounters) { + if (counter.expiresAt <= now) { + apiKeyLimitCounters.delete(key); + } + } +} + +function countUnknownCharacters(value: unknown): number { + if (value === undefined || value === null) return 0; + if (typeof value === "string") return value.length; + try { + return JSON.stringify(value)?.length || 0; + } catch { + return String(value).length; + } +} + +function countImageInputs(value: unknown): number { + if (Array.isArray(value)) { + return value.reduce((sum, item) => sum + countImageInputs(item), 0); + } + if (!isRecord(value)) return 0; + const type = typeof value.type === "string" ? value.type.toLowerCase() : ""; + const isImage = type === "image" || type === "image_url" || type === "input_image" || value.image_url !== undefined || value.input_image !== undefined; + return (isImage ? 1 : 0) + Object.values(value).reduce((sum, item) => sum + countImageInputs(item), 0); +} + +function readPositiveNumber(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined; +} diff --git a/packages/core/src/gateway/request/pipeline.ts b/packages/core/src/gateway/request/pipeline.ts new file mode 100644 index 00000000..b24e2972 --- /dev/null +++ b/packages/core/src/gateway/request/pipeline.ts @@ -0,0 +1,475 @@ +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; +import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; +import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store"; +import { recordGatewayUsageCapture } from "@ccr/core/usage/store"; +import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; +import { adaptRouteRequestBody, restoreRouteRequestBody } from "@ccr/core/routing/protocol-adapter"; +import { reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; +import { recordProviderCredentialOutcome } from "@ccr/core/providers/credential-pool"; +import { codexApplyPatchBridgeResponseStream, prepareCodexApplyPatchBridgeRequest } from "@ccr/core/gateway/features/codex-patch-bridge"; +import { prepareCursorOpenAICompatChatBody } from "@ccr/core/gateway/features/cursor-compat"; +import { browserWebSearchUnavailableMessage } from "@ccr/core/mcp/fusion-config"; +import { filteredResponseHeaders, formatError, forwardHeaders, inferGatewayClient, parseJsonObject, readRequestBody, sendJson, shouldCaptureGatewayUsage, shouldSendBody, stripLocalGatewayAuthHeaders } from "@ccr/core/gateway/http/io"; +import { createGatewayModelsResponse, prepareClaudeAppFallbackModelRequest, prepareClaudeCodeDiscoveredModelRequest, shouldServeGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery"; +import { resolveProviderLogName, resolveResponseProviderProtocol, sanitizeHeaderValue } from "@ccr/core/providers/runtime-topology"; +import { createBodySampler, shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; +import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor"; +import { clientClosedRequestStatusCode, clientDisconnectMessage, UpstreamRequestError } from "@ccr/core/gateway/internal/shared"; +import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, UpstreamFetchResult } from "@ccr/core/gateway/internal/shared"; +import { applyProviderCapabilityRouting, cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor"; +import { shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints"; +import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, hostedWebSearchProtocolResponseStream, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/index"; + +export type GatewayRequestPipelineDependencies = { + getBrowserWebSearchMcpIntegration: () => BrowserWebSearchMcpIntegration | undefined; + getConfig: () => AppConfig | undefined; + getCoreAuthToken: () => string; + getPlugin: () => ClaudeCodeRouterPlugin | undefined; + getStatus: () => { coreEndpoint: string; endpoint: string }; + takePendingRawTraceUpdate: (requestId: string) => RequestLogRawTraceUpdateInput | undefined; +}; + +export class GatewayRequestPipeline { + constructor(private readonly dependencies: GatewayRequestPipelineDependencies) {} + + private get browserWebSearchMcpIntegration() { return this.dependencies.getBrowserWebSearchMcpIntegration(); } + private get config() { return this.dependencies.getConfig(); } + private get coreAuthToken() { return this.dependencies.getCoreAuthToken(); } + private get plugin() { return this.dependencies.getPlugin(); } + private get status() { return this.dependencies.getStatus(); } + private takePendingRawTraceUpdate(requestId: string) { return this.dependencies.takePendingRawTraceUpdate(requestId); } + + async proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig): Promise { + if (!this.config || !this.plugin) { + sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); + return; + } + + const headers = forwardHeaders(request.headers); + if (apiKey) { + stripLocalGatewayAuthHeaders(headers); + headers["x-auth-api-key-id"] = apiKey.id; + headers["x-auth-sub"] = apiKey.id; + } + const method = request.method ?? "GET"; + const requestBody = await readRequestBody(request); + const client = inferGatewayClient(apiKey, request.headers); + const cursorCompatPreparation = prepareCursorOpenAICompatChatBody(this.config, client, method, path, requestBody); + if (cursorCompatPreparation) { + headers["x-ccr-cursor-openai-compat"] = sanitizeHeaderValue(cursorCompatPreparation.diagnostic); + } + let bodyToForward: Buffer | undefined = cursorCompatPreparation?.body ?? requestBody; + let routeFallback = this.config.Router.fallback; + let routedModel: string | undefined; + let codexApplyPatchBridgeActive = false; + const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward); + if (claudeModelRewrite) { + headers["x-ccr-claude-model-discovery"] = sanitizeHeaderValue(claudeModelRewrite.diagnostic); + bodyToForward = claudeModelRewrite.body; + } + const claudeAppModelRewrite = prepareClaudeAppFallbackModelRequest(this.config, method, path, bodyToForward); + if (claudeAppModelRewrite) { + headers["x-ccr-claude-app-model-rewrite"] = sanitizeHeaderValue(claudeAppModelRewrite.diagnostic); + bodyToForward = claudeAppModelRewrite.body; + routedModel = claudeAppModelRewrite.routedModel; + } + if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { + return; + } + const startedAt = Date.now(); + const startedAtIso = new Date(startedAt).toISOString(); + const requestId = randomUUID(); + headers["x-client-request-id"] = requestId; + const requestUrl = new URL(request.url || path, this.status.endpoint || "http://127.0.0.1").toString(); + const upstreamAbortController = new AbortController(); + let clientDisconnected = false; + let responseCompleted = false; + let onClientDisconnect: (() => void) | undefined; + let onResponseFinish: (() => void) | undefined; + const handleClientDisconnect = () => { + if (responseCompleted || response.writableEnded) { + return; + } + if (!clientDisconnected) { + clientDisconnected = true; + upstreamAbortController.abort(new Error(clientDisconnectMessage)); + } + onClientDisconnect?.(); + }; + + response.once("finish", () => { + responseCompleted = true; + onResponseFinish?.(); + }); + response.once("close", handleClientDisconnect); + response.on("error", () => { + // Client-side write failures (EPIPE / ECONNRESET when the client closes + // mid-stream, common during tool execution) must not crash the main + // process as an Uncaught Exception. Swallow them here; the close handler + // above already records the disconnect via writeStreamLog. + handleClientDisconnect(); + }); + + const writeRequestLog = ( + statusCode: number, + responseHeaders: Headers, + responseBodyText = "", + responseBodyTruncated = false, + error?: string + ) => { + const config = this.config; + if (!config || !shouldRecordRequestLogs(config)) { + return; + } + void (async () => { + await recordGatewayRequestLog({ + client, + completedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + error, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(responseHeaders, config, routedModel), + providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), + requestBody: shouldSendBody(method) ? bodyToForward ?? Buffer.alloc(0) : Buffer.alloc(0), + requestHeaders: headers, + requestId, + responseBodyText, + responseBodyTruncated, + responseHeaders, + startedAt: startedAtIso, + statusCode, + url: requestUrl + }); + const pendingRawTraceUpdate = this.takePendingRawTraceUpdate(requestId); + if (pendingRawTraceUpdate) { + await updateGatewayRequestLogFromRawTrace(pendingRawTraceUpdate); + } + })(); + }; + + const shouldCaptureUsage = shouldCaptureGatewayUsage(method, path); + if (shouldServeGatewayModelsResponse(method, path)) { + const responseText = `${JSON.stringify(createGatewayModelsResponse(this.config, request.headers, apiKey))}\n`; + const modelHeaders = new Headers({ + "cache-control": "no-store, max-age=0", + "content-length": String(Buffer.byteLength(responseText)), + "content-type": "application/json; charset=utf-8", + "expires": "0", + "pragma": "no-cache" + }); + response.writeHead(200, Object.fromEntries(filteredResponseHeaders(modelHeaders))); + response.end(responseText); + return; + } + + if (shouldApplyGatewayRouting(method, path)) { + const adaptation = adaptRouteRequestBody(path, parseJsonObject(bodyToForward ?? requestBody)); + const routed = await this.plugin.routeRequest({ + body: adaptation.body, + headers: headers as Record, + method, + url: request.url ?? path + }); + const serialized = Buffer.from(`${JSON.stringify(restoreRouteRequestBody(routed.body, adaptation))}\n`, "utf8"); + headers["content-type"] = "application/json"; + headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason); + headers["x-ccr-route-source"] = routed.decision.source; + if (routed.decision.diagnostics.length > 0) { + headers["x-ccr-route-diagnostics"] = String(routed.decision.diagnostics.length); + } + routeFallback = routed.decision.fallback ?? routeFallback; + if (routed.decision.model) { + headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model); + routedModel = routed.decision.model; + } + bodyToForward = serialized; + } + + const codexApplyPatchBridgeRequest = prepareCodexApplyPatchBridgeRequest({ + body: bodyToForward, + config: this.config, + headers: request.headers, + method, + path, + routedModel + }); + if (codexApplyPatchBridgeRequest) { + bodyToForward = codexApplyPatchBridgeRequest.body; + codexApplyPatchBridgeActive = true; + headers["x-ccr-codex-patch-bridge"] = sanitizeHeaderValue(codexApplyPatchBridgeRequest.diagnostic); + headers["content-type"] = "application/json"; + } + + const providerCapabilityRouting = applyProviderCapabilityRouting({ + body: bodyToForward, + config: this.config, + fallback: routeFallback, + headers, + path, + routedModel + }); + bodyToForward = providerCapabilityRouting.body; + 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 message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); + const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); + const responseBody = JSON.stringify({ error: { message } }); + writeRequestLog(503, responseHeaders, responseBody, false, message); + sendJson(response, 503, { error: { message } }); + return; + } + + 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; + } + } + } + + 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; + + try { + upstreamResult = await fetchUpstreamWithFallback({ + body: bodyToForward, + config: this.config, + fallback: routeFallback, + headers, + method, + path, + routedModel, + coreAuthToken: this.coreAuthToken, + signal: upstreamAbortController.signal, + upstreamUrl + }); + } catch (error) { + const message = formatError(error); + if (error instanceof UpstreamRequestError) { + bodyToForward = error.attempt?.body ?? bodyToForward; + routedModel = error.attempt?.model ?? routedModel; + } + if (clientDisconnected || upstreamAbortController.signal.aborted) { + writeRequestLog(clientClosedRequestStatusCode, new Headers(), "", false, clientDisconnectMessage); + return; + } + if (shouldCaptureUsage) { + void recordGatewayUsageCapture({ + bodyText: "", + client, + durationMs: Date.now() - startedAt, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(new Headers(), this.config, routedModel), + providerProtocol: resolveResponseProviderProtocol(new Headers(), this.config), + requestId, + responseHeaders: new Headers(), + statusCode: 502 + }); + } + writeRequestLog(502, new Headers(), "", false, message); + throw error; + } + + bodyToForward = upstreamResult.attempt.body ?? bodyToForward; + routedModel = upstreamResult.attempt.model ?? routedModel; + const responseHeaders = rewriteCapabilityResponseHeaders( + // Copy into a mutable Headers instance: upstream fetch Response.headers + // can be immutable (TypeError: immutable on .delete/.set), and + // mergeFallbackResponseHeaders returns the original object as-is when + // no fallback occurred. Codex apply_patch / web-search paths call + // .delete("content-length") below, which would otherwise throw and + // surface as a 502. + new Headers(mergeFallbackResponseHeaders(upstreamResponseHeaders(upstreamResult), upstreamResult)), + this.config + ); + const upstreamResponse = upstreamResult.response; + if (clientDisconnected || upstreamAbortController.signal.aborted) { + await cancelResponseBody(upstreamResponse); + writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); + return; + } + if (codexApplyPatchBridgeActive) { + responseHeaders.delete("content-length"); + } + const hostedWebSearchResponseContentType = responseHeaders.get("content-type")?.toLowerCase() ?? ""; + if ( + hostedWebSearchProtocolContext && + (hostedWebSearchResponseContentType.includes("application/json") || + hostedWebSearchResponseContentType.includes("text/event-stream")) && + (this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || this.browserWebSearchMcpIntegration?.runBrowserWebSearch) + ) { + responseHeaders.delete("content-length"); + } + recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders); + if (clientDisconnected || response.destroyed) { + await cancelResponseBody(upstreamResponse); + writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); + return; + } + response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders))); + if (!upstreamResponse.body) { + if (shouldCaptureUsage) { + void recordGatewayUsageCapture({ + bodyText: "", + client, + durationMs: Date.now() - startedAt, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), + providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), + requestId, + responseHeaders, + statusCode: upstreamResponse.status + }); + } + writeRequestLog(upstreamResponse.status, responseHeaders); + response.end(); + return; + } + + const upstreamBody = Readable.fromWeb(upstreamResponse.body as unknown as import("node:stream/web").ReadableStream); + const patchedResponseBody = codexApplyPatchBridgeActive + ? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders) + : upstreamBody; + const responseBody = hostedWebSearchProtocolContext + ? hostedWebSearchProtocolResponseStream( + patchedResponseBody, + responseHeaders, + hostedWebSearchProtocolContext, + this.browserWebSearchMcpIntegration + ) + : patchedResponseBody; + const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, responseBody]); + const sampler = createBodySampler(); + const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined); + let streamDetectedError: string | undefined; + let upstreamStreamEnded = false; + let logRecorded = false; + const writeStreamLog = (error?: string) => { + if (logRecorded) { + return; + } + logRecorded = true; + writeRequestLog( + clientDisconnected ? clientClosedRequestStatusCode : upstreamResponse.status, + responseHeaders, + sampler.read(), + sampler.isTruncated(), + error ?? streamDetectedError + ); + }; + onClientDisconnect = () => { + writeStreamLog(clientDisconnectMessage); + responseBody.unpipe(response); + destroyResponseStreams(responseStreams); + }; + onResponseFinish = () => { + if (upstreamStreamEnded) { + writeStreamLog(); + } + }; + const onResponseStreamError = (error: Error) => { + streamDetectedError ??= sseErrorDetector.finish(); + writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error)); + }; + for (const stream of responseStreams) { + stream.on("error", onResponseStreamError); + } + responseBody.on("data", (chunk) => { + sampler.append(chunk); + streamDetectedError ??= sseErrorDetector.append(chunk); + }); + responseBody.once("end", () => { + upstreamStreamEnded = true; + streamDetectedError ??= sseErrorDetector.finish(); + if (responseCompleted || response.writableEnded) { + writeStreamLog(); + } + }); + if (shouldCaptureUsage) { + responseBody.once("end", () => { + void recordGatewayUsageCapture({ + bodyText: sampler.read(), + client, + durationMs: Date.now() - startedAt, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), + providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), + requestId, + responseHeaders, + statusCode: upstreamResponse.status + }); + }); + } + if (clientDisconnected || response.destroyed) { + onClientDisconnect(); + return; + } + responseBody.pipe(response); + } +} + diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index a9db98e8..f80d7366 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -1,8587 +1,15 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { randomBytes, randomUUID } from "node:crypto"; -import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { createRequire } from "node:module"; -import { networkInterfaces } from "node:os"; -import { Readable, Transform } from "node:stream"; -import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "node:path"; -import type { - ApiKeyConfig, - ApiKeyLimitConfig, - AppConfig, - GatewayMcpServerConfig, - GatewayNetworkEndpoint, - GatewayProviderCapability, - GatewayProviderConfig, - GatewayProviderProtocol, - ProviderCredentialConfig, - GatewayStatus, - RouterFallbackConfig, - RouterFallbackMode, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider -} from "@ccr/core/contracts/app"; -import { - CLAUDE_APP_FALLBACK_MODEL, - buildClaudeAppGatewayModelRoutes, - inferClaudeAppGatewayTargetModel, - resolveClaudeAppGatewayRouteModel, - type ClaudeAppGatewayModelRouteOptions -} from "@ccr/core/agents/claude-app/gateway-routes"; -import { - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, - hasAvailableGatewayModels -} from "@ccr/core/contracts/app"; -import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index"; -import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "@ccr/core/providers/url"; -import { backendService } from "@ccr/core/plugins/backend-service"; -import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants"; -import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store"; -import { codexDefaultBaseUrl, readCodexAuth, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/service"; -import { grokAccessTokenExpired } from "@ccr/core/agents/local-providers/grok"; -import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch"; -import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp"; -import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config"; -import { pluginService } from "@ccr/core/plugins/service"; -import { proxyService } from "@ccr/core/proxy/service"; -import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store"; -import { recordGatewayUsageCapture } from "@ccr/core/usage/store"; -import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; -import { createRouteExecutionPlan } from "@ccr/core/routing/execution-plan"; -import type { RouteModelRef } from "@ccr/core/routing/contracts"; -import { classifyRouteFailure } from "@ccr/core/routing/failure-classifier"; -import { - adaptRouteRequestBody, - restoreRouteRequestBody, - rewriteRouteModelInUrl -} from "@ccr/core/routing/protocol-adapter"; -import { - modelRegistryForConfig, - normalizeRouteSelector, - parseProviderModelSelector, - providerRuntimeId -} from "@ccr/core/routing/model-registry"; -import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service"; -import { - claudeCodeEffectiveMaxInputTokens, - findModelCatalogEntry, - modelCatalogMaxInputTokens, - modelCatalogMaxOutputTokens, - readCatalogCapability, - type ModelCatalogCapabilities, - type ModelCatalogEntry -} from "@ccr/core/gateway/model-catalog"; - -type CoreGatewayProvider = { - apikey?: string; - baseurl?: string; - billing?: unknown; - extraBody?: unknown; - extraHeaders?: unknown; - models: string[]; - name: string; - type: GatewayProviderProtocol; -}; - -const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "brave"; -const fusionModelProviderName = "Fusion"; -const claudeCodeOneMillionContextSuffix = "[1m]"; -const claudeAppGatewayModelRouteOptions: ClaudeAppGatewayModelRouteOptions = { - displayName: (model) => findModelCatalogEntry(model)?.displayName, - supportsOneMillionContext: (model) => Boolean(findModelCatalogEntry(model)?.limits?.supports1MContext) -}; - -type ApiKeyAuthorizationResult = - | { ok: true; apiKey?: ApiKeyConfig } - | { ok: false }; - -type ApiKeyLimitUsage = { - imageCount: number; - totalTokens: number; -}; - -type ApiKeyLimitRule = { - limit: number; - metric: "images" | "requests" | "tokens"; - name: string; - requested: number; - windowMs: number; -}; - -type GatewayStopOptions = { - proxyRestoreTimeoutMs?: number; -}; - -type HostedWebSearchProtocolContext = { - maxUses?: number; - protocol: GatewayProviderProtocol; - queryHint?: string; - records?: BrowserWebSearchProtocolRecord[]; - requestId: string; - sinceMs: number; - toolName: string; -}; - -type AnthropicWebSearchProtocolContext = HostedWebSearchProtocolContext; - -type ClaudeCodeWebSearchContinuationContext = { - queryHint?: string; - sinceMs: number; - toolName: string; -}; - -export type BrowserWebSearchMcpRegistration = { - env?: Record; - name: string; - resultCount?: number; - timeoutMs?: number; - toolName: string; -}; - -export type BrowserWebSearchProtocolResult = { - content?: string; - diagnostics?: 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; -}; - -export type BrowserAutomationMcpIntegration = { - handleBrowserAutomationMcpRequest: (request: IncomingMessage, response: ServerResponse) => Promise; - stopBrowserAutomationMcpServer: () => Promise; -}; - -type CoreGatewayHealth = { - runtimeId?: string; - status?: string; -}; - -type ManagedGatewayRuntimeMarker = { - generatedConfigFile?: unknown; - gatewayEntry?: unknown; - pid?: unknown; - runtimeId?: unknown; - startedAt?: unknown; -}; - -type ApiKeyWindowCounter = { - expiresAt: number; - value: number; - windowStart: number; -}; - -type PendingRawTraceUpdate = RequestLogRawTraceUpdateInput & { - receivedAt: number; -}; - -type RawTracePartText = { - contentType?: string; - text: string; -}; - -type CursorOpenAICompatContext = { - systemPrompt?: string; - toolChoice?: unknown; - tools: unknown[]; -}; - -type CursorOpenAICompatPreparation = { - body?: Buffer; - diagnostic: "fallback-injected" | "simplified-missing-context"; -}; - -type ClaudeCodeDiscoverableModel = { - id: string; - oneMillionContext: boolean; -}; - -type UpstreamAttempt = { - body?: Buffer; - credentialChain?: string[]; - credentialIds?: string[]; - credentialProtocol?: GatewayProviderProtocol; - headers?: Record; - index: number; - logicalProvider?: string; - model?: string; - target?: RouteModelRef; -}; - -type UpstreamFailedAttempt = { - credentialChain?: string[]; - credentialIds?: string[]; - delayMs?: number; - error?: string; - model?: string; - statusCode?: number; -}; - -type UpstreamFetchResult = { - attempt: UpstreamAttempt; - failedAttempts: UpstreamFailedAttempt[]; - response: Response; -}; - -type ProviderCredentialRoutingTarget = { - body?: Buffer; - model?: string; - provider: GatewayProviderConfig; - protocol: GatewayProviderProtocol; - source: "header" | "model" | "plan"; -}; - -class UpstreamRequestError extends Error { - readonly attempt?: UpstreamAttempt; - readonly failedAttempts: UpstreamFailedAttempt[]; - - constructor(message: string, options: { attempt?: UpstreamAttempt; cause?: unknown; failedAttempts: UpstreamFailedAttempt[] }) { - super(message); - this.name = "UpstreamRequestError"; - this.attempt = options.attempt; - this.cause = options.cause; - this.failedAttempts = options.failedAttempts; - } -} - -const requireFromHere = createRequire(__filename); -const claudeCodeOauthBetaHeader = "anthropic-beta"; -const claudeCodeOauthRequiredBeta = "oauth-2025-04-20"; -const coreGatewayAuthHeader = "x-ccr-core-auth"; -const coreGatewayAuthTokenEnv = "CCR_CORE_GATEWAY_AUTH_TOKEN"; -const clientClosedRequestStatusCode = 499; -const clientDisconnectMessage = "Client connection closed before response completed."; -const localObservabilityHeaderNames = new Set([ - "x-ccr-claude-app-model-rewrite", - "x-ccr-codex-patch-bridge", - "x-ccr-claude-model-discovery", - "x-ccr-cursor-openai-compat", - "x-ccr-logical-provider", - "x-ccr-provider-credential-chain", - "x-ccr-provider-credential-saturated" -]); -const proxyHeaderDenyList = new Set(["connection", coreGatewayAuthHeader, "host", "upgrade"]); -const responseHeaderDenyList = new Set(["connection", "content-encoding", "transfer-encoding"]); -const maxUsageCaptureBytes = 8 * 1024 * 1024; -const maxPendingRawTraceUpdates = 200; -const pendingRawTraceMaxAgeMs = 5 * 60 * 1000; -const apiKeyLimitCounterRetentionWindows = 2; -const gatewayRuntimeMarkerFile = "gateway-runtime.json"; -const rawTraceSyncHeader = "x-ccr-raw-trace-token"; -const virtualApplyPatchToolName = "virtual_apply_patch"; -let warnedMissingCursorOpenAICompatContext = false; -const rawTraceSyncPath = "/__ccr/raw-trace-sync"; -const gatewayEntryOverrideEnv = "CCR_GATEWAY_ENTRY"; -const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"]; -const codexPatchBridgeInstructionText = [ - "When modifying files, call virtual_apply_patch.", - "Do not use exec_command or write_stdin to edit files, including shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar shell-based edits.", - "Use exec_command only for reading files, listing/searching, running builds/tests, starting servers, and other commands that are not manual file edits." -].join(" "); -const codexPatchBridgeShellToolGuidance = [ - "When virtual_apply_patch is available, do not use this tool to edit files.", - "Do not write files with shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar commands.", - "Use virtual_apply_patch for manual file changes." -].join(" "); -const virtualApplyPatchLarkGrammar = [ - "start: begin_patch hunk+ end_patch", - "begin_patch: \"*** Begin Patch\" LF", - "end_patch: \"*** End Patch\" LF?", - "", - "hunk: add_hunk | delete_hunk | update_hunk", - "add_hunk: \"*** Add File: \" filename LF add_line+", - "delete_hunk: \"*** Delete File: \" filename LF", - "update_hunk: \"*** Update File: \" filename LF change_move? change?", - "", - "filename: /(.+)/", - "add_line: \"+\" /(.*)/ LF -> line", - "", - "change_move: \"*** Move to: \" filename LF", - "change: (change_context | change_line)+ eof_line?", - "change_context: (\"@@\" | \"@@ \" /(.+)/) LF", - "change_line: (\"+\" | \"-\" | \" \") /(.*)/ LF", - "eof_line: \"*** End of File\" LF", - "", - "%import common.LF" -].join("\n"); -const apiKeyLimitCounters = new Map(); -const providerCredentialCooldowns = new Map(); -const providerCredentialCooldownMs = 60_000; -const providerCredentialSpilloverThreshold = 0.8; -const upstreamRetryBackoffBaseMs = 1_000; -const upstreamRetryBackoffMaxMs = 30_000; -const upstreamRetryAfterMaxMs = 60_000; -const gatewayProviderProtocolFallbackOrder: GatewayProviderProtocol[] = [ - "anthropic_messages", - "openai_chat_completions", - "openai_responses", - "gemini_generate_content", - "gemini_interactions" -]; -const privateDirMode = 0o700; -const privateFileMode = 0o600; -const persistedApiKeyCacheTtlMs = 1000; -let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined; - -class GatewayService { - private browserAutomationMcpIntegration?: BrowserAutomationMcpIntegration; - private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration; - private child?: ChildProcess; - private config?: AppConfig; - private coreAuthToken = ""; - private plugin?: ClaudeCodeRouterPlugin; - private readonly pendingRawTraceUpdates = new Map(); - private readonly rawTraceSyncToken = randomUUID(); - private server?: Server; - private status: GatewayStatus = { - coreEndpoint: "", - endpoint: "", - generatedConfigFile: "", - networkEndpoints: [], - state: "stopped" - }; - - setBrowserWebSearchMcpIntegration(integration: BrowserWebSearchMcpIntegration): void { - this.browserWebSearchMcpIntegration = integration; - } - - setBrowserAutomationMcpIntegration(integration: BrowserAutomationMcpIntegration): void { - this.browserAutomationMcpIntegration = integration; - } - - async start(config: AppConfig): Promise { - const coreHostError = loopbackCoreHostError(config.gateway.coreHost); - if (coreHostError) { - this.status = { - ...this.getStatus(), - lastError: coreHostError, - state: "error" - }; - return this.status; - } - await this.stop(); - this.config = config; - this.coreAuthToken = generateCoreGatewayAuthToken(); - this.plugin = new ClaudeCodeRouterPlugin(config); - this.status = { - coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), - endpoint: endpoint(config.gateway.host, config.gateway.port), - generatedConfigFile: config.gateway.generatedConfigFile, - networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port), - state: "starting" - }; - - try { - await pluginService.start(config); - const shouldRunServer = shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes(); - const shouldRunGateway = shouldRunGatewayRuntime(config); - if (shouldRunGateway && !hasAvailableGatewayModels(config)) { - throw new Error(NO_AVAILABLE_GATEWAY_MODELS_MESSAGE); - } - if (!shouldRunServer) { - await pluginService.stop(); - await backendService.stopAll(); - this.coreAuthToken = ""; - this.status = { - ...this.status, - state: "stopped" - }; - return this.status; - } - - await this.listen(config); - if (this.server) { - const proxyStatus = await proxyService.attach(config, this.server); - if (proxyStatus.state === "error" && !config.gateway.enabled) { - throw new Error(proxyStatus.lastError || "Proxy service failed to start."); - } - } - - if (shouldRunGateway) { - await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.coreAuthToken, 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}`); - } - await proxyService.refreshUpstreamProxyFromCurrentSystem(); - const runtimeId = randomUUID(); - const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config); - this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, this.coreAuthToken); - const managedChild = this.child; - writeManagedCoreGatewayMarker(config, this.child, runtimeId); - this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`)); - this.child.stderr?.on("data", (chunk) => console.warn(`[gateway] ${chunk.toString().trimEnd()}`)); - this.child.on("exit", (code, signal) => { - void this.handleCoreGatewayExit(managedChild, code, signal); - }); - } - - this.status = { - ...this.status, - coreManagedExternally: this.status.coreManagedExternally, - lastStartedAt: new Date().toISOString(), - pid: this.child?.pid, - state: "running" - }; - return this.status; - } catch (error) { - await this.stop(); - this.status = { - ...this.status, - lastError: formatError(error), - state: "error" - }; - return this.status; - } - } - - async stop(options: GatewayStopOptions = {}): Promise { - const child = this.child; - const config = this.config; - this.child = undefined; - this.coreAuthToken = ""; - if (child && !child.killed) { - child.kill(); - } - removeManagedCoreGatewayMarker(config); - - const server = this.server; - this.server = undefined; - if (server) { - await closeServer(server); - } - - 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)}`); - }); - await this.browserAutomationMcpIntegration?.stopBrowserAutomationMcpServer().catch((error) => { - console.warn(`[gateway] Failed to stop browser automation MCP: ${formatError(error)}`); - }); - - this.status = { - ...this.status, - coreManagedExternally: undefined, - pid: undefined, - state: "stopped" - }; - return this.getStatus(); - } - - getStatus(): GatewayStatus { - return { - ...this.status, - networkEndpoints: this.config - ? gatewayNetworkEndpoints(this.config.gateway.host, this.config.gateway.port) - : this.status.networkEndpoints - }; - } - - updateConfig(config: AppConfig): void { - assertLoopbackCoreHost(config.gateway.coreHost); - this.config = config; - this.plugin = new ClaudeCodeRouterPlugin(config); - proxyService.updateConfig(config); - this.status = { - ...this.status, - coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), - endpoint: endpoint(config.gateway.host, config.gateway.port), - generatedConfigFile: config.gateway.generatedConfigFile, - networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port) - }; - } - - private async listen(config: AppConfig): Promise { - this.server = createServer((request, response) => { - if (proxyService.shouldHandleHttpRequest(request)) { - void proxyService.handleHttpRequest(request, response).catch((error) => { - response.writeHead(502, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: { message: formatError(error) } })); - }); - return; - } - - void this.handleRequest(request, response).catch((error) => { - response.writeHead(502, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: { message: formatError(error) } })); - }); - }); - - await new Promise((resolve, reject) => { - this.server?.once("error", reject); - this.server?.listen(config.gateway.port, config.gateway.host, () => { - this.server?.off("error", reject); - resolve(); - }); - }); - } - - private async handleCoreGatewayExit(child: ChildProcess, code: number | null, signal: NodeJS.Signals | null): Promise { - if (this.child !== child || this.status.state === "stopped") { - return; - } - removeManagedCoreGatewayMarker(this.config); - this.status = { - ...this.status, - coreManagedExternally: undefined, - lastError: `Core gateway exited with ${signal ?? code ?? "unknown status"}`, - pid: undefined, - state: "error" - }; - } - - private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { - applyCors(response, this.config); - - if (request.method === "OPTIONS") { - response.writeHead(204); - response.end(); - return; - } - - if (!this.config || !this.plugin) { - sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); - return; - } - - const path = request.url ? new URL(request.url, this.status.endpoint || "http://127.0.0.1").pathname : "/"; - if (path === rawTraceSyncPath) { - if (!shouldRecordRequestLogs(this.config)) { - sendJson(response, 202, { applied: false, disabled: true, ok: true }); - return; - } - await this.handleRawTraceSync(request, response); - return; - } - - if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) { - const authorization = await authorize(request, response, this.config); - if (!authorization.ok) { - return; - } - await ccrRemoteControlService.handleRequest({ - endpoint: this.status.endpoint, - path, - readBody: readRequestBody, - request, - response, - sendJson - }); - return; - } - - if (path === BROWSER_AUTOMATION_MCP_PATH || path === `${BROWSER_AUTOMATION_MCP_PATH}/`) { - if (!browserAutomationMcpEnabled(this.config)) { - sendJson(response, 404, { - error: { - message: "CCR browser automation MCP is disabled." - } - }); - return; - } - const authorization = await authorize(request, response, this.config); - if (!authorization.ok) { - return; - } - if (!this.browserAutomationMcpIntegration) { - sendJson(response, 503, { - error: { - message: "CCR browser automation MCP is only available in the Electron desktop app." - } - }); - return; - } - await this.browserAutomationMcpIntegration.handleBrowserAutomationMcpRequest(request, response); - return; - } - - if (isNetworkCaptureMcpPath(path)) { - if (!this.config.proxy.captureNetwork) { - sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } }); - return; - } - const authorization = await authorize(request, response, this.config); - if (!authorization.ok) { - return; - } - await handleNetworkCaptureMcpRequest(request, response); - return; - } - - const pluginRoute = pluginService.matchGatewayRoute(request.method, path); - if (pluginRoute) { - if (pluginRoute.auth !== "none") { - const authorization = await authorize(request, response, this.config); - if (!authorization.ok) { - return; - } - } - await pluginService.handleGatewayRoute(pluginRoute, request, response); - return; - } - - if (!shouldServeGatewayRequest(this.config, request)) { - sendJson(response, 503, { error: { message: "Gateway runtime is disabled." } }); - return; - } - - if (path === "/health") { - sendJson(response, 200, { - core: this.status.coreEndpoint, - coreManagedExternally: this.status.coreManagedExternally || undefined, - status: this.status.state, - timestamp: new Date().toISOString() - }); - return; - } - - if (path === "/") { - sendJson(response, 200, { - core: "next-ai-gateway", - endpoints: ["POST /mcp", "POST /v1/messages", "POST /v1/messages/count_tokens", "GET /v1/models"], - name: "claude-code-router", - plugin: "claude-code-router", - wrapperPlugins: this.config.plugins.filter((plugin) => plugin.enabled !== false).map((plugin) => plugin.id) - }); - return; - } - - const authorization = await authorize(request, response, this.config); - if (!authorization.ok) { - return; - } - - if (request.method === "POST" && path === "/v1/messages/count_tokens") { - const requestBody = await readRequestBody(request); - const body = parseJsonObject(requestBody); - if (!reserveApiKeyLimits(authorization.apiKey, request, response, requestBody)) { - return; - } - sendJson(response, 200, this.plugin.countTokens(body)); - return; - } - - await this.proxyRequest(request, response, path, authorization.apiKey); - } - - private async proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig): Promise { - if (!this.config || !this.plugin) { - sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); - return; - } - - const headers = forwardHeaders(request.headers); - if (apiKey) { - stripLocalGatewayAuthHeaders(headers); - headers["x-auth-api-key-id"] = apiKey.id; - headers["x-auth-sub"] = apiKey.id; - } - const method = request.method ?? "GET"; - const requestBody = await readRequestBody(request); - const client = inferGatewayClient(apiKey, request.headers); - const cursorCompatPreparation = prepareCursorOpenAICompatChatBody(this.config, client, method, path, requestBody); - if (cursorCompatPreparation) { - headers["x-ccr-cursor-openai-compat"] = sanitizeHeaderValue(cursorCompatPreparation.diagnostic); - } - let bodyToForward: Buffer | undefined = cursorCompatPreparation?.body ?? requestBody; - let routeFallback = this.config.Router.fallback; - let routedModel: string | undefined; - let codexApplyPatchBridgeActive = false; - const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward); - if (claudeModelRewrite) { - headers["x-ccr-claude-model-discovery"] = sanitizeHeaderValue(claudeModelRewrite.diagnostic); - bodyToForward = claudeModelRewrite.body; - } - const claudeAppModelRewrite = prepareClaudeAppFallbackModelRequest(this.config, method, path, bodyToForward); - if (claudeAppModelRewrite) { - headers["x-ccr-claude-app-model-rewrite"] = sanitizeHeaderValue(claudeAppModelRewrite.diagnostic); - bodyToForward = claudeAppModelRewrite.body; - routedModel = claudeAppModelRewrite.routedModel; - } - if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { - return; - } - const startedAt = Date.now(); - const startedAtIso = new Date(startedAt).toISOString(); - const requestId = randomUUID(); - headers["x-client-request-id"] = requestId; - const requestUrl = new URL(request.url || path, this.status.endpoint || "http://127.0.0.1").toString(); - const upstreamAbortController = new AbortController(); - let clientDisconnected = false; - let responseCompleted = false; - let onClientDisconnect: (() => void) | undefined; - let onResponseFinish: (() => void) | undefined; - const handleClientDisconnect = () => { - if (responseCompleted || response.writableEnded) { - return; - } - if (!clientDisconnected) { - clientDisconnected = true; - upstreamAbortController.abort(new Error(clientDisconnectMessage)); - } - onClientDisconnect?.(); - }; - - response.once("finish", () => { - responseCompleted = true; - onResponseFinish?.(); - }); - response.once("close", handleClientDisconnect); - response.on("error", () => { - // Client-side write failures (EPIPE / ECONNRESET when the client closes - // mid-stream, common during tool execution) must not crash the main - // process as an Uncaught Exception. Swallow them here; the close handler - // above already records the disconnect via writeStreamLog. - handleClientDisconnect(); - }); - - const writeRequestLog = ( - statusCode: number, - responseHeaders: Headers, - responseBodyText = "", - responseBodyTruncated = false, - error?: string - ) => { - const config = this.config; - if (!config || !shouldRecordRequestLogs(config)) { - return; - } - void (async () => { - await recordGatewayRequestLog({ - client, - completedAt: new Date().toISOString(), - durationMs: Date.now() - startedAt, - error, - fallbackModel: routedModel, - method, - path, - providerName: resolveProviderLogName(responseHeaders, config, routedModel), - providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), - requestBody: shouldSendBody(method) ? bodyToForward ?? Buffer.alloc(0) : Buffer.alloc(0), - requestHeaders: headers, - requestId, - responseBodyText, - responseBodyTruncated, - responseHeaders, - startedAt: startedAtIso, - statusCode, - url: requestUrl - }); - const pendingRawTraceUpdate = this.takePendingRawTraceUpdate(requestId); - if (pendingRawTraceUpdate) { - await updateGatewayRequestLogFromRawTrace(pendingRawTraceUpdate); - } - })(); - }; - - const shouldCaptureUsage = shouldCaptureGatewayUsage(method, path); - if (shouldServeGatewayModelsResponse(method, path)) { - const responseText = `${JSON.stringify(createGatewayModelsResponse(this.config, request.headers, apiKey))}\n`; - const modelHeaders = new Headers({ - "cache-control": "no-store, max-age=0", - "content-length": String(Buffer.byteLength(responseText)), - "content-type": "application/json; charset=utf-8", - "expires": "0", - "pragma": "no-cache" - }); - response.writeHead(200, Object.fromEntries(filteredResponseHeaders(modelHeaders))); - response.end(responseText); - return; - } - - if (shouldApplyGatewayRouting(method, path)) { - const adaptation = adaptRouteRequestBody(path, parseJsonObject(bodyToForward ?? requestBody)); - const routed = await this.plugin.routeRequest({ - body: adaptation.body, - headers: headers as Record, - method, - url: request.url ?? path - }); - const serialized = Buffer.from(`${JSON.stringify(restoreRouteRequestBody(routed.body, adaptation))}\n`, "utf8"); - headers["content-type"] = "application/json"; - headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason); - headers["x-ccr-route-source"] = routed.decision.source; - if (routed.decision.diagnostics.length > 0) { - headers["x-ccr-route-diagnostics"] = String(routed.decision.diagnostics.length); - } - routeFallback = routed.decision.fallback ?? routeFallback; - if (routed.decision.model) { - headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model); - routedModel = routed.decision.model; - } - bodyToForward = serialized; - } - - const codexApplyPatchBridgeRequest = prepareCodexApplyPatchBridgeRequest({ - body: bodyToForward, - config: this.config, - headers: request.headers, - method, - path, - routedModel - }); - if (codexApplyPatchBridgeRequest) { - bodyToForward = codexApplyPatchBridgeRequest.body; - codexApplyPatchBridgeActive = true; - headers["x-ccr-codex-patch-bridge"] = sanitizeHeaderValue(codexApplyPatchBridgeRequest.diagnostic); - headers["content-type"] = "application/json"; - } - - const providerCapabilityRouting = applyProviderCapabilityRouting({ - body: bodyToForward, - config: this.config, - fallback: routeFallback, - headers, - path, - routedModel - }); - bodyToForward = providerCapabilityRouting.body; - 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 message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); - const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); - const responseBody = JSON.stringify({ error: { message } }); - writeRequestLog(503, responseHeaders, responseBody, false, message); - sendJson(response, 503, { error: { message } }); - return; - } - - 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; - } - } - } - - 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; - - try { - upstreamResult = await fetchUpstreamWithFallback({ - body: bodyToForward, - config: this.config, - fallback: routeFallback, - headers, - method, - path, - routedModel, - coreAuthToken: this.coreAuthToken, - signal: upstreamAbortController.signal, - upstreamUrl - }); - } catch (error) { - const message = formatError(error); - if (error instanceof UpstreamRequestError) { - bodyToForward = error.attempt?.body ?? bodyToForward; - routedModel = error.attempt?.model ?? routedModel; - } - if (clientDisconnected || upstreamAbortController.signal.aborted) { - writeRequestLog(clientClosedRequestStatusCode, new Headers(), "", false, clientDisconnectMessage); - return; - } - if (shouldCaptureUsage) { - void recordGatewayUsageCapture({ - bodyText: "", - client, - durationMs: Date.now() - startedAt, - fallbackModel: routedModel, - method, - path, - providerName: resolveProviderLogName(new Headers(), this.config, routedModel), - providerProtocol: resolveResponseProviderProtocol(new Headers(), this.config), - requestId, - responseHeaders: new Headers(), - statusCode: 502 - }); - } - writeRequestLog(502, new Headers(), "", false, message); - throw error; - } - - bodyToForward = upstreamResult.attempt.body ?? bodyToForward; - routedModel = upstreamResult.attempt.model ?? routedModel; - const responseHeaders = rewriteCapabilityResponseHeaders( - // Copy into a mutable Headers instance: upstream fetch Response.headers - // can be immutable (TypeError: immutable on .delete/.set), and - // mergeFallbackResponseHeaders returns the original object as-is when - // no fallback occurred. Codex apply_patch / web-search paths call - // .delete("content-length") below, which would otherwise throw and - // surface as a 502. - new Headers(mergeFallbackResponseHeaders(upstreamResponseHeaders(upstreamResult), upstreamResult)), - this.config - ); - const upstreamResponse = upstreamResult.response; - if (clientDisconnected || upstreamAbortController.signal.aborted) { - await cancelResponseBody(upstreamResponse); - writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); - return; - } - if (codexApplyPatchBridgeActive) { - responseHeaders.delete("content-length"); - } - const hostedWebSearchResponseContentType = responseHeaders.get("content-type")?.toLowerCase() ?? ""; - if ( - hostedWebSearchProtocolContext && - (hostedWebSearchResponseContentType.includes("application/json") || - hostedWebSearchResponseContentType.includes("text/event-stream")) && - (this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || this.browserWebSearchMcpIntegration?.runBrowserWebSearch) - ) { - responseHeaders.delete("content-length"); - } - recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders); - if (clientDisconnected || response.destroyed) { - await cancelResponseBody(upstreamResponse); - writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); - return; - } - response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders))); - if (!upstreamResponse.body) { - if (shouldCaptureUsage) { - void recordGatewayUsageCapture({ - bodyText: "", - client, - durationMs: Date.now() - startedAt, - fallbackModel: routedModel, - method, - path, - providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), - providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), - requestId, - responseHeaders, - statusCode: upstreamResponse.status - }); - } - writeRequestLog(upstreamResponse.status, responseHeaders); - response.end(); - return; - } - - const upstreamBody = Readable.fromWeb(upstreamResponse.body as unknown as import("node:stream/web").ReadableStream); - const patchedResponseBody = codexApplyPatchBridgeActive - ? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders) - : upstreamBody; - const responseBody = hostedWebSearchProtocolContext - ? hostedWebSearchProtocolResponseStream( - patchedResponseBody, - responseHeaders, - hostedWebSearchProtocolContext, - this.browserWebSearchMcpIntegration - ) - : patchedResponseBody; - const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, responseBody]); - const sampler = createBodySampler(); - const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined); - let streamDetectedError: string | undefined; - let upstreamStreamEnded = false; - let logRecorded = false; - const writeStreamLog = (error?: string) => { - if (logRecorded) { - return; - } - logRecorded = true; - writeRequestLog( - clientDisconnected ? clientClosedRequestStatusCode : upstreamResponse.status, - responseHeaders, - sampler.read(), - sampler.isTruncated(), - error ?? streamDetectedError - ); - }; - onClientDisconnect = () => { - writeStreamLog(clientDisconnectMessage); - responseBody.unpipe(response); - destroyResponseStreams(responseStreams); - }; - onResponseFinish = () => { - if (upstreamStreamEnded) { - writeStreamLog(); - } - }; - const onResponseStreamError = (error: Error) => { - streamDetectedError ??= sseErrorDetector.finish(); - writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error)); - }; - for (const stream of responseStreams) { - stream.on("error", onResponseStreamError); - } - responseBody.on("data", (chunk) => { - sampler.append(chunk); - streamDetectedError ??= sseErrorDetector.append(chunk); - }); - responseBody.once("end", () => { - upstreamStreamEnded = true; - streamDetectedError ??= sseErrorDetector.finish(); - if (responseCompleted || response.writableEnded) { - writeStreamLog(); - } - }); - if (shouldCaptureUsage) { - responseBody.once("end", () => { - void recordGatewayUsageCapture({ - bodyText: sampler.read(), - client, - durationMs: Date.now() - startedAt, - fallbackModel: routedModel, - method, - path, - providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), - providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), - requestId, - responseHeaders, - statusCode: upstreamResponse.status - }); - }); - } - if (clientDisconnected || response.destroyed) { - onClientDisconnect(); - return; - } - responseBody.pipe(response); - } - - private async handleRawTraceSync(request: IncomingMessage, response: ServerResponse): Promise { - if (request.method !== "POST") { - sendJson(response, 405, { error: { message: "Method not allowed." } }); - return; - } - if (readHeader(request.headers[rawTraceSyncHeader]) !== this.rawTraceSyncToken) { - sendJson(response, 401, { error: { message: "Unauthorized raw trace sync." } }); - return; - } - - const manifest = parseJsonObject(await readRequestBody(request)); - const update = readRawTraceRequestLogUpdate(manifest); - cleanupRawTraceBundle(manifest); - if (!update) { - sendJson(response, 202, { applied: false, ok: true }); - return; - } - - const applied = await updateGatewayRequestLogFromRawTrace(update); - if (!applied) { - this.storePendingRawTraceUpdate(update); - } - sendJson(response, 200, { applied, ok: true }); - } - - private storePendingRawTraceUpdate(update: RequestLogRawTraceUpdateInput): void { - this.prunePendingRawTraceUpdates(); - this.pendingRawTraceUpdates.set(update.requestId, { - ...update, - receivedAt: Date.now() - }); - while (this.pendingRawTraceUpdates.size > maxPendingRawTraceUpdates) { - const oldestKey = this.pendingRawTraceUpdates.keys().next().value; - if (!oldestKey) { - break; - } - this.pendingRawTraceUpdates.delete(oldestKey); - } - } - - private takePendingRawTraceUpdate(requestId: string): RequestLogRawTraceUpdateInput | undefined { - const update = this.pendingRawTraceUpdates.get(requestId); - if (!update) { - return undefined; - } - this.pendingRawTraceUpdates.delete(requestId); - const { receivedAt: _receivedAt, ...input } = update; - return input; - } - - private prunePendingRawTraceUpdates(): void { - const cutoff = Date.now() - pendingRawTraceMaxAgeMs; - for (const [requestId, update] of this.pendingRawTraceUpdates) { - if (update.receivedAt < cutoff) { - this.pendingRawTraceUpdates.delete(requestId); - } - } - } -} - -export const gatewayService = new GatewayService(); - -async function writeCoreGatewayConfig( - config: AppConfig, - rawTraceSyncToken: string, - coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration -): Promise { - assertLoopbackCoreHost(config.gateway.coreHost); - mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); - const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); - const configuredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins([ - ...(config.providerPlugins ?? []).filter(providerPluginEnabled), - ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) - ]); - const providerPlugins = await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins)); - const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); - const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ - ...(config.virtualModelProfiles ?? []), - ...pluginService.getVirtualModelProfiles() - ])), config); - const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); - const providers = [ - ...config.Providers - .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) - .filter((provider): provider is CoreGatewayProvider => Boolean(provider)), - ...builtinToolArtifacts.providers - ]; - const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {}; - const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : []; - const externalMcpServers = [ - ...pluginMcpServers, - ...(config.agent?.mcpServers ?? []), - ...(config.toolHub?.mcpServers ?? []) - ]; - const toolHubServer = toolHubMcpServer(config, externalMcpServers); - const mcpServers = [ - ...builtinToolArtifacts.mcpServers, - ...(toolHubServer ? [toolHubServer] : externalMcpServers) - ]; - const fallbackMcpServer = fusionToolFallbackMcpServer(virtualModelProfiles, [ - ...builtinToolArtifacts.mcpServers, - ...externalMcpServers - ]); - if (fallbackMcpServer) { - mcpServers.push(fallbackMcpServer); - } - const payload = { - ...pluginCoreGatewayConfig, - auth: { - enabled: true, - mode: "static_api_key", - required: true, - staticApiKeys: { - keyBearerOnly: false, - keyEnv: coreGatewayAuthTokenEnv, - keyHeader: coreGatewayAuthHeader - } - }, - billing: { - enabled: true - }, - billingQueue: { - enabled: false - }, - billingWebhook: { - enabled: false - }, - bodyLimitBytes: 50 * 1024 * 1024, - host: config.gateway.coreHost, - mcpGateway: { - enabled: false - }, - port: config.gateway.corePort, - upstreamTimeoutMs: Number(config.API_TIMEOUT_MS) || 0, - agent: { - ...pluginAgentConfig, - mcpServers - }, - rawTrace: buildRawTraceConfig(config, rawTraceSyncToken), - providerPlugins, - providers, - virtualModelProfiles - }; - - writePrivateTextFile(config.gateway.generatedConfigFile, `${JSON.stringify(payload, null, 2)}\n`); -} - -function writePrivateTextFile(file: string, content: string): void { - writeFileSync(file, content, { encoding: "utf8", mode: privateFileMode }); - if (process.platform !== "win32") { - try { - chmodSync(file, privateFileMode); - } catch { - // Best effort for filesystems that do not support chmod. - } - } -} - -function providerPluginEnabled(plugin: unknown): boolean { - return !isRecord(plugin) || plugin.enabled !== false; -} - -export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] { - return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config)); -} - -function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppConfig): unknown { - if (!isRecord(profile)) { - return profile; - } - - let nextProfile: Record | undefined; - const baseModel = isRecord(profile.baseModel) ? profile.baseModel : undefined; - const fixedModel = stringValue(baseModel?.fixedModel); - const rewrittenFixedModel = fixedModel - ? rewriteModelSelectorForCoreGatewayProfile(fixedModel, config, "anthropic_messages") - : undefined; - if (baseModel && rewrittenFixedModel && rewrittenFixedModel !== fixedModel) { - nextProfile = { - ...profile, - baseModel: { - ...baseModel, - fixedModel: rewrittenFixedModel - } - }; - } - - const sourceProfile = nextProfile ?? profile; - const metadata = isRecord(sourceProfile.metadata) ? sourceProfile.metadata : undefined; - const fusionVision = isRecord(metadata?.fusionVision) ? metadata.fusionVision : undefined; - const visionBaseUrl = stringValue(fusionVision?.baseUrl); - const visionSelectorField = stringValue(fusionVision?.modelSelector) ? "modelSelector" : stringValue(fusionVision?.model) ? "model" : undefined; - const visionSelector = visionSelectorField ? stringValue(fusionVision?.[visionSelectorField]) : undefined; - const rewrittenVisionSelector = fusionVision && !visionBaseUrl && visionSelector - ? rewriteModelSelectorForCoreGatewayProfile(visionSelector, config, "openai_chat_completions") - : undefined; - - if (metadata && fusionVision && visionSelectorField && rewrittenVisionSelector && rewrittenVisionSelector !== visionSelector) { - nextProfile = { - ...sourceProfile, - metadata: { - ...metadata, - fusionVision: { - ...fusionVision, - [visionSelectorField]: rewrittenVisionSelector - } - } - }; - } - - const profileAfterVision = nextProfile ?? profile; - const profileAfterWebSearchToolName = normalizeFusionWebSearchProfileToolName(profileAfterVision) ?? profileAfterVision; - return withFusionWebSearchToolInstructions(profileAfterWebSearchToolName) ?? profileAfterWebSearchToolName; -} - -function rewriteModelSelectorForCoreGatewayProfile( - model: string, - config: AppConfig, - clientProtocol: GatewayProviderProtocol -): string | undefined { - const normalized = normalizeRouteSelector(model); - if (!normalized) { - return undefined; - } - - const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; - const selector = - resolveConfiguredProviderModelSelector(publicModel, config) ?? - resolveUniqueConfiguredProviderModelSelector(publicModel, config); - if (!selector) { - return publicModel; - } - - const providerName = coreGatewayProviderSelectorName(selector.provider, clientProtocol); - return providerName ? `${providerName}/${selector.model}` : publicModel; -} - -function coreGatewayProviderSelectorName( - provider: GatewayProviderConfig, - clientProtocol: GatewayProviderProtocol -): string | undefined { - const capability = providerCapabilityForClientProtocol(provider, clientProtocol); - const explicitCapabilities = normalizedProviderCapabilities(provider); - const protocol = capability?.type ?? (explicitCapabilities.length === 0 ? providerProtocolForClientProtocol(provider, clientProtocol) : undefined); - if (!protocol) { - return undefined; - } - - const credentials = sortProviderCredentialsForConfig(activeProviderCredentials(provider)); - if (credentials.length > 0) { - return providerCredentialInternalName(provider, protocol, credentials[0]); - } - - return capability ? providerCapabilityInternalName(provider, protocol) : providerRuntimeId(provider); -} - -function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { - const codexAuth = readCodexAuth(); - return providerPlugins.map((plugin) => { - if (!isLocalCodexOauthProviderPlugin(plugin)) { - return plugin; - } - - const codexOauth = plugin.codexOauth; - const nextCodexOauth = { - ...codexOauth, - ...(!hasOwn(codexOauth, "accountId") && !hasOwn(codexOauth, "account_id") && codexAuth?.accountId - ? { accountId: codexAuth.accountId } - : {}) - }; - const nextPlugin: Record = { - ...plugin, - codexOauth: nextCodexOauth, - request: withCodexBackendRequestTransform(plugin.request) - }; - - if (codexAuth?.isFedrampAccount) { - const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; - const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; - nextPlugin.auth = { - ...currentAuth, - headers: { - ...currentHeaders, - "X-OpenAI-Fedramp": "true" - } - }; - } - - return nextPlugin; - }); -} - -async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise { - const grokAuth = await resolveGrokAuth().catch(() => readGrokAuth()); - if (!grokAuth?.accessToken || grokAccessTokenExpired(grokAuth)) { - return providerPlugins; - } - - return providerPlugins.map((plugin) => { - if (!isLocalGrokOauthProviderPlugin(plugin)) { - return plugin; - } - const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; - const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; - return { - ...plugin, - auth: { - ...currentAuth, - headers: { - ...currentHeaders, - authorization: `Bearer ${grokAuth.accessToken}` - } - } - }; - }); -} - -function codexOauthLocalProviderNames(providerPlugins: unknown[]): Set { - const names = new Set(); - for (const plugin of providerPlugins) { - if (!isLocalCodexOauthProviderPlugin(plugin)) { - continue; - } - addProviderNameVariants(names, stringValue(plugin.providerName)); - } - return names; -} - -function withCodexOauthProviderBaseUrl( - provider: GatewayProviderConfig, - codexOauthProviderNames: Set -): GatewayProviderConfig { - if (!codexOauthProviderNames.has(provider.name)) { - return provider; - } - - const protocol = - normalizeProviderProtocol(provider.type) ?? - normalizeProviderProtocol(provider.provider) ?? - inferProtocol(provider); - if (protocol !== "openai_responses") { - return provider; - } - - const capabilities = Array.isArray(provider.capabilities) - ? provider.capabilities.map((capability) => { - const capabilityProtocol = normalizeProviderProtocol(capability.type); - if (capabilityProtocol !== "openai_responses") { - return capability; - } - return { - ...capability, - baseUrl: codexDefaultBaseUrl - }; - }) - : provider.capabilities; - - return { - ...provider, - api_base_url: codexDefaultBaseUrl, - baseUrl: codexDefaultBaseUrl, - baseurl: codexDefaultBaseUrl, - capabilities - }; -} - -function isLocalCodexOauthProviderPlugin(value: unknown): value is Record & { codexOauth: Record } { - if (!isRecord(value) || !isRecord(value.codexOauth)) { - return false; - } - const key = stringValue(value.key)?.toLowerCase() ?? ""; - return key.startsWith("ccr-local-agent-") && key.includes("codex-oauth"); -} - -function isLocalClaudeCodeOauthProviderPlugin(value: unknown): value is Record { - if (!isRecord(value)) { - return false; - } - const key = stringValue(value.key)?.toLowerCase() ?? ""; - return key.startsWith("ccr-local-agent-") && key.includes("claude-code-oauth"); -} - -export function normalizeClaudeCodeOauthProviderPlugins(providerPlugins: unknown[]): unknown[] { - return providerPlugins.map((plugin) => { - if (!isLocalClaudeCodeOauthProviderPlugin(plugin)) { - return plugin; - } - - const auth = isRecord(plugin.auth) ? plugin.auth : {}; - const headers = isRecord(auth.headers) ? auth.headers : {}; - const configuredBeta = Object.entries(headers) - .find(([name]) => name.trim().toLowerCase() === claudeCodeOauthBetaHeader)?.[1]; - const defaultBeta = mergeAnthropicBetaValues( - configuredAnthropicBetaDefault(configuredBeta), - claudeCodeOauthRequiredBeta - ); - const normalizedHeaders = Object.fromEntries( - Object.entries(headers).filter(([name]) => name.trim().toLowerCase() !== claudeCodeOauthBetaHeader) - ); - - return { - ...plugin, - auth: { - ...auth, - headers: { - ...normalizedHeaders, - [claudeCodeOauthBetaHeader]: { - default: defaultBeta, - from: `request.headers.${claudeCodeOauthBetaHeader}` - } - } - } - }; - }); -} - -function configuredAnthropicBetaDefault(value: unknown): string | undefined { - if (typeof value === "string") { - return value; - } - if (!isRecord(value)) { - return undefined; - } - return stringValue(value.default); -} - -function isLocalGrokOauthProviderPlugin(value: unknown): value is Record { - if (!isRecord(value)) { - return false; - } - const key = stringValue(value.key)?.toLowerCase() ?? ""; - return key.startsWith("ccr-local-agent-") && key.includes("grok-cli-oauth"); -} - -function withCodexBackendRequestTransform(request: unknown): Record { - const currentRequest = isRecord(request) ? request : {}; - const bodyRemove = Array.isArray(currentRequest.bodyRemove) - ? currentRequest.bodyRemove.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) - : []; - return { - ...currentRequest, - bodyRemove: uniqueStrings([...bodyRemove, "max_output_tokens"]) - }; -} - -function addProviderNameVariants(names: Set, providerName: string | undefined): void { - if (!providerName) { - return; - } - names.add(providerName); - const capabilitySeparatorIndex = providerName.indexOf("::"); - if (capabilitySeparatorIndex > 0) { - names.add(providerName.slice(0, capabilitySeparatorIndex)); - } -} - -function hasOwn(value: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(value, key); -} - -async function fusionBuiltinToolArtifacts( - profiles: unknown[], - coreEndpoint: string, - coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration -): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { - const providers: CoreGatewayProvider[] = []; - const mcpServers: GatewayMcpServerConfig[] = []; - const toolServerKeys = new Set(); - const entry = bundledFusionBuiltinMcpEntryPath(); - - for (const [index, profile] of profiles.entries()) { - if (!isRecord(profile) || profile.enabled === false) { - continue; - } - const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; - const profileId = stringValue(profile.id) || stringValue(profile.key) || `fusion-${index + 1}`; - const sanitizedProfileId = sanitizeMcpServerName(profileId); - - const visionConfig = readFusionVisionConfig(metadata?.fusionVision) ?? legacyFusionVisionConfig(profile); - if (visionConfig?.toolName) { - const resolvedVision = resolveFusionVisionRuntime(visionConfig); - providers.push(...resolvedVision.providers); - const toolServerKey = `vision:${visionConfig.toolName}`; - if (!toolServerKeys.has(toolServerKey)) { - toolServerKeys.add(toolServerKey); - const useGatewayVisionRuntime = !visionConfig.baseUrl; - mcpServers.push(fusionBuiltinMcpServer({ - entry, - env: { - FUSION_BUILTIN_TOOL_KIND: "vision", - FUSION_TOOL_NAME: visionConfig.toolName, - ...(useGatewayVisionRuntime ? { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` } : { VISION_BASE_URL: visionConfig.baseUrl || "" }), - ...(useGatewayVisionRuntime && coreAuthToken ? { VISION_GATEWAY_API_KEY: coreAuthToken } : {}), - ...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}), - ...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}), - ...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {}) - }, - name: `fusion-vision-${sanitizedProfileId}` - })); - } - } - - const webSearchConfig = readFusionWebSearchConfig(metadata?.fusionWebSearch) ?? legacyFusionWebSearchConfig(profile); - if (webSearchConfig?.toolName) { - const toolServerKey = `web_search:${webSearchConfig.toolName}`; - if (!toolServerKeys.has(toolServerKey)) { - toolServerKeys.add(toolServerKey); - 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 }; -} - -export async function fusionBuiltinToolArtifactsForTest( - profiles: unknown[], - coreEndpoint: string, - coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration -): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> { - return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); -} - -function fusionBuiltinMcpServer({ - entry, - env, - name -}: { - entry: string; - env: Record; - name: string; -}): GatewayMcpServerConfig { - return { - args: [entry], - command: process.execPath, - env: { - ELECTRON_RUN_AS_NODE: "1", - ...env - }, - name, - protocolVersion: "2024-11-05", - requestTimeoutMs: 600000, - startupTimeoutMs: 600000, - stdioMessageMode: "content-length", - transport: "stdio" - }; -} - -function bundledFusionBuiltinMcpEntryPath(): string { - return pathJoin(__dirname, "fusion-vision-mcp.js"); -} - -function fusionToolFallbackMcpServer( - profiles: unknown[], - existingServers: unknown[] -): GatewayMcpServerConfig | undefined { - const tools = fusionFallbackToolDefinitions(profiles, fusionToolNamesBackedByMcpServers(existingServers)); - if (tools.length === 0) { - return undefined; - } - - return { - args: [bundledFusionToolFallbackMcpEntryPath()], - command: process.execPath, - env: { - ELECTRON_RUN_AS_NODE: "1", - FUSION_FALLBACK_TOOLS_JSON: JSON.stringify(tools) - }, - name: uniqueMcpServerName("ccr-fusion-tool-fallback", existingServers), - protocolVersion: "2024-11-05", - requestTimeoutMs: 600000, - startupTimeoutMs: 600000, - stdioMessageMode: "content-length", - transport: "stdio" - }; -} - -function bundledFusionToolFallbackMcpEntryPath(): string { - return pathJoin(__dirname, "fusion-tool-fallback-mcp.js"); -} - -function toolHubMcpServer(config: AppConfig, backendServers: unknown[]): GatewayMcpServerConfig | undefined { - const toolHub = config.toolHub; - const runtimeBackendServers = [ - ...toolHubBuiltInBackendServers(config), - ...backendServers - ]; - const runtimeConfig = toolHubMcpRuntimeConfig(config, runtimeBackendServers); - if (!toolHub?.enabled || !runtimeConfig) { - return undefined; - } - - return { - ...runtimeConfig, - name: uniqueMcpServerName(TOOL_HUB_MCP_SERVER_NAME, runtimeBackendServers), - protocolVersion: "2024-11-05", - requestTimeoutMs: toolHubRequestTimeoutMs(config, runtimeBackendServers), - startupTimeoutMs: 600000, - stdioMessageMode: "content-length", - transport: "stdio" - }; -} - -export function fusionFallbackToolDefinitions( - profiles: unknown[], - backedToolNames: Set = new Set() -): FusionFallbackToolDefinition[] { - const byName = new Map(); - - for (const profile of profiles) { - if (!isRecord(profile) || profile.enabled === false) { - continue; - } - - if (Array.isArray(profile.tools)) { - for (const tool of profile.tools) { - if (!isRecord(tool)) { - continue; - } - const name = stringValue(tool.name); - if (!name) { - continue; - } - if (backedToolNames.has(name)) { - continue; - } - - const existing = byName.get(name); - const description = stringValue(tool.description); - const inputSchema = isRecord(tool.inputSchema) - ? tool.inputSchema - : isRecord(tool.input_schema) - ? tool.input_schema - : undefined; - const unavailableMessage = fusionFallbackToolUnavailableMessage(profile, name); - if (existing) { - if (!existing.description && description) { - existing.description = description; - } - if (!existing.inputSchema && inputSchema) { - existing.inputSchema = inputSchema; - } - if (!existing.unavailableMessage && unavailableMessage) { - existing.unavailableMessage = unavailableMessage; - } - continue; - } - - byName.set(name, { - ...(description ? { description } : {}), - ...(inputSchema ? { inputSchema } : {}), - ...(unavailableMessage ? { unavailableMessage } : {}), - name - }); - } - } - - const browserFallback = browserWebSearchFallbackToolDefinition(profile, backedToolNames); - if (browserFallback && !byName.has(browserFallback.name)) { - byName.set(browserFallback.name, browserFallback); - } - } - - return [...byName.values()]; -} - -type FusionFallbackToolDefinition = { - description?: string; - inputSchema?: Record; - name: string; - unavailableMessage?: string; -}; - -function fusionFallbackToolUnavailableMessage(profile: unknown, toolName: string): string | undefined { - if (!isRecord(profile)) { - return undefined; - } - const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; - const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; - const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); - if (webSearchConfig?.provider !== "browser" || webSearchConfig.toolName !== toolName) { - return undefined; - } - return browserWebSearchUnavailableMessage(toolName); -} - -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." - ].join(" "); -} - -function browserWebSearchFallbackToolDefinition( - profile: Record, - backedToolNames: Set -): FusionFallbackToolDefinition | undefined { - const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; - const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; - const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); - if (webSearchConfig?.provider !== "browser" || !webSearchConfig.toolName || backedToolNames.has(webSearchConfig.toolName)) { - return undefined; - } - return { - description: "Fallback registration for CCR In-app Browser web search when the Electron browser integration is unavailable.", - inputSchema: { - additionalProperties: true, - properties: { - count: { maximum: 20, minimum: 1, type: "number" }, - prompt: { type: "string" }, - query: { type: "string" } - }, - required: ["prompt"], - type: "object" - }, - name: webSearchConfig.toolName, - unavailableMessage: fusionFallbackToolUnavailableMessage(profile, webSearchConfig.toolName) - }; -} - -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 - .map((server) => isRecord(server) ? stringValue(server.name)?.toLowerCase() : undefined) - .filter((name): name is string => Boolean(name)) - ); - if (!used.has(baseName.toLowerCase())) { - return baseName; - } - for (let index = 2; ; index += 1) { - const candidate = `${baseName}-${index}`; - if (!used.has(candidate.toLowerCase())) { - return candidate; - } - } -} - -function withFusionVirtualModelAliases(profiles: unknown[]): unknown[] { - return profiles.map((profile) => { - if (!isRecord(profile)) { - return profile; - } - const match = isRecord(profile.match) ? profile.match : {}; - const exactAliases = stringListValue(match.exactAliases); - const catalogNames = exactAliases.length > 0 - ? exactAliases - : [stringValue(profile.key) || stringValue(profile.displayName)].filter((value): value is string => Boolean(value)); - const fusionAliases = catalogNames.flatMap(fusionModelSelectors).filter(Boolean); - if (fusionAliases.length === 0) { - return profile; - } - return { - ...profile, - match: { - ...match, - exactAliases: uniqueStrings([...exactAliases, ...fusionAliases]) - } - }; - }); -} - -function withCodexCompatibleVirtualModelProfiles(profiles: unknown[]): unknown[] { - return profiles.map((profile) => { - if (!isRecord(profile) || profile.enabled === false) { - return profile; - } - const materialization = isRecord(profile.materialization) ? profile.materialization : {}; - if (materialization.enabled === false || materialization.includeInGatewayModels === false) { - return profile; - } - const execution = isRecord(profile.execution) ? profile.execution : {}; - if (execution.clientToolsPolicy === "allow") { - return profile; - } - return { - ...profile, - execution: { - ...execution, - clientToolsPolicy: "allow" - } - }; - }); -} - -function fusionModelSelector(model: string): string { - const normalized = fusionModelNameFromSelector(model); - return normalized ? `${fusionModelProviderName}/${normalized}` : ""; -} - -function fusionModelSelectors(model: string): string[] { - const normalized = fusionModelNameFromSelector(model); - if (!normalized) { - return []; - } - const lowerModel = normalized.toLowerCase(); - return uniqueStrings([ - fusionModelSelector(normalized), - lowerModel, - `${fusionModelProviderName}/${lowerModel}`, - `${fusionModelProviderName.toLowerCase()}/${lowerModel}` - ]); -} - -function fusionModelNameFromSelector(model: string): string { - const trimmed = model.trim(); - const prefix = `${fusionModelProviderName}/`; - return trimmed.toLowerCase().startsWith(prefix.toLowerCase()) - ? trimmed.slice(prefix.length).trim() - : trimmed; -} - -function legacyFusionVisionConfig(profile: Record): VirtualModelFusionVisionConfig | undefined { - const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_VISION_TOOL_NAME, "matchMultimodal"); - return toolName ? { toolName } : undefined; -} - -function legacyFusionWebSearchConfig(profile: Record): VirtualModelFusionWebSearchConfig | undefined { - const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, "matchWebSearch"); - return toolName ? { provider: defaultFusionWebSearchProvider, toolName } : undefined; -} - -function legacyFusionBuiltinToolName( - profile: Record, - baseToolName: string, - executionFlag: "matchMultimodal" | "matchWebSearch" -): string | undefined { - const tools = Array.isArray(profile.tools) ? profile.tools : []; - const toolName = tools - .map((tool) => isRecord(tool) ? stringValue(tool.name) ?? "" : "") - .find((name) => fusionBuiltinToolNameMatches(name, baseToolName)); - if (toolName) { - return toolName; - } - const execution = isRecord(profile.execution) ? profile.execution : {}; - 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; - } - const toolName = stringValue(value.toolName); - if (!toolName) { - return undefined; - } - const config: VirtualModelFusionVisionConfig = { - toolName, - apiKey: stringValue(value.apiKey), - baseUrl: stringValue(value.baseUrl), - model: stringValue(value.model), - modelSelector: stringValue(value.modelSelector) - }; - const timeoutMs = numberValue(value.timeoutMs); - if (timeoutMs) { - config.timeoutMs = timeoutMs; - } - return config; -} - -function readFusionWebSearchConfig(value: unknown): VirtualModelFusionWebSearchConfig | undefined { - if (!isRecord(value)) { - return undefined; - } - const toolName = stringValue(value.toolName); - if (!toolName) { - return undefined; - } - const config: VirtualModelFusionWebSearchConfig = { - toolName, - env: isRecord(value.env) ? stringRecordFromUnknown(value.env) : undefined, - provider: parseFusionWebSearchProvider(value.provider) - }; - const resultCount = numberValue(value.resultCount); - if (resultCount) { - config.resultCount = resultCount; - } - const timeoutMs = numberValue(value.timeoutMs); - if (timeoutMs) { - config.timeoutMs = timeoutMs; - } - return config; -} - -function resolveFusionVisionRuntime( - config: VirtualModelFusionVisionConfig -): { model?: string; providers: CoreGatewayProvider[] } { - const selector = config.modelSelector || config.model; - if (config.baseUrl) { - return { - model: config.model || config.modelSelector, - providers: [] - }; - } - - const parsed = parseFusionModelSelector(selector); - if (!parsed) { - return { - model: selector ? normalizeGatewayModelSelector(selector) : undefined, - providers: [] - }; - } - - return { - model: `${parsed.providerName}/${parsed.model}`, - providers: [] - }; -} - -function parseFusionModelSelector(value: string | undefined): { model: string; providerName: string } | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - const commaIndex = trimmed.indexOf(","); - if (commaIndex > 0 && commaIndex < trimmed.length - 1) { - const providerName = trimmed.slice(0, commaIndex).trim(); - const model = trimmed.slice(commaIndex + 1).trim(); - return providerName && model ? { model, providerName } : undefined; - } - const slashIndex = trimmed.indexOf("/"); - if (slashIndex > 0 && slashIndex < trimmed.length - 1) { - const providerName = trimmed.slice(0, slashIndex).trim(); - const model = trimmed.slice(slashIndex + 1).trim(); - return providerName && model ? { model, providerName } : undefined; - } - return undefined; -} - -function normalizeGatewayModelSelector(value: string): string { - const parsed = parseFusionModelSelector(value); - return parsed ? `${parsed.providerName}/${parsed.model}` : value.trim(); -} - -function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSearchProvider | undefined { - const normalized = stringValue(value)?.toLowerCase(); - if ( - normalized === "brave" || - normalized === "bing" || - normalized === "google_cse" || - normalized === "serper" || - normalized === "serpapi" || - normalized === "tavily" || - normalized === "exa" || - normalized === "browser" - ) { - return normalized; - } - return undefined; -} - -function stringRecordFromUnknown(value: Record): Record | undefined { - const result: Record = {}; - for (const [key, rawValue] of Object.entries(value)) { - const normalizedKey = key.trim(); - const normalizedValue = stringValue(rawValue); - if (normalizedKey && normalizedValue) { - result[normalizedKey] = normalizedValue; - } - } - return Object.keys(result).length ? result : undefined; -} - -function sanitizeMcpServerName(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9_.-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80) || "fusion"; -} - -function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string): Record { - const enabled = rawTraceEnabledFromEnv() && shouldRecordRequestLogs(config); - return { - deleteLocalAfterUpload: false, - enabled, - maxPartBytes: maxUsageCaptureBytes, - mode: "wire_raw", - spoolDir: RAW_TRACE_SPOOL_DIR, - sync: { - enabled, - endpoint: `${endpoint(config.gateway.host, config.gateway.port)}${rawTraceSyncPath}`, - headers: { - [rawTraceSyncHeader]: rawTraceSyncToken - }, - timeoutMs: 5000 - } - }; -} - -function shouldRecordRequestLogs(config: AppConfig): boolean { - return Boolean(config.observability?.requestLogs || config.observability?.agentAnalysis); -} - -function rawTraceEnabledFromEnv(): boolean { - const value = (process.env.CCR_RAW_TRACE_ENABLED ?? process.env.CCR_RAW_TRACE ?? "").trim().toLowerCase(); - return value === "1" || value === "true" || value === "yes" || value === "on"; -} - -function readRawTraceRequestLogUpdate(manifest: Record): RequestLogRawTraceUpdateInput | undefined { - const requestId = stringValue(manifest.turnKey); - const parts = Array.isArray(manifest.parts) - ? manifest.parts.filter((part): part is Record => isRecord(part)) - : []; - if (!requestId || parts.length === 0) { - return undefined; - } - - const upstreamRequestMetadata = readRawTraceJsonPart(parts, "upstream_request_metadata"); - const upstreamResponseMetadata = readRawTraceJsonPart(parts, "upstream_response_metadata"); - const upstreamRequestBody = readRawTraceTextPart(parts, "upstream_request"); - const upstreamResponseStream = readRawTraceTextPart(parts, "response_stream"); - const upstreamResponseBody = upstreamResponseStream ?? readRawTraceTextPart(parts, "upstream_response"); - const target = isRecord(manifest.target) ? manifest.target : {}; - const rawUrl = stringValue(upstreamRequestMetadata?.url); - const url = sanitizeUrlForLog(rawUrl); - - return { - method: stringValue(upstreamRequestMetadata?.method) || "POST", - model: stringValue(target.model), - path: pathFromUrl(url), - provider: stringValue(target.providerName) || stringValue(target.provider), - requestBodyContentType: upstreamRequestBody?.contentType, - requestBodyText: upstreamRequestBody?.text, - requestHeaders: headerRecordFromUnknown(upstreamRequestMetadata?.headers), - requestId, - isStream: upstreamResponseStream !== undefined, - responseBodyContentType: upstreamResponseBody?.contentType, - responseBodyText: upstreamResponseBody?.text, - responseHeaders: headerRecordFromUnknown(upstreamResponseMetadata?.headers), - statusCode: numberValue(upstreamResponseMetadata?.statusCode), - url - }; -} - -function readRawTraceJsonPart(parts: Record[], partType: string): Record | undefined { - const text = readRawTraceTextPart(parts, partType)?.text; - if (!text) { - return undefined; - } - try { - const parsed = JSON.parse(text) as unknown; - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - -function readRawTraceTextPart(parts: Record[], partType: string): RawTracePartText | undefined { - const part = parts.find((candidate) => stringValue(candidate.partType) === partType); - const filePath = stringValue(part?.filePath); - if (!filePath || !isRawTraceSpoolFile(filePath)) { - return undefined; - } - try { - return { - contentType: stringValue(part?.contentType), - text: readFileSync(filePath, "utf8") - }; - } catch (error) { - console.warn(`[gateway] Failed to read raw trace part ${partType}: ${formatError(error)}`); - return undefined; - } -} - -function cleanupRawTraceBundle(manifest: Record): void { - const parts = Array.isArray(manifest.parts) - ? manifest.parts.filter((part): part is Record => isRecord(part)) - : []; - const firstFilePath = parts.map((part) => stringValue(part.filePath)).find((value): value is string => Boolean(value)); - if (!firstFilePath || !isRawTraceSpoolFile(firstFilePath)) { - return; - } - try { - rmSync(dirname(firstFilePath), { force: true, recursive: true }); - } catch (error) { - console.warn(`[gateway] Failed to clean raw trace bundle: ${formatError(error)}`); - } -} - -function isRawTraceSpoolFile(filePath: string): boolean { - const spoolDir = pathResolve(RAW_TRACE_SPOOL_DIR); - const resolvedFile = pathResolve(filePath); - return dirname(resolvedFile) !== spoolDir && resolvedFile.startsWith(`${spoolDir}${pathSep}`); -} - -function headerRecordFromUnknown(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const headers: Record = {}; - for (const [key, headerValue] of Object.entries(value)) { - if (headerValue === undefined || headerValue === null) { - continue; - } - headers[key] = Array.isArray(headerValue) - ? headerValue.map((item) => String(item)).join(", ") - : String(headerValue); - } - return headers; -} - -function sanitizeUrlForLog(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - try { - const url = new URL(value); - for (const key of [...url.searchParams.keys()]) { - if (isSensitiveQueryParam(key)) { - url.searchParams.set(key, "[redacted]"); - } - } - return url.toString(); - } catch { - return value; - } -} - -function isSensitiveQueryParam(value: string): boolean { - const normalized = value.trim().toLowerCase(); - return normalized === "key" || normalized === "api_key" || normalized === "apikey" || normalized === "access_token"; -} - -function pathFromUrl(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - try { - return new URL(value).pathname || undefined; - } catch { - return undefined; - } -} - -function createBodySampler() { - const chunks: Buffer[] = []; - let totalBytes = 0; - let truncated = false; - - return { - append(chunk: Buffer | string) { - if (truncated) { - return; - } - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - if (totalBytes + buffer.byteLength > maxUsageCaptureBytes) { - const remaining = Math.max(0, maxUsageCaptureBytes - totalBytes); - if (remaining > 0) { - chunks.push(buffer.subarray(0, remaining)); - totalBytes += remaining; - } - truncated = true; - return; - } - chunks.push(buffer); - totalBytes += buffer.byteLength; - }, - isTruncated() { - return truncated; - }, - read() { - return Buffer.concat(chunks, totalBytes).toString("utf8"); - } - }; -} - -function applyProviderCapabilityRouting(input: { - body?: Buffer; - config: AppConfig; - fallback: RouterFallbackConfig; - headers: Record; - path: string; - routedModel?: string; -}): { body?: Buffer; fallback: RouterFallbackConfig; routedModel?: string } { - const protocol = requestProtocolForPath(input.path); - if (!protocol) { - return { - body: input.body, - fallback: input.fallback, - routedModel: input.routedModel - }; - } - - rewriteProviderHeader(input.headers, "x-target-provider", input.config, protocol); - rewriteProviderListHeader(input.headers, "x-target-providers", input.config, protocol); - rewriteProviderHeader(input.headers, "x-gateway-target-provider", input.config, protocol); - - const routedModel = rewriteModelSelectorForProtocol(input.routedModel, input.config, protocol); - const fallback = rewriteFallbackForProtocol(input.fallback, input.config, protocol); - const body = rewriteBodyModelForProtocol(input.body, input.config, protocol); - clearTargetProviderHeadersForModelSelector(input.headers, input.config, body, routedModel); - - return { - body, - fallback, - routedModel - }; -} - -export function prepareGatewayUpstreamAttemptForTest(input: { - body: Record; - config: AppConfig; - fallback?: RouterFallbackConfig; - headers: Record; - method: string; - path: string; - routedModel?: string; -}): { - body?: Record; - credentialChain?: string[]; - credentialIds?: string[]; - credentialProtocol?: GatewayProviderProtocol; - fallback: RouterFallbackConfig; - headers?: Record; - logicalProvider?: string; - model?: string; - routedModel?: string; -} { - const headers = { ...input.headers }; - const providerCapabilityRouting = applyProviderCapabilityRouting({ - body: Buffer.from(`${JSON.stringify(input.body)}\n`, "utf8"), - config: input.config, - fallback: input.fallback ?? input.config.Router.fallback, - headers, - path: input.path, - routedModel: input.routedModel - }); - const attempt = prepareUpstreamCredentialAttempt({ - attempt: { - body: providerCapabilityRouting.body, - index: 0, - model: normalizeRouteSelector(providerCapabilityRouting.routedModel) - }, - config: input.config, - headers, - method: input.method, - path: input.path - }); - return { - body: parseJsonObjectSafe(attempt.body), - credentialChain: attempt.credentialChain, - credentialIds: attempt.credentialIds, - credentialProtocol: attempt.credentialProtocol, - fallback: providerCapabilityRouting.fallback, - headers: attempt.headers, - logicalProvider: attempt.logicalProvider, - model: attempt.model, - routedModel: providerCapabilityRouting.routedModel - }; -} - -export function prepareCodexApplyPatchBridgeRequest(input: { - body?: Buffer; - config: AppConfig; - headers: IncomingHttpHeaders; - method: string; - path: string; - routedModel?: string; -}): { body: Buffer; diagnostic: string } | undefined { - if (!codexApplyPatchBridgeEnabled(input.config, input.headers, input.method, input.path)) { - return undefined; - } - const parsedBody = parseJsonObjectSafe(input.body); - if (!parsedBody) { - return undefined; - } - const model = input.routedModel || stringValue(parsedBody.model); - if (!codexPatchBridgeModelEligible(model)) { - return undefined; - } - const transformed = transformCodexApplyPatchBridgeRequestBody(parsedBody); - if (!transformed.changed) { - return undefined; - } - return { - body: Buffer.from(`${JSON.stringify(transformed.body)}\n`, "utf8"), - diagnostic: `${model ?? "unknown"}:${transformed.changedParts.join(",")}` - }; -} - -export function transformCodexApplyPatchBridgeRequestBody(body: Record): { - body: Record; - changed: boolean; - changedParts: string[]; -} { - const next = { ...body }; - const changedParts: string[] = []; - const tools = transformCodexApplyPatchBridgeTools(body.tools); - if (tools.changed) { - next.tools = tools.value; - changedParts.push("tools"); - const instructions = transformCodexApplyPatchBridgeInstructions(body.instructions); - if (instructions.changed) { - next.instructions = instructions.value; - changedParts.push("instructions"); - } - const input = transformCodexApplyPatchBridgeInput(body.input); - if (input.changed) { - next.input = input.value; - changedParts.push("input"); - } - } - return { - body: next, - changed: changedParts.length > 0, - changedParts - }; -} - -function transformCodexApplyPatchBridgeTools(value: unknown): { value: unknown; changed: boolean } { - if (!Array.isArray(value)) { - return { value, changed: false }; - } - const hasApplyPatchTool = value.some((tool) => isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch"); - if (!hasApplyPatchTool) { - return { value, changed: false }; - } - let changed = false; - const tools = value.map((tool) => { - if (isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch") { - changed = true; - return virtualApplyPatchToolSpec(); - } - const shellTool = transformCodexPatchBridgeShellTool(tool); - if (shellTool.changed) { - changed = true; - return shellTool.value; - } - return tool; - }); - return { value: tools, changed }; -} - -function transformCodexApplyPatchBridgeInstructions(value: unknown): { value: unknown; changed: boolean } { - const text = rawStringValue(value); - if (text === undefined) { - return value === undefined - ? { value: codexPatchBridgeInstructionText, changed: true } - : { value, changed: false }; - } - if (text.includes(codexPatchBridgeInstructionText)) { - return { value, changed: false }; - } - return { - value: `${text.trimEnd()}\n\n${codexPatchBridgeInstructionText}`, - changed: true - }; -} - -function transformCodexPatchBridgeShellTool(value: unknown): { value: unknown; changed: boolean } { - if (!isRecord(value) || value.type !== "function") { - return { value, changed: false }; - } - const name = stringValue(value.name); - if (name !== "exec_command" && name !== "write_stdin") { - return { value, changed: false }; - } - let changed = false; - const next: Record = { ...value }; - const description = rawStringValue(value.description) ?? ""; - if (!description.includes(codexPatchBridgeShellToolGuidance)) { - next.description = description - ? `${description} ${codexPatchBridgeShellToolGuidance}` - : codexPatchBridgeShellToolGuidance; - changed = true; - } - if (name === "exec_command") { - const parameters = transformCodexPatchBridgeExecCommandParameters(value.parameters); - if (parameters.changed) { - next.parameters = parameters.value; - changed = true; - } - } - return { value: changed ? next : value, changed }; -} - -function transformCodexPatchBridgeExecCommandParameters(value: unknown): { value: unknown; changed: boolean } { - if (!isRecord(value) || !isRecord(value.properties) || !isRecord(value.properties.cmd)) { - return { value, changed: false }; - } - const cmd = value.properties.cmd; - const description = rawStringValue(cmd.description) ?? ""; - if (description.includes(codexPatchBridgeShellToolGuidance)) { - return { value, changed: false }; - } - return { - value: { - ...value, - properties: { - ...value.properties, - cmd: { - ...cmd, - description: description - ? `${description} ${codexPatchBridgeShellToolGuidance}` - : codexPatchBridgeShellToolGuidance - } - } - }, - changed: true - }; -} - -function transformCodexApplyPatchBridgeInput(value: unknown): { value: unknown; changed: boolean } { - if (!Array.isArray(value)) { - return { value, changed: false }; - } - const applyPatchCallIds = new Set(); - for (const item of value) { - if (isRecord(item) && item.type === "custom_tool_call" && item.name === "apply_patch") { - const callId = stringValue(item.call_id); - if (callId) { - applyPatchCallIds.add(callId); - } - } - } - let changed = false; - const items = value.map((item) => { - const transformed = transformCodexApplyPatchBridgeInputItem(item, applyPatchCallIds); - changed ||= transformed.changed; - return transformed.value; - }); - return { value: items, changed }; -} - -function transformCodexApplyPatchBridgeInputItem(value: unknown, applyPatchCallIds: Set): { value: unknown; changed: boolean } { - if (!isRecord(value)) { - return { value, changed: false }; - } - if (value.type === "custom_tool_call" && value.name === "apply_patch") { - const { input: patchInput, name: _name, type: _type, ...rest } = value; - return { - value: { - ...rest, - type: "function_call", - name: virtualApplyPatchToolName, - arguments: JSON.stringify({ patch: rawStringValue(patchInput) ?? "" }) - }, - changed: true - }; - } - if ( - value.type === "custom_tool_call_output" && - (applyPatchCallIds.has(stringValue(value.call_id) ?? "") || value.name === "apply_patch") - ) { - const { name: _name, type: _type, ...rest } = value; - return { - value: { - ...rest, - type: "function_call_output" - }, - changed: true - }; - } - return { value, changed: false }; -} - -function virtualApplyPatchToolSpec(): Record { - return { - type: "function", - name: virtualApplyPatchToolName, - description: [ - "Edit files by returning exactly one complete apply_patch patch.", - "The patch field must be raw patch grammar text starting with *** Begin Patch and ending with *** End Patch.", - "Do not wrap the patch in JSON, markdown fences, shell commands, cat, sed, perl, or python.", - "The patch field must match this Lark grammar:", - virtualApplyPatchLarkGrammar - ].join("\n\n"), - strict: true, - parameters: { - type: "object", - additionalProperties: false, - required: ["patch"], - properties: { - patch: { - type: "string", - description: [ - "Raw apply_patch grammar text matching this Lark grammar:", - virtualApplyPatchLarkGrammar - ].join("\n\n") - } - } - } - }; -} - -function codexApplyPatchBridgeEnabled(config: AppConfig, headers: IncomingHttpHeaders, method: string, path: string): boolean { - const codexRule = config.Router.builtInRules?.codex; - return (method || "GET").toUpperCase() === "POST" && - requestProtocolForPath(path) === "openai_responses" && - isCodexUserAgent(headers) && - codexRule?.enabled !== false; -} - -function isCodexUserAgent(headers: IncomingHttpHeaders): boolean { - return readHeader(headers["user-agent"])?.toLowerCase().includes("codex") ?? false; -} - -function codexPatchBridgeModelEligible(model: string | undefined): boolean { - const modelName = modelNameForPatchBridge(model); - return Boolean(modelName) && !modelName.toLowerCase().includes("gpt"); -} - -function modelNameForPatchBridge(model: string | undefined): string { - const normalized = normalizeRouteSelector(model) ?? ""; - const slashIndex = normalized.lastIndexOf("/"); - return slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; -} - -function codexApplyPatchBridgeResponseStream(input: Readable, headers: Headers): Readable { - const contentType = headers.get("content-type")?.toLowerCase() ?? ""; - if (contentType.includes("text/event-stream")) { - return input.pipe(new Transform({ - transform(chunk, _encoding, callback) { - transformSseChunk(this, chunk); - callback(); - }, - flush(callback) { - flushSseTransform(this); - callback(); - } - })); - } - if (contentType.includes("application/json")) { - 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 raw = Buffer.concat(chunks).toString("utf8"); - try { - const parsed = JSON.parse(raw); - const transformed = transformCodexApplyPatchBridgeResponseValue(parsed); - this.push(Buffer.from(`${JSON.stringify(transformed.value)}\n`, "utf8")); - } catch { - this.push(Buffer.from(raw, "utf8")); - } - callback(); - } - })); - } - return input; -} - -export function transformCodexApplyPatchBridgeResponseValue(value: unknown): { value: unknown; changed: boolean } { - if (!isRecord(value)) { - return { value, changed: false }; - } - let changed = false; - const next = { ...value }; - if (isRecord(value.item)) { - const item = transformVirtualApplyPatchFunctionCall(value.item, value.type === "response.output_item.added"); - if (item.changed) { - next.item = item.value; - changed = true; - } - } - if (Array.isArray(value.output)) { - const output = transformCodexApplyPatchBridgeResponseItems(value.output); - if (output.changed) { - next.output = output.value; - changed = true; - } - } - if (isRecord(value.response) && Array.isArray(value.response.output)) { - const output = transformCodexApplyPatchBridgeResponseItems(value.response.output); - if (output.changed) { - next.response = { - ...value.response, - output: output.value - }; - changed = true; - } - } - const item = transformVirtualApplyPatchFunctionCall(next, false); - if (item.changed) { - return item; - } - return { value: next, changed }; -} - -function transformCodexApplyPatchBridgeResponseItems(items: unknown[]): { value: unknown[]; changed: boolean } { - let changed = false; - const value = items.map((item) => { - const transformed = isRecord(item) - ? transformVirtualApplyPatchFunctionCall(item, false) - : { value: item, changed: false }; - changed ||= transformed.changed; - return transformed.value; - }); - return { value, changed }; -} - -function transformVirtualApplyPatchFunctionCall(item: Record, allowEmptyInput: boolean): { value: unknown; changed: boolean } { - if (item.type !== "function_call" || item.name !== virtualApplyPatchToolName) { - return { value: item, changed: false }; - } - const patch = patchInputFromVirtualApplyPatchArguments(item.arguments); - if (patch === undefined && !allowEmptyInput) { - return { value: item, changed: false }; - } - const { arguments: _arguments, name: _name, type: _type, ...rest } = item; - return { - value: { - ...rest, - type: "custom_tool_call", - name: "apply_patch", - input: patch ?? "" - }, - changed: true - }; -} - -function patchInputFromVirtualApplyPatchArguments(value: unknown): string | undefined { - if (isRecord(value)) { - return rawStringValue(value.patch); - } - const text = rawStringValue(value); - if (text === undefined) { - return undefined; - } - try { - const parsed = JSON.parse(text); - return isRecord(parsed) ? rawStringValue(parsed.patch) : undefined; - } catch { - return undefined; - } -} - -function transformSseChunk(stream: Transform, chunk: Buffer | string): void { - const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string }; - state.__ccrCodexPatchBridgeSsePending = (state.__ccrCodexPatchBridgeSsePending ?? "") + chunk.toString(); - while (state.__ccrCodexPatchBridgeSsePending) { - const match = /\r?\n\r?\n/.exec(state.__ccrCodexPatchBridgeSsePending); - if (!match || match.index === undefined) { - break; - } - const block = state.__ccrCodexPatchBridgeSsePending.slice(0, match.index); - const delimiter = match[0]; - state.__ccrCodexPatchBridgeSsePending = state.__ccrCodexPatchBridgeSsePending.slice(match.index + delimiter.length); - stream.push(transformCodexApplyPatchBridgeSseEvent(block) + delimiter); - } -} - -function flushSseTransform(stream: Transform): void { - const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string }; - if (state.__ccrCodexPatchBridgeSsePending) { - stream.push(transformCodexApplyPatchBridgeSseEvent(state.__ccrCodexPatchBridgeSsePending)); - state.__ccrCodexPatchBridgeSsePending = ""; - } -} - -export function transformCodexApplyPatchBridgeSseEvent(block: string): string { - const lines = block.split(/\r?\n/g); - const data = lines - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).replace(/^ /, "")) - .join("\n"); - if (!data || data === "[DONE]") { - return block; - } - try { - const parsed = JSON.parse(data); - const transformed = transformCodexApplyPatchBridgeResponseValue(parsed); - if (!transformed.changed) { - return block; - } - const event = stringValue((transformed.value as Record).type) || stringValue(parsed.type); - return [ - event ? `event: ${event}` : undefined, - `data: ${JSON.stringify(transformed.value)}` - ].filter(Boolean).join("\n"); - } catch { - return block; - } -} - -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; -} - -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[], - 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"); -} - -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 } : {}; - 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 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; - 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}` : "", - result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" - ].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 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) { - 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 ? "..." : ""}`; -} - -export 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")) { - if (context.protocol === "anthropic_messages") { - return anthropicHostedWebSearchProtocolSseStream(input, context, integration); - } - return hostedWebSearchProtocolSseStream(input, context, integration); - } - 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 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[], - 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 hasClientToolUse = responseValueContainsAnthropicClientToolUse(value); - const answer = hasClientToolUse || 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) } : {}), - ...(shouldEndAnthropicHostedWebSearchTurn(value.stop_reason, Boolean(answer), hasClientToolUse) ? { 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 hasClientToolUse = sseEventsContainAnthropicClientToolUse(events); - const answer = hasClientToolUse || 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), 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`; -} - -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 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); - 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 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; - } - 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 sseEventIsDone(event: ParsedSseEvent): boolean { - return Boolean(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 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()) - .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 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 []; - } - 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) : ""; - for (const candidate of fusionWebSearchToolCandidates(config)) { - if (!normalizedModel || candidate.aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { - return candidate.toolName; - } - } - return undefined; -} - -function fusionWebSearchToolCandidates(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; - } - const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; - const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; - const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); - if (!webSearchConfig?.toolName) { - 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))); - candidates.push({ aliases, toolName: webSearchConfig.toolName }); - } - return candidates; -} - -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] : []; -} - -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 -): 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 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) { - 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) => { - return sseEventContainsAnthropicWebSearchBlock(event); - }); -} - -function sseEventsContainVisibleText(events: ParsedSseEvent[]): boolean { - 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[] { - 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, - hasClientToolUse: 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 (isRecord(delta) && shouldEndAnthropicHostedWebSearchTurn(delta.stop_reason, didSynthesizeAnswer, hasClientToolUse)) { - nextData.delta = { ...delta, stop_reason: "end_turn" }; - } - return { - ...event, - data: nextData - }; -} - -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); - 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 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; - } - 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 = anthropicWebSearchResultSnippet(result); - return { - encrypted_content: "", - ...(snippet ? { snippet: snippet.slice(0, 1_200) } : {}), - title: result.title, - type: "web_search_result", - url: result.url - }; -} - -function anthropicWebSearchResultSnippet(result: BrowserWebSearchProtocolResult): string | undefined { - const parts = [ - result.snippet ? `Search snippet: ${sanitizeWebSearchEvidenceText(result.snippet)}` : "", - result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : "", - result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" - ].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"); -} - -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(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 { - 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")) { - return "anthropic_messages"; - } - if (normalized === "/v1/chat/completions" || normalized === "/chat/completions" || normalized.endsWith("/chat/completions")) { - return "openai_chat_completions"; - } - if (normalized === "/v1/responses" || normalized === "/responses" || normalized.endsWith("/responses")) { - return "openai_responses"; - } - if (/\/v1(?:beta)?\/models\/[^/]+:(?:generatecontent|streamgeneratecontent)$/i.test(normalized)) { - return "gemini_generate_content"; - } - if (/\/v1(?:beta)?\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i.test(normalized)) { - return "gemini_interactions"; - } - return undefined; -} - -export function shouldApplyGatewayRouting(method: string, path: string): boolean { - if (method.toUpperCase() !== "POST") { - return false; - } - const protocol = requestProtocolForPath(path); - if (protocol === "gemini_interactions") { - return /\/v1(?:beta)?\/interactions$/i.test(path); - } - return Boolean(protocol); -} - -function rewriteProviderHeader( - headers: Record, - headerName: string, - config: AppConfig, - protocol: GatewayProviderProtocol -): void { - const value = headers[headerName]; - if (!value) { - return; - } - headers[headerName] = rewriteProviderSelectorForProtocol(value, config, protocol); -} - -function rewriteProviderListHeader( - headers: Record, - headerName: string, - config: AppConfig, - protocol: GatewayProviderProtocol -): void { - const value = headers[headerName]; - if (!value) { - return; - } - headers[headerName] = value - .split(",") - .map((item) => rewriteProviderSelectorForProtocol(item.trim(), config, protocol)) - .filter(Boolean) - .join(","); -} - -function rewriteProviderSelectorForProtocol(value: string, config: AppConfig, protocol: GatewayProviderProtocol): string { - const provider = findProviderByPublicOrInternalName(config, value); - const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined; - return provider && capability ? providerCapabilityInternalName(provider, capability.type) : value; -} - -function rewriteFallbackForProtocol(fallback: RouterFallbackConfig, config: AppConfig, protocol: GatewayProviderProtocol): RouterFallbackConfig { - const models = fallback.models.map((model) => rewriteModelSelectorForProtocol(model, config, protocol) ?? model); - return models.every((model, index) => model === fallback.models[index]) - ? fallback - : { - ...fallback, - models - }; -} - -function rewriteBodyModelForProtocol(body: Buffer | undefined, config: AppConfig, protocol: GatewayProviderProtocol): Buffer | undefined { - const parsedBody = parseJsonObjectSafe(body); - if (!parsedBody) { - return body; - } - const model = stringValue(parsedBody.model); - const rewrittenModel = rewriteModelSelectorForProtocol(model, config, protocol); - if (!rewrittenModel || rewrittenModel === model) { - return body; - } - return Buffer.from(`${JSON.stringify({ ...parsedBody, model: rewrittenModel })}\n`, "utf8"); -} - -function clearTargetProviderHeadersForModelSelector( - headers: Record, - config: AppConfig, - body: Buffer | undefined, - routedModel: string | undefined -): void { - const parsedBody = parseJsonObjectSafe(body); - const model = stringValue(parsedBody?.model) || routedModel; - if (!resolveConfiguredProviderModelSelector(model, config)) { - return; - } - - delete headers["x-target-provider"]; - delete headers["x-target-providers"]; - delete headers["x-gateway-target-provider"]; -} - -function rewriteModelSelectorForProtocol( - model: string | undefined, - config: AppConfig, - protocol: GatewayProviderProtocol -): string | undefined { - const normalized = normalizeRouteSelector(model); - if (!normalized) { - return model; - } - const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; - const selector = - resolveConfiguredProviderModelSelector(publicModel, config) ?? - resolveUniqueConfiguredProviderModelSelector(publicModel, config); - const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined; - return selector && capability - ? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}` - : publicModel; -} - -function providerCapabilityForClientProtocol( - provider: GatewayProviderConfig, - clientProtocol: GatewayProviderProtocol -): GatewayProviderCapability | undefined { - const capabilities = normalizedProviderCapabilities(provider); - for (const protocol of providerProtocolPreferenceForClient(clientProtocol)) { - const capability = capabilities.find((item) => item.type === protocol); - if (capability) { - return capability; - } - } - return undefined; -} - -function providerProtocolForClientProtocol( - provider: GatewayProviderConfig, - clientProtocol: GatewayProviderProtocol -): GatewayProviderProtocol | undefined { - const capability = providerCapabilityForClientProtocol(provider, clientProtocol); - if (capability) { - return capability.type; - } - const directProtocol = - normalizeProviderProtocol(provider.type) ?? - normalizeProviderProtocol(provider.provider) ?? - inferProtocol(provider); - return providerProtocolPreferenceForClient(clientProtocol).includes(directProtocol) - ? directProtocol - : undefined; -} - -function providerProtocolPreferenceForClient(clientProtocol: GatewayProviderProtocol): GatewayProviderProtocol[] { - if (clientProtocol === "openai_responses") { - return ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_interactions"]; - } - if (clientProtocol === "anthropic_messages") { - return uniqueProviderProtocols([clientProtocol, ...gatewayProviderProtocolFallbackOrder]); - } - return [clientProtocol]; -} - -function uniqueProviderProtocols(protocols: GatewayProviderProtocol[]): GatewayProviderProtocol[] { - const seen = new Set(); - const output: GatewayProviderProtocol[] = []; - for (const protocol of protocols) { - if (seen.has(protocol)) { - continue; - } - seen.add(protocol); - output.push(protocol); - } - return output; -} - -function findProviderByPublicOrInternalName(config: AppConfig, name: string): GatewayProviderConfig | undefined { - const normalized = name.trim().toLowerCase(); - if (!normalized) { - return undefined; - } - const credentialInternalName = parseProviderCredentialInternalName(name); - if (credentialInternalName) { - const internalProviderId = credentialInternalName.providerId.toLowerCase(); - return config.Providers.find((provider) => - provider.name.trim().toLowerCase() === internalProviderId || - providerRuntimeId(provider).toLowerCase() === internalProviderId - ); - } - return modelRegistryForConfig(config).findProvider(normalized); -} - -function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): Headers { - const providerName = headers.get("x-gateway-target-provider-name")?.trim(); - if (!providerName) { - return headers; - } - const credentialInternalName = parseProviderCredentialInternalName(providerName); - if (credentialInternalName) { - const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerId); - if (!provider) { - return headers; - } - const credential = findProviderCredentialBySlug(provider, credentialInternalName.credentialSlug); - const rewritten = new Headers(headers); - rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); - rewritten.set("x-ccr-provider-protocol", credentialInternalName.protocol); - rewritten.set("x-ccr-provider-credential-provider", providerRuntimeId(provider)); - rewritten.set("x-ccr-provider-credential-id", providerCredentialSlug(credential ? providerCredentialRuntimeId(provider, credential) : credentialInternalName.credentialSlug)); - return rewritten; - } - const provider = findProviderByPublicOrInternalName(config, providerName); - if (!provider) { - return headers; - } - const capability = normalizedProviderCapabilities(provider).find((item) => - providerCapabilityNameMatches(provider, item.type, providerName) - ); - const rewritten = new Headers(headers); - rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); - if (capability) { - rewritten.set("x-ccr-provider-protocol", capability.type); - } - return rewritten; -} - -async function fetchUpstreamWithFallback(input: { - body?: Buffer; - config: AppConfig; - coreAuthToken: string; - fallback: RouterFallbackConfig; - headers: Record; - method: string; - path: string; - routedModel?: string; - signal?: AbortSignal; - upstreamUrl: string; -}): Promise { - const fallbackMode = input.fallback.mode; - const attempts = buildUpstreamAttempts( - input.config, - input.fallback, - input.method, - input.path, - input.body, - input.routedModel - ); - const failedAttempts: UpstreamFailedAttempt[] = []; - - for (let index = 0; index < attempts.length; index += 1) { - if (input.signal?.aborted) { - throw new UpstreamRequestError(abortSignalMessage(input.signal), { - failedAttempts - }); - } - - const attempt = prepareUpstreamCredentialAttempt({ - attempt: attempts[index], - config: input.config, - headers: input.headers, - method: input.method, - path: input.path - }); - const hasNextAttempt = index < attempts.length - 1; - - try { - const response = await fetchWithSystemProxy(rewriteRouteModelInUrl(input.upstreamUrl, attempt.model), { - body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined, - headers: withCoreGatewayAuthHeader(omitLocalObservabilityHeaders(attempt.headers ?? input.headers), input.coreAuthToken), - method: input.method, - signal: input.signal - }); - - if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) { - const delayMs = retryDelayAfterStatus(response.status, response.headers, failedAttempts.length); - failedAttempts.push({ - credentialChain: attempt.credentialChain, - credentialIds: attempt.credentialIds, - delayMs, - model: attempt.model, - statusCode: response.status - }); - recordProviderCredentialOutcome(input.config, input.method, attempt, response.status, response.headers); - await drainResponseBody(response); - if (delayMs > 0) { - await delay(delayMs); - } - continue; - } - - return { - attempt, - failedAttempts, - response - }; - } catch (error) { - const message = formatError(error); - const delayMs = hasNextAttempt && !input.signal?.aborted - ? retryDelayAfterNetworkError(failedAttempts.length) - : 0; - failedAttempts.push({ - credentialChain: attempt.credentialChain, - credentialIds: attempt.credentialIds, - delayMs, - error: message, - model: attempt.model - }); - if (input.signal?.aborted) { - throw new UpstreamRequestError(abortSignalMessage(input.signal), { - attempt, - cause: error, - failedAttempts - }); - } - if (hasNextAttempt) { - if (delayMs > 0) { - await delay(delayMs); - } - continue; - } - throw new UpstreamRequestError(message, { - attempt, - cause: error, - failedAttempts - }); - } - } - - throw new UpstreamRequestError("Gateway request failed before reaching an upstream provider.", { - failedAttempts - }); -} - -function prepareUpstreamCredentialAttempt(input: { - attempt: UpstreamAttempt; - config: AppConfig; - headers: Record; - method: string; - path: string; -}): UpstreamAttempt { - const normalizedBody = normalizeConfiguredProviderModelBody(input.attempt.body, input.config); - const target = resolvePlannedProviderCredentialRoutingTarget(input.attempt, input.path) ?? - resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body); - const attemptBody = (body: Buffer | undefined) => usageAwareOpenAiChatAttemptBody({ - body, - config: input.config, - path: input.path, - target - }); - if (!target) { - const body = bodyHasConfiguredProviderModelSelector(input.attempt.body, input.config) - ? input.attempt.body - : normalizedBody?.body ?? input.attempt.body; - return { - ...input.attempt, - body: attemptBody(body), - headers: input.headers - }; - } - - const attemptHeaders = withClaudeCodeOauthBetaHeader(input.headers, input.config, target); - - const credentials = activeProviderCredentials(target.provider); - if (credentials.length === 0) { - const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); - return { - ...input.attempt, - body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), - headers: preserveModelSelector - ? clearTargetProviderHeaders(attemptHeaders) - : targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol) - }; - } - - const usage = estimateLimitUsage(input.method, input.attempt.body ?? Buffer.alloc(0)); - const selection = selectProviderCredentials(target.provider, target.protocol, credentials, usage); - if (selection.credentials.length === 0) { - const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); - return { - ...input.attempt, - body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), - headers: preserveModelSelector - ? clearTargetProviderHeaders(attemptHeaders) - : targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol) - }; - } - - const headers: Record = { - ...attemptHeaders, - "x-target-providers": selection.credentials.map((candidate) => candidate.internalName).join(","), - "x-ccr-logical-provider": providerRuntimeId(target.provider), - "x-ccr-provider-credential-chain": selection.credentials.map((candidate) => candidate.credentialId).join(",") - }; - delete headers["x-target-provider"]; - if (selection.saturated) { - headers["x-ccr-provider-credential-saturated"] = "true"; - } - - return { - ...input.attempt, - body: attemptBody(target.body ?? normalizedBody?.body ?? input.attempt.body), - credentialChain: selection.credentials.map((candidate) => candidate.internalName), - credentialIds: selection.credentials.map((candidate) => candidate.credentialId), - credentialProtocol: target.protocol, - headers, - logicalProvider: target.provider.name - }; -} - -function withClaudeCodeOauthBetaHeader( - headers: Record, - config: AppConfig, - target: ProviderCredentialRoutingTarget -): Record { - if ( - target.protocol !== "anthropic_messages" || - !claudeCodeOauthPluginMatchesTarget(config, target.provider, target.protocol) - ) { - return headers; - } - - const existingEntry = Object.entries(headers) - .find(([name]) => name.trim().toLowerCase() === claudeCodeOauthBetaHeader); - const merged = mergeAnthropicBetaValues(existingEntry?.[1], claudeCodeOauthRequiredBeta); - if (existingEntry?.[0] === claudeCodeOauthBetaHeader && existingEntry[1] === merged) { - return headers; - } - - const next = Object.fromEntries( - Object.entries(headers).filter(([name]) => name.trim().toLowerCase() !== claudeCodeOauthBetaHeader) - ); - next[claudeCodeOauthBetaHeader] = merged; - return next; -} - -function claudeCodeOauthPluginMatchesTarget( - config: AppConfig, - provider: GatewayProviderConfig, - protocol: GatewayProviderProtocol -): boolean { - const targetNames = new Set([ - provider.name, - providerRuntimeId(provider), - providerCapabilityInternalName(provider, protocol) - ].map((name) => name.trim().toLowerCase())); - return (config.providerPlugins ?? []).some((plugin) => { - if (!isLocalClaudeCodeOauthProviderPlugin(plugin)) { - return false; - } - const providerName = stringValue(plugin.providerName)?.toLowerCase(); - return Boolean(providerName && targetNames.has(providerName)); - }); -} - -function mergeAnthropicBetaValues(...values: Array): string { - const seen = new Set(); - const merged: string[] = []; - for (const value of values) { - for (const token of value?.split(",") ?? []) { - const normalized = token.trim(); - const key = normalized.toLowerCase(); - if (!normalized || seen.has(key)) { - continue; - } - seen.add(key); - merged.push(normalized); - } - } - return merged.join(","); -} - -function targetProviderFallbackHeaders( - headers: Record, - provider: GatewayProviderConfig, - protocol: GatewayProviderProtocol -): Record { - const next = { ...headers }; - next["x-target-provider"] = targetProviderHeaderValue(provider, protocol); - delete next["x-target-providers"]; - delete next["x-gateway-target-provider"]; - return next; -} - -function clearTargetProviderHeaders(headers: Record): Record { - const next = { ...headers }; - delete next["x-target-provider"]; - delete next["x-target-providers"]; - delete next["x-gateway-target-provider"]; - return next; -} - -function shouldPreserveCapabilityModelSelector(body: Buffer | undefined, target: ProviderCredentialRoutingTarget): boolean { - if (target.source === "header" || target.protocol !== "gemini_interactions") { - return false; - } - return Boolean(parseProviderModelSelector(stringValue(parseJsonObjectSafe(body)?.model))); -} - -function resolvePlannedProviderCredentialRoutingTarget( - attempt: UpstreamAttempt, - path: string -): ProviderCredentialRoutingTarget | undefined { - if (attempt.target?.kind !== "provider") { - return undefined; - } - const clientProtocol = requestProtocolForPath(path); - const protocol = clientProtocol - ? providerProtocolForClientProtocol(attempt.target.provider, clientProtocol) - : undefined; - if (!protocol) { - return undefined; - } - const parsedBody = parseJsonObjectSafe(attempt.body); - return { - body: parsedBody && clientProtocol !== "gemini_generate_content" - ? serializeJsonBodyWithModel(parsedBody, attempt.target.model) - : attempt.body, - model: attempt.target.model, - provider: attempt.target.provider, - protocol, - source: "plan" - }; -} - -function targetProviderHeaderValue(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string { - const capability = normalizedProviderCapabilities(provider).find((item) => item.type === protocol); - return capability ? providerCapabilityInternalName(provider, capability.type) : provider.name || providerRuntimeId(provider); -} - -function usageAwareOpenAiChatAttemptBody(input: { - body: Buffer | undefined; - config: AppConfig; - path: string; - target?: { protocol: GatewayProviderProtocol }; -}): Buffer | undefined { - const clientProtocol = requestProtocolForPath(input.path); - const parsedBody = parseJsonObjectSafe(input.body); - const modelSelector = resolveConfiguredProviderModelSelector(stringValue(parsedBody?.model), input.config); - const providerProtocol = input.target?.protocol ?? ( - modelSelector && clientProtocol - ? providerProtocolForClientProtocol(modelSelector.provider, clientProtocol) - : undefined - ); - if (providerProtocol !== "openai_chat_completions" && providerProtocol !== "openai_responses") { - return input.body; - } - const sanitizedBody = stripUnsupportedOpenAiRequestParameters(input.body); - return providerProtocol === "openai_chat_completions" - ? usageAwareOpenAiChatBody(sanitizedBody) - : sanitizedBody; -} - -function stripUnsupportedOpenAiRequestParameters(body: Buffer | undefined): Buffer | undefined { - const parsedBody = parseJsonObjectSafe(body); - if (!parsedBody || (!("thinking" in parsedBody) && !("reasoning_split" in parsedBody))) { - return body; - } - const next = { ...parsedBody }; - delete next.thinking; - delete next.reasoning_split; - return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); -} - -function usageAwareOpenAiChatBody(body: Buffer | undefined): Buffer | undefined { - const parsedBody = parseJsonObjectSafe(body); - if (!parsedBody || parsedBody.stream !== true) { - return body; - } - const streamOptions = isRecord(parsedBody.stream_options) - ? parsedBody.stream_options - : isRecord(parsedBody.streamOptions) - ? parsedBody.streamOptions - : {}; - if (streamOptions.include_usage === true || streamOptions.includeUsage === true) { - return body; - } - return Buffer.from(`${JSON.stringify({ - ...parsedBody, - stream_options: { - ...streamOptions, - include_usage: true - } - })}\n`, "utf8"); -} - -function normalizeConfiguredProviderModelBody( - body: Buffer | undefined, - config: AppConfig -): { body: Buffer; model: string } | undefined { - const parsedBody = parseJsonObjectSafe(body); - const model = stringValue(parsedBody?.model); - const selector = resolveConfiguredProviderModelSelector(model, config); - if (!parsedBody || !selector || selector.model === model) { - return undefined; - } - return { - body: serializeJsonBodyWithModel(parsedBody, selector.model), - model: selector.model - }; -} - -function bodyHasConfiguredProviderModelSelector(body: Buffer | undefined, config: AppConfig): boolean { - const parsedBody = parseJsonObjectSafe(body); - const model = stringValue(parsedBody?.model); - return Boolean(resolveConfiguredProviderModelSelector(model, config)); -} - -function resolveProviderCredentialRoutingTarget( - config: AppConfig, - headers: Record, - path: string, - body: Buffer | undefined -): ProviderCredentialRoutingTarget | undefined { - const protocol = requestProtocolForPath(path); - if (!protocol) { - return undefined; - } - - const parsedBody = parseJsonObjectSafe(body); - const bodyModel = stringValue(parsedBody?.model); - const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config) ?? - resolveUniqueConfiguredProviderModelSelector(bodyModel, config); - if (modelSelector) { - const provider = modelSelector.provider; - const providerProtocol = provider ? providerProtocolForClientProtocol(provider, protocol) : undefined; - if (provider && providerProtocol) { - return { - body: parsedBody ? serializeJsonBodyWithModel(parsedBody, modelSelector.model) : body, - model: modelSelector.model, - provider, - protocol: providerProtocol, - source: "model" - }; - } - } - - const targetProviderName = firstTargetProviderHeader(headers); - if (!targetProviderName) { - return undefined; - } - - const provider = findProviderByPublicOrInternalName(config, targetProviderName); - if (!provider) { - return undefined; - } - const providerProtocol = providerProtocolForClientProtocol(provider, protocol); - if (!providerProtocol) { - return undefined; - } - const providerModel = resolveModelForProvider(bodyModel, provider); - - return { - body: parsedBody && providerModel && providerModel !== bodyModel - ? serializeJsonBodyWithModel(parsedBody, providerModel) - : body, - model: providerModel ?? bodyModel, - provider, - protocol: providerProtocol, - source: "header" - }; -} - -function resolveModelForProvider( - value: string | undefined, - provider: GatewayProviderConfig -): string | undefined { - const normalized = normalizeRouteSelector(value); - if (!normalized) { - return undefined; - } - if (providerHasModel(provider, normalized)) { - return normalized; - } - const parsed = parseProviderModelSelector(normalized); - return parsed && providerHasModel(provider, parsed.model) ? parsed.model : undefined; -} - -function providerHasModel(provider: GatewayProviderConfig, model: string): boolean { - const normalized = model.trim().toLowerCase(); - return Boolean(normalized) && provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized); -} - -function resolveConfiguredProviderModelSelector( - value: string | undefined, - config: AppConfig -): { model: string; provider: GatewayProviderConfig } | undefined { - return modelRegistryForConfig(config).resolveProviderModel(value); -} - -function resolveUniqueConfiguredProviderModelSelector( - value: string | undefined, - config: AppConfig -): { model: string; provider: GatewayProviderConfig } | undefined { - return modelRegistryForConfig(config).resolveUniqueProviderModel(value); -} - -function firstTargetProviderHeader(headers: Record): string | undefined { - const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; - if (provider?.trim()) { - return provider.trim(); - } - const providers = headers["x-target-providers"]; - return providers - ?.split(",") - .map((item) => item.trim()) - .find(Boolean); -} - -function activeProviderCredentials(provider: GatewayProviderConfig): ProviderCredentialConfig[] { - return (provider.credentials ?? []).filter((credential) => - credential.enabled !== false && - Boolean(providerCredentialApiKey(credential)) - ); -} - -function selectProviderCredentials( - provider: GatewayProviderConfig, - protocol: GatewayProviderProtocol, - credentials: ProviderCredentialConfig[], - usage: ApiKeyLimitUsage -): { credentials: Array<{ credential: ProviderCredentialConfig; credentialId: string; internalName: string }>; saturated: boolean } { - const candidates = credentials.map((credential, index) => { - const providerIndex = provider.credentials?.indexOf(credential) ?? index; - const limitState = providerCredentialLimitState(provider, credential, usage); - const cooldown = readProviderCredentialCooldown(provider, credential); - return { - cooldown, - credential, - credentialId: providerCredentialSlug(providerCredentialRuntimeId(provider, credential, providerIndex)), - index: providerIndex, - internalName: providerCredentialInternalName(provider, protocol, credential), - limitState, - priority: providerCredentialPriority(credential, providerIndex), - weight: Math.max(1, credential.weight ?? 1) - }; - }); - const available = candidates.filter((candidate) => !candidate.cooldown && !candidate.limitState.blocked); - const sorted = sortProviderCredentialCandidates(available.length > 0 ? available : candidates); - return { - credentials: sorted.map((candidate) => ({ - credential: candidate.credential, - credentialId: candidate.credentialId, - internalName: candidate.internalName - })), - saturated: available.length === 0 && candidates.length > 0 - }; -} - -function sortProviderCredentialCandidates(candidates: T[]): T[] { - const prioritySorted = [...candidates].sort((left, right) => - left.priority - right.priority || - left.limitState.utilization - right.limitState.utilization || - right.weight - left.weight || - left.index - right.index - ); - const primaryPriority = prioritySorted[0]?.priority; - const primaryCandidates = prioritySorted.filter((candidate) => candidate.priority === primaryPriority); - const shouldSpillOver = primaryCandidates.length > 0 && - primaryCandidates.every((candidate) => candidate.limitState.utilization >= providerCredentialSpilloverThreshold); - - if (shouldSpillOver) { - return prioritySorted.sort((left, right) => - left.limitState.utilization - right.limitState.utilization || - left.priority - right.priority || - right.weight - left.weight || - left.index - right.index - ); - } - - return prioritySorted; -} - -function providerCredentialPriority(credential: ProviderCredentialConfig, index: number): number { - return Number.isFinite(credential.priority) ? Number(credential.priority) : index + 1; -} - -function buildUpstreamAttempts( - config: AppConfig, - fallback: RouterFallbackConfig, - method: string, - path: string, - body: Buffer | undefined, - routedModel: string | undefined -): UpstreamAttempt[] { - const parsedBody = parseJsonObjectSafe(body); - const modelInPath = requestProtocolForPath(path) === "gemini_generate_content"; - const plan = createRouteExecutionPlan({ - bodyModel: modelInPath ? undefined : stringValue(parsedBody?.model), - fallback, - hasRequestBody: shouldSendBody(method) && (fallback.mode !== "model-chain" || Boolean(parsedBody)), - modelRegistry: modelRegistryForConfig(config), - primaryModel: routedModel - }); - return plan.attempts.map((attempt) => ({ - body: parsedBody && !modelInPath && fallback.mode === "model-chain" && attempt.model - ? serializeJsonBodyWithModel(parsedBody, attempt.model) - : body, - index: attempt.index, - model: attempt.model, - target: attempt.target - })); -} - -function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode): boolean { - return classifyRouteFailure(statusCode, mode).shouldFallback; -} - -function retryDelayAfterStatus(_statusCode: number, headers: Headers, failedAttemptIndex: number): number { - const retryAfterMs = parseRetryAfterHeaderMs(headers.get("retry-after")); - if (retryAfterMs !== undefined && retryAfterMs > 0) { - return clampNumber(retryAfterMs, 1, upstreamRetryAfterMaxMs); - } - return exponentialRetryBackoffMs(failedAttemptIndex); -} - -function retryDelayAfterNetworkError(failedAttemptIndex: number): number { - return exponentialRetryBackoffMs(failedAttemptIndex); -} - -export function fallbackRetryDelayAfterStatusForTest(input: { failedAttemptIndex?: number; retryAfter?: string | null; statusCode: number }): number { - const headers = new Headers(); - if (input.retryAfter !== undefined && input.retryAfter !== null) { - headers.set("retry-after", input.retryAfter); - } - return retryDelayAfterStatus(input.statusCode, headers, input.failedAttemptIndex ?? 0); -} - -export function fallbackRetryDelayAfterNetworkErrorForTest(failedAttemptIndex = 0): number { - return retryDelayAfterNetworkError(failedAttemptIndex); -} - -function parseRetryAfterHeaderMs(value: string | null): number | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - - const seconds = Number(trimmed); - if (Number.isFinite(seconds) && seconds >= 0) { - return seconds * 1000; - } - - const retryAt = Date.parse(trimmed); - return Number.isFinite(retryAt) ? Math.max(0, retryAt - Date.now()) : undefined; -} - -function exponentialRetryBackoffMs(failedAttemptIndex: number): number { - const exponent = Math.min(10, Math.max(0, failedAttemptIndex)); - return Math.min(upstreamRetryBackoffMaxMs, upstreamRetryBackoffBaseMs * 2 ** exponent); -} - -async function drainResponseBody(response: Response): Promise { - try { - await response.arrayBuffer(); - } catch { - // The failed attempt is already being skipped; body drain errors should not block the next attempt. - } -} - -async function cancelResponseBody(response: Response): Promise { - try { - await response.body?.cancel(); - } catch { - // The client already disconnected; best-effort upstream cleanup must not mask that expected path. - } -} - -function uniqueStreams(streams: Readable[]): Readable[] { - return [...new Set(streams)]; -} - -function destroyResponseStreams(streams: Readable[]): void { - for (const stream of streams) { - if (!stream.destroyed) { - // A downstream client close is an expected abort path. Destroying with - // an Error would emit another error event on Readable/Transform stages, - // and intermediate stages may not be the final responseBody listener. - stream.destroy(); - } - } -} - -function parseJsonObjectSafe(buffer: Buffer | undefined): Record | undefined { - if (!buffer || buffer.byteLength === 0) { - return undefined; - } - try { - return parseJsonObject(buffer); - } catch { - return undefined; - } -} - -function serializeJsonBodyWithModel(body: Record, model: string): Buffer { - return Buffer.from(`${JSON.stringify({ ...body, model })}\n`, "utf8"); -} - -function mergeFallbackResponseHeaders(headers: Headers, result: UpstreamFetchResult): Headers { - const credentialIds = result.attempt.credentialIds ?? []; - const credentialSaturated = result.attempt.headers?.["x-ccr-provider-credential-saturated"] === "true"; - if (result.failedAttempts.length === 0 && credentialIds.length === 0 && !credentialSaturated) { - return headers; - } - - const merged = new Headers(headers); - if (result.failedAttempts.length > 0) { - merged.set("x-ccr-fallback-attempts", String(result.failedAttempts.length + 1)); - merged.set("x-ccr-fallback-failures", formatFallbackFailures(result.failedAttempts)); - if (result.failedAttempts.some((attempt) => (attempt.delayMs ?? 0) > 0)) { - merged.set("x-ccr-fallback-delays-ms", formatFallbackDelays(result.failedAttempts)); - } - if (result.attempt.model) { - merged.set("x-ccr-fallback-model", sanitizeHeaderValue(result.attempt.model)); - } - } - if (credentialIds.length) { - merged.set("x-ccr-provider-credential-chain", credentialIds.join(",")); - } - if (credentialSaturated) { - merged.set("x-ccr-provider-credential-saturated", "true"); - } - return merged; -} - -function upstreamResponseHeaders(result: UpstreamFetchResult): Headers { - return result.response.headers; -} - -function formatFallbackFailures(failedAttempts: UpstreamFailedAttempt[]): string { - return failedAttempts - .map((attempt) => attempt.statusCode ? String(attempt.statusCode) : attempt.error ? "network" : "failed") - .join(","); -} - -function formatFallbackDelays(failedAttempts: UpstreamFailedAttempt[]): string { - return failedAttempts - .map((attempt) => String(Math.max(0, attempt.delayMs ?? 0))) - .join(","); -} - -function clampNumber(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, Math.trunc(Number.isFinite(value) ? value : min))); -} - -function uniqueStrings(values: Array): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - const item = value?.trim(); - if (!item || seen.has(item)) { - continue; - } - seen.add(item); - result.push(item); - } - return result; -} - -function spawnGatewayProcess(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): ChildProcess { - const gatewayEntry = resolveGatewayEntry(); - const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile(config, upstreamProxyUrl) : undefined; - const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken); - const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayEntry] : [gatewayEntry]; - return spawn(process.execPath, args, { - cwd: dirname(config.gateway.generatedConfigFile), - env, - stdio: ["ignore", "pipe", "pipe"] - }); -} - -function resolveGatewayEntry(): string { - const override = process.env[gatewayEntryOverrideEnv]?.trim(); - if (override) { - const entry = pathResolve(override); - if (!existsSync(entry)) { - throw new Error(`${gatewayEntryOverrideEnv} points to a missing gateway entry: ${entry}`); - } - return entry; - } - - const bundledEntry = resolveBundledGatewayEntry(); - if (bundledEntry) { - return bundledEntry; - } - - for (const packageName of gatewayPackageCandidates) { - try { - return requireFromHere.resolve(packageName); - } catch { - // Try the next known package name. - } - } - return requireFromHere.resolve(gatewayPackageCandidates[0]); -} - -function resolveBundledGatewayEntry(): string | undefined { - const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; - return [ - pathJoin(__dirname, "next-ai-gateway.js"), - ...(resourcesPath - ? [ - pathJoin(resourcesPath, "app.asar", "dist", "main", "next-ai-gateway.js"), - pathJoin(resourcesPath, "app", "dist", "main", "next-ai-gateway.js") - ] - : []) - ].find((candidate) => existsSync(candidate)); -} - -function resolveUndiciProxyAgentModule(): string { - const bundled = resolveBundledUndiciProxyAgentModule(); - if (bundled) { - return bundled; - } - - try { - return requireFromHere.resolve("undici"); - } catch (error) { - throw new Error(`Unable to resolve undici ProxyAgent module for gateway proxy preload: ${formatError(error)}`); - } -} - -function resolveBundledUndiciProxyAgentModule(): string | undefined { - const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; - return [ - pathJoin(__dirname, "undici-proxy-agent.js"), - ...(resourcesPath - ? [ - pathJoin(resourcesPath, "app.asar", "dist", "main", "undici-proxy-agent.js"), - pathJoin(resourcesPath, "app", "dist", "main", "undici-proxy-agent.js") - ] - : []) - ].find((candidate) => existsSync(candidate)); -} - -function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { - ...process.env, - AUTH_ENABLED: "true", - AUTH_MODE: "static_api_key", - AUTH_REQUIRED: "true", - AUTH_STATIC_API_KEY_BEARER_ONLY: "false", - AUTH_STATIC_API_KEY_ENV: coreGatewayAuthTokenEnv, - AUTH_STATIC_API_KEY_HEADER: coreGatewayAuthHeader, - CCR_GATEWAY_RUNTIME_ID: runtimeId, - [coreGatewayAuthTokenEnv]: coreAuthToken, - ELECTRON_RUN_AS_NODE: "1", - GATEWAY_CONFIG_PATH: config.gateway.generatedConfigFile, - HOST: config.gateway.coreHost, - 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; - } - - env.HTTP_PROXY = upstreamProxyUrl; - env.HTTPS_PROXY = upstreamProxyUrl; - env.ALL_PROXY = upstreamProxyUrl; - env.http_proxy = upstreamProxyUrl; - env.https_proxy = upstreamProxyUrl; - env.all_proxy = upstreamProxyUrl; - env.CCR_UPSTREAM_PROXY_URL = upstreamProxyUrl; - env.CCR_UNDICI_MODULE = resolveUndiciProxyAgentModule(); - return env; -} - -function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: string): string { - const file = pathJoin(dirname(config.gateway.generatedConfigFile), "gateway-proxy-preload.cjs"); - writeFileSync( - file, - [ - "\"use strict\";", - "const up = process.env.CCR_UPSTREAM_PROXY_URL;", - "const um = process.env.CCR_UNDICI_MODULE;", - "if (up && um) {", - " const { ProxyAgent } = require(um);", - " const agent = new ProxyAgent(up);", - " const realFetch = globalThis.fetch.bind(globalThis);", - " const raw = (process.env.NO_PROXY || process.env.no_proxy || '').toLowerCase();", - " const byp = raw.split(',').map((s) => s.trim()).filter(Boolean);", - " const norm = (h) => h.replace(/^\\[/, '').replace(/\\]$/, '').replace(/\\.$/, '');", - " const isLP = (h) => h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0.0.0.0' || h.startsWith('127.');", - " const shouldBypass = (input) => {", - " let h;", - " try {", - " const u = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input && input.url ? input.url : String(input));", - " h = norm(u.hostname);", - " } catch { return true; }", - " if (!h) return false;", - " if (isLP(h)) return true;", - " return byp.some((p) => {", - " if (p === '*') return true;", - " const s = p.split(':');", - " const ph = norm(s[0]);", - " if (s.length === 2 && s[1]) {", - " if (h !== ph) return false;", - " try { return new URL(input).port === s[1]; } catch { return false; }", - " }", - " if (ph.startsWith('*.')) return h.endsWith(ph.slice(1));", - " if (ph.startsWith('.')) return h.endsWith(ph) || h === ph.slice(1);", - " return h === ph;", - " });", - " };", - " const patched = function(input, init) {", - " if (init && init.dispatcher) return realFetch(input, init);", - " if (shouldBypass(input)) return realFetch(input, init);", - " return realFetch(input, Object.assign({}, init, { dispatcher: agent }));", - " };", - " if (Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.writable) {", - " globalThis.fetch = patched;", - " }", - "}" - ].join("\n"), - "utf8" - ); - return file; -} - -function mergeNoProxy(current: string | undefined, values: string[]): string { - const merged = new Set(); - for (const value of [...(current || "").split(","), ...values]) { - const trimmed = value.trim(); - if (trimmed) { - merged.add(trimmed); - } - } - return [...merged].join(","); -} - -function toCoreGatewayProviders(provider: GatewayProviderConfig): CoreGatewayProvider[] { - const capabilities = normalizedProviderCapabilities(provider); - if (capabilities.length === 0) { - return toCoreGatewayProvidersForCapability(provider); - } - - return capabilities - .flatMap((capability) => toCoreGatewayProvidersForCapability(provider, capability)) - .filter((item): item is CoreGatewayProvider => Boolean(item)); -} - -function toCoreGatewayProvidersForCapability( - provider: GatewayProviderConfig, - capability?: GatewayProviderCapability -): CoreGatewayProvider[] { - const credentials = activeProviderCredentials(provider); - if (credentials.length === 0) { - const coreProvider = toCoreGatewayProvider(provider, capability); - return coreProvider ? [coreProvider] : []; - } - - return sortProviderCredentialsForConfig(credentials) - .map((credential) => toCoreGatewayProvider(provider, capability, credential)) - .filter((item): item is CoreGatewayProvider => Boolean(item)); -} - -function toCoreGatewayProvider( - provider: GatewayProviderConfig, - capability?: GatewayProviderCapability, - credential?: ProviderCredentialConfig -): CoreGatewayProvider | undefined { - const type = - capability?.type ?? - normalizeProviderProtocol(provider.type) ?? - normalizeProviderProtocol(provider.provider) ?? - inferProtocol(provider); - const baseurl = normalizeProviderRuntimeBaseUrl(capability?.baseUrl ?? readBaseUrl(provider), type); - const apikey = credential ? providerCredentialApiKey(credential) : provider.apikey || provider.apiKey || provider.api_key; - - if (!provider.name || provider.models.length === 0) { - return undefined; - } - const safetyIssue = providerApiKeySafetyIssue({ - apiKey: apikey, - baseUrl: baseurl ?? "", - name: provider.name - }); - if (safetyIssue) { - throw new Error(safetyIssue.message); - } - - return { - apikey, - baseurl, - billing: provider.billing, - extraBody: provider.extraBody, - extraHeaders: provider.extraHeaders, - models: provider.models, - name: credential - ? providerCredentialInternalName(provider, type, credential) - : capability - ? providerCapabilityInternalName(provider, type) - : providerRuntimeId(provider), - type - }; -} - -function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[]): ProviderCredentialConfig[] { - return [...credentials].sort((left, right) => - providerCredentialPriority(left, 0) - providerCredentialPriority(right, 0) || - providerCredentialSortKey(left).localeCompare(providerCredentialSortKey(right)) - ); -} - -function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] { - const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : []; - const normalized: GatewayProviderCapability[] = []; - const byProtocol = new Map(); - for (const capability of capabilities) { - const type = normalizeProviderProtocol(capability.type); - const baseUrl = capability.baseUrl?.trim(); - if (!type || !baseUrl) { - continue; - } - const item = { - ...capability, - baseUrl, - type - }; - const existing = byProtocol.get(type); - if (!existing || providerCapabilityPriority(item) < providerCapabilityPriority(existing)) { - byProtocol.set(type, item); - } - } - for (const capability of capabilities) { - const type = normalizeProviderProtocol(capability.type); - const selected = type ? byProtocol.get(type) : undefined; - if (selected && !normalized.includes(selected)) { - normalized.push(selected); - } - } - return applyPresetProtocolLock(provider, normalized); -} - -function applyPresetProtocolLock( - provider: GatewayProviderConfig, - capabilities: GatewayProviderCapability[] -): GatewayProviderCapability[] { - const lockedProtocols = lockedProviderPresetProtocols(provider, capabilities); - if (lockedProtocols.length === 0) { - return capabilities; - } - - const lockedProtocolSet = new Set(lockedProtocols); - const lockedCapabilities = capabilities.filter((capability) => lockedProtocolSet.has(capability.type)); - if (lockedCapabilities.length > 0) { - return lockedCapabilities; - } - - const lockedProtocol = lockedProtocols[0]; - const baseUrl = readBaseUrl(provider); - const normalizedBaseUrl = normalizeProviderRuntimeBaseUrl(baseUrl, lockedProtocol); - return normalizedBaseUrl - ? [{ baseUrl: normalizedBaseUrl, source: "preset", type: lockedProtocol }] - : []; -} - -function lockedProviderPresetProtocols( - provider: GatewayProviderConfig, - capabilities: GatewayProviderCapability[] -): GatewayProviderProtocol[] { - const baseUrls = [ - readBaseUrl(provider), - ...capabilities.map((capability) => capability.baseUrl) - ].filter((value): value is string => Boolean(value?.trim())); - - for (const baseUrl of baseUrls) { - if (findProviderPresetByBaseUrl(baseUrl)?.id === "gemini") { - return ["gemini_generate_content", "gemini_interactions"]; - } - } - - return []; -} - -function providerCapabilityPriority(capability: GatewayProviderCapability): number { - if (capability.source === "preset") { - return 0; - } - if (capability.source === "detected") { - return 2; - } - return 1; -} - -function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string { - return `${providerRuntimeId(provider)}::${protocol}`; -} - -function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderProtocol): string { - return `${providerName}::${protocol}`; -} - -function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol, value: string): boolean { - const normalized = value.trim().toLowerCase(); - return providerCapabilityInternalName(provider, protocol).toLowerCase() === normalized || - providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized; -} - -function sanitizeHeaderValue(value: unknown): string { - // HTTP header values must be ByteString (code point <= 255). Values derived - // from user-facing names — model selectors like "小米mimo/...", provider - // names, route reasons — can contain non-ASCII characters that crash Node's - // fetch/undici with "Cannot convert argument to a ByteString" (surfaced as - // 502). Normalize to ASCII while preserving case and printable punctuation. - const text = typeof value === "string" && value.trim() ? value : "unknown"; - const sanitized = text - .replace(/[^\x20-\x7E]+/g, "-") - .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, ""); - return sanitized || "unknown"; -} - -function providerCredentialInternalName( - provider: GatewayProviderConfig, - protocol: GatewayProviderProtocol, - credential: ProviderCredentialConfig -): string { - return `${providerCapabilityInternalName(provider, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`; -} - -function parseProviderCredentialInternalName(value: string | undefined): { - credentialSlug: string; - providerId: string; - protocol: GatewayProviderProtocol; -} | undefined { - const marker = "::cred:"; - const markerIndex = value?.lastIndexOf(marker) ?? -1; - if (!value || markerIndex <= 0) { - return undefined; - } - const baseName = value.slice(0, markerIndex); - const credentialSlug = value.slice(markerIndex + marker.length).trim(); - const protocolSeparator = baseName.lastIndexOf("::"); - if (!credentialSlug || protocolSeparator <= 0) { - return undefined; - } - const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2)); - const providerId = baseName.slice(0, protocolSeparator).trim(); - return protocol && providerId ? { credentialSlug, providerId, protocol } : undefined; -} - -function providerCredentialSlug(value: string | undefined): string { - return (value ?? "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9_.-]+/g, "-") - .replace(/^-+|-+$/g, "") || "key"; -} - -function providerCredentialRuntimeId( - provider: GatewayProviderConfig, - credential: ProviderCredentialConfig, - index = provider.credentials?.indexOf(credential) ?? -1 -): string { - const explicitId = credential.id?.trim(); - if (explicitId) { - return explicitId; - } - const oneBasedIndex = index >= 0 ? index + 1 : 1; - const label = credential.name?.trim() || credential.label?.trim(); - return label ? `${providerCredentialSlug(label)}-${oneBasedIndex}` : `key-${oneBasedIndex}`; -} - -function providerCredentialSortKey(credential: ProviderCredentialConfig): string { - return providerCredentialSlug(credential.id || credential.name || credential.label); -} - -function providerCredentialApiKey(credential: ProviderCredentialConfig): string { - return credential.api_key || credential.apiKey || credential.apikey || ""; -} - -function findProviderCredentialByRuntimeId( - provider: GatewayProviderConfig, - credentialId: string -): ProviderCredentialConfig | undefined { - const normalizedId = credentialId.trim(); - const normalizedSlug = providerCredentialSlug(normalizedId); - return (provider.credentials ?? []).find((credential, index) => { - const runtimeId = providerCredentialRuntimeId(provider, credential, index); - return runtimeId === normalizedId || providerCredentialSlug(runtimeId) === normalizedSlug || credential.id?.trim() === normalizedId; - }); -} - -function findProviderCredentialBySlug( - provider: GatewayProviderConfig, - credentialSlug: string -): ProviderCredentialConfig | undefined { - const normalizedSlug = providerCredentialSlug(credentialSlug); - return (provider.credentials ?? []).find((credential, index) => providerCredentialSlug(providerCredentialRuntimeId(provider, credential, index)) === normalizedSlug); -} - -function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (normalized === "openai" || normalized === "openai_responses") { - return "openai_responses"; - } - if (normalized === "openai_chat" || normalized === "openai_chat_completions") { - return "openai_chat_completions"; - } - if (normalized === "anthropic" || normalized === "anthropic_messages") { - return "anthropic_messages"; - } - if (normalized === "gemini" || normalized === "gemini_generate_content") { - return "gemini_generate_content"; - } - if ( - normalized === "gemini_interactions" || - normalized === "gemini-interactions" || - normalized === "google_interactions" || - normalized === "google-interactions" || - normalized === "interactions" || - normalized === "interaction" - ) { - return "gemini_interactions"; - } - return undefined; -} - -function inferProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol { - const url = readBaseUrl(provider)?.toLowerCase() ?? ""; - const transformerNames = JSON.stringify(provider.transformer ?? "").toLowerCase(); - if (url.includes("/interactions") || transformerNames.includes("gemini_interactions")) { - return "gemini_interactions"; - } - if (url.includes("generativelanguage.googleapis.com") || transformerNames.includes("gemini")) { - return "gemini_generate_content"; - } - if (url.includes("anthropic") || transformerNames.includes("anthropic")) { - return "anthropic_messages"; - } - return "openai_chat_completions"; -} - -function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | undefined): GatewayProviderProtocol | undefined { - const ccrProtocol = normalizeProviderProtocol(headers.get("x-ccr-provider-protocol")); - if (ccrProtocol) { - return ccrProtocol; - } - const providerName = - headers.get("x-gateway-target-provider-name")?.trim() || - headers.get("x-gateway-target-provider")?.trim(); - if (!providerName) { - return undefined; - } - const credentialInternalName = parseProviderCredentialInternalName(providerName); - if (credentialInternalName) { - return credentialInternalName.protocol; - } - const provider = config ? findProviderByPublicOrInternalName(config, providerName) : undefined; - if (!provider) { - return normalizeProviderProtocol(providerName); - } - const capability = normalizedProviderCapabilities(provider).find((item) => - providerCapabilityNameMatches(provider, item.type, providerName) - ); - if (capability) { - return capability.type; - } - return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider); -} - -function resolveProviderLogName(headers: Headers, config: AppConfig | undefined, fallbackModel?: string): string | undefined { - const providerSelector = - headers.get("x-gateway-target-provider-name")?.trim() || - headers.get("x-gateway-target-provider")?.trim(); - const headerProvider = providerSelector && config - ? findProviderByPublicOrInternalName(config, providerSelector) - : undefined; - if (headerProvider) { - return headerProvider.name; - } - - const routeProvider = parseProviderModelSelector(fallbackModel)?.provider; - const modelProvider = routeProvider && config - ? findProviderByPublicOrInternalName(config, routeProvider) - : undefined; - return modelProvider?.name; -} - -function providerMatchesName(provider: GatewayProviderConfig, name: string): boolean { - const normalizedName = name.trim().toLowerCase(); - return [provider.id, provider.name, provider.provider] - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) - .some((value) => value.trim().toLowerCase() === normalizedName); -} - -function normalizeProviderRuntimeBaseUrl(value: string | undefined, type: GatewayProviderProtocol): string | undefined { - if (!value) { - return undefined; - } - return normalizeProviderBaseUrlInput(value, type) || undefined; -} - -function readBaseUrl(provider: GatewayProviderConfig): string | undefined { - return provider.baseurl || provider.baseUrl || provider.api_base_url; -} - -function endpoint(host: string, port: number): string { - const endpointHost = host === "0.0.0.0" ? "127.0.0.1" : host; - return `http://${endpointHost}:${port}`; -} - -function gatewayNetworkEndpoints(host: string, port: number): GatewayNetworkEndpoint[] { - const normalizedHost = normalizeBindHost(host); - const lanAddresses = physicalLanAddresses(); - const addresses = isWildcardBindHost(normalizedHost) - ? lanAddresses - : lanAddresses.filter((entry) => entry.address === normalizedHost); - - return addresses.map((entry) => ({ - address: entry.address, - endpoint: endpoint(entry.address, port), - interfaceName: entry.interfaceName - })); -} - -function physicalLanAddresses(): Array<{ address: string; interfaceName: string }> { - const seen = new Set(); - const result: Array<{ address: string; interfaceName: string }> = []; - - for (const [interfaceName, entries] of Object.entries(networkInterfaces())) { - if (!entries || isVirtualNetworkInterface(interfaceName)) { - continue; - } - - for (const entry of entries) { - if (entry.internal || entry.family !== "IPv4" || !isPrivateIpv4(entry.address)) { - continue; - } - - const key = `${interfaceName}:${entry.address}`; - if (seen.has(key)) { - continue; - } - - seen.add(key); - result.push({ address: entry.address, interfaceName }); - } - } - - return result.sort((left, right) => - left.interfaceName.localeCompare(right.interfaceName) || - left.address.localeCompare(right.address, undefined, { numeric: true }) - ); -} - -function normalizeBindHost(host: string): string { - return host.trim().replace(/^\[|\]$/g, "").toLowerCase(); -} - -function isLoopbackBindHost(host: string): boolean { - const normalized = normalizeBindHost(host).replace(/\.$/, ""); - return normalized === "localhost" || - normalized === "127.0.0.1" || - normalized === "::1" || - normalized === "0:0:0:0:0:0:0:1" || - /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized); -} - -function isWildcardBindHost(host: string): boolean { - return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; -} - -function isPrivateIpv4(address: string): boolean { - const parts = address.split(".").map((part) => Number(part)); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { - return false; - } - - return parts[0] === 10 || - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || - (parts[0] === 192 && parts[1] === 168); -} - -function isVirtualNetworkInterface(interfaceName: string): boolean { - const normalized = interfaceName.toLowerCase(); - return [ - /^lo\d*$/, - /^awdl\d*$/, - /^llw\d*$/, - /^utun\d*$/, - /^gif\d*$/, - /^stf\d*$/, - /^bridge\d*$/, - /^br-/, - /^docker/, - /^veth/, - /^vmnet/, - /^vbox/, - /^tun\d*$/, - /^tap\d*$/, - /^wg\d*$/, - /\bloopback\b/, - /\bvirtual\b/, - /\bvirtualbox\b/, - /\bvmware\b/, - /\bhyper-v\b/, - /\bvethernet\b/, - /\bwsl\b/, - /\btunnel\b/, - /\btailscale\b/, - /\bzerotier\b/, - /\bwireguard\b/, - /\bhamachi\b/, - /\bparallels\b/, - /\bvpn\b/ - ].some((pattern) => pattern.test(normalized)); -} - -async function stopPreviousManagedCoreGateway(config: AppConfig, coreEndpoint: string): Promise { - const marker = readManagedCoreGatewayMarker(config); - const markerRuntimeId = stringValue(marker?.runtimeId); - const pid = numberValue(marker?.pid); - if (!markerRuntimeId || !pid) { - return; - } - - const health = await readCoreGatewayHealth(coreEndpoint); - if (health?.runtimeId !== markerRuntimeId) { - return; - } - - if (!isProcessAlive(pid)) { - removeManagedCoreGatewayMarker(config); - return; - } - - try { - process.kill(pid, "SIGTERM"); - } catch { - removeManagedCoreGatewayMarker(config); - return; - } - - if (await waitForCoreGatewayStop(coreEndpoint)) { - removeManagedCoreGatewayMarker(config); - return; - } - - try { - process.kill(pid, "SIGKILL"); - } catch { - // Process may have exited between the health check and SIGKILL. - } - await waitForCoreGatewayStop(coreEndpoint); - removeManagedCoreGatewayMarker(config); -} - -function readManagedCoreGatewayMarker(config: AppConfig): ManagedGatewayRuntimeMarker | undefined { - const file = managedCoreGatewayMarkerPath(config); - if (!existsSync(file)) { - return undefined; - } - try { - const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - -function writeManagedCoreGatewayMarker(config: AppConfig, child: ChildProcess, runtimeId: string): void { - if (!child.pid) { - return; - } - try { - writeFileSync( - managedCoreGatewayMarkerPath(config), - `${JSON.stringify( - { - generatedConfigFile: config.gateway.generatedConfigFile, - gatewayEntry: resolveGatewayEntry(), - pid: child.pid, - runtimeId, - startedAt: new Date().toISOString() - }, - null, - 2 - )}\n`, - "utf8" - ); - } catch (error) { - console.warn(`[gateway] Failed to write gateway runtime marker: ${formatError(error)}`); - } -} - -function removeManagedCoreGatewayMarker(config: AppConfig | undefined): void { - if (!config) { - return; - } - try { - rmSync(managedCoreGatewayMarkerPath(config), { force: true }); - } catch (error) { - console.warn(`[gateway] Failed to remove gateway runtime marker: ${formatError(error)}`); - } -} - -function managedCoreGatewayMarkerPath(config: AppConfig): string { - return pathJoin(dirname(config.gateway.generatedConfigFile), gatewayRuntimeMarkerFile); -} - -async function waitForCoreGatewayStop(coreEndpoint: string): Promise { - for (let index = 0; index < 20; index += 1) { - if (!(await isCoreGatewayHealthy(coreEndpoint))) { - return true; - } - await delay(100); - } - return false; -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function assertLoopbackCoreHost(host: string): void { - const error = loopbackCoreHostError(host); - if (error) { - throw new Error(error); - } -} - -function loopbackCoreHostError(host: string): string | undefined { - const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "::1" - ? undefined - : "Core gateway host must be 127.0.0.1 or ::1."; -} - -function generateCoreGatewayAuthToken(): string { - return randomBytes(32).toString("base64url"); -} - -async function isCoreGatewayHealthy(coreEndpoint: string): Promise { - const health = await readCoreGatewayHealth(coreEndpoint); - return health?.status === "ok"; -} - -async function readCoreGatewayHealth(coreEndpoint: string): Promise { - if (!coreEndpoint) { - return undefined; - } - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 500); - try { - const healthUrl = new URL("/health", coreEndpoint); - const response = await fetchWithSystemProxy(healthUrl, { signal: controller.signal }); - if (!response.ok) { - return undefined; - } - const body = await response.json().catch(() => undefined); - if (!isRecord(body)) { - return undefined; - } - return { - runtimeId: stringValue(body.runtimeId), - status: stringValue(body.status) - }; - } catch { - return undefined; - } finally { - clearTimeout(timer); - } -} - -function shouldRunUnifiedServer(config: AppConfig): boolean { - return config.gateway.enabled || config.proxy.enabled; -} - -function shouldRunGatewayRuntime(config: AppConfig): boolean { - return config.gateway.enabled || (config.proxy.enabled && config.proxy.mode === "gateway"); -} - -function shouldServeGatewayRequest(config: AppConfig, request: IncomingMessage): boolean { - if (config.gateway.enabled) { - return true; - } - return config.proxy.enabled && config.proxy.mode === "gateway" && readHeader(request.headers["x-ccr-proxy-mode"]) === "gateway"; -} - -function applyCors(response: ServerResponse, config?: AppConfig): void { - const origin = config ? endpoint(config.gateway.host, config.gateway.port) : "*"; - response.setHeader("Access-Control-Allow-Origin", origin); - response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, Last-Event-ID, Anthropic-Version, Anthropic-Beta, Mcp-Session-Id, MCP-Protocol-Version"); - response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); - response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); -} - -async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise { - let apiKeys = await configuredApiKeys(config); - if (apiKeys.length === 0) { - sendJson(response, 403, { - error: { - message: "CCR API key is not initialized. Save a gateway API key or restart CCR to generate one." - } - }); - return { ok: false }; - } - - const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request); - let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; - if (!apiKey && token) { - apiKeys = await configuredApiKeys(config, { refresh: true }); - apiKey = apiKeys.find((item) => item.key === token); - } - if (apiKey) { - if (isApiKeyExpired(apiKey)) { - sendJson(response, 401, { error: { message: "API key is expired." } }); - return { ok: false }; - } - return { ok: true, apiKey }; - } - - sendJson(response, 401, { error: { message: token ? "Invalid API key." : "API key is missing." } }); - return { ok: false }; -} - -async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise { - const persistedApiKeys = await loadPersistedApiKeysCached(options); - const values = [ - ...persistedApiKeys, - ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []), - ...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : []) - ]; - const seen = new Set(); - const result: ApiKeyConfig[] = []; - for (const value of values) { - const key = value?.key?.trim(); - if (!key || seen.has(key)) { - continue; - } - seen.add(key); - result.push({ ...value, key }); - } - return result; -} - -async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise { - const now = Date.now(); - if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) { - return persistedApiKeyCache.values; - } - try { - const values = await loadPersistedApiKeys(); - persistedApiKeyCache = { - loadedAt: now, - values - }; - return values; - } catch (error) { - console.warn(`[gateway] Failed to load persisted API keys: ${formatError(error)}`); - return []; - } -} - -function isApiKeyExpired(apiKey: ApiKeyConfig): boolean { - if (!apiKey.expiresAt) { - return false; - } - const expiresAt = Date.parse(apiKey.expiresAt); - return Number.isFinite(expiresAt) && expiresAt <= Date.now(); -} - -function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: IncomingMessage, response: ServerResponse, requestBody: Buffer): boolean { - if (!apiKey?.limits) { - return true; - } - - const usage = estimateApiKeyLimitUsage(request, requestBody); - const rules = apiKeyLimitRules(apiKey, usage); - const now = Date.now(); - const checks = rules.map((rule) => { - const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; - return { - counterKey: ["api-key", apiKey.id, rule.name, rule.metric, rule.windowMs, windowStart].join("|"), - rule, - windowStart - }; - }); - - for (const check of checks) { - const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now); - if (counter.value + check.rule.requested > check.rule.limit) { - sendJson(response, 429, { - error: { - code: "rate_limit_exceeded", - message: `API key ${check.rule.name} limit exceeded.`, - details: { - limit: check.rule.limit, - limit_name: check.rule.name, - metric: check.rule.metric, - requested: check.rule.requested, - used: counter.value, - window_ms: check.rule.windowMs - } - } - }); - return false; - } - } - - for (const check of checks) { - readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now).value += check.rule.requested; - } - return true; -} - -function apiKeyLimitRules(apiKey: ApiKeyConfig, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { - return limitRules(apiKey.limits, usage); -} - -function limitRules(limits: ApiKeyLimitConfig | undefined, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { - if (!limits) { - return []; - } - const rules: ApiKeyLimitRule[] = []; - addApiKeyLimitRule(rules, "requests", "requests", limits.windowMs ?? 60_000, limits.maxRequests, 1); - addApiKeyLimitRule(rules, "rpm", "requests", 60_000, limits.rpm, 1); - addApiKeyLimitRule(rules, "rph", "requests", 3_600_000, limits.rph, 1); - addApiKeyLimitRule(rules, "rpd", "requests", 86_400_000, limits.rpd, 1); - addApiKeyLimitRule(rules, "tpm", "tokens", 60_000, limits.tpm, usage.totalTokens); - addApiKeyLimitRule(rules, "tph", "tokens", 3_600_000, limits.tph, usage.totalTokens); - addApiKeyLimitRule(rules, "tpd", "tokens", 86_400_000, limits.tpd, usage.totalTokens); - addApiKeyLimitRule(rules, "ipm", "images", 60_000, limits.ipm, usage.imageCount); - addApiKeyLimitRule(rules, "iph", "images", 3_600_000, limits.iph, usage.imageCount); - addApiKeyLimitRule(rules, "ipd", "images", 86_400_000, limits.ipd, usage.imageCount); - addApiKeyLimitRule(rules, "quota", "tokens", limits.quotaWindowMs ?? 86_400_000, limits.maxTokens, usage.totalTokens); - return rules; -} - -function providerCredentialLimitState( - provider: GatewayProviderConfig, - credential: ProviderCredentialConfig, - usage: ApiKeyLimitUsage -): { blocked: boolean; utilization: number } { - const rules = limitRules(credential.limits, usage); - if (rules.length === 0) { - return { - blocked: false, - utilization: 0 - }; - } - - const now = Date.now(); - let blocked = false; - let utilization = 0; - for (const rule of rules) { - const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; - const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now); - blocked = blocked || counter.value + rule.requested > rule.limit; - utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit); - } - - return { - blocked, - utilization - }; -} - -function recordProviderCredentialOutcome( - config: AppConfig, - method: string, - attempt: UpstreamAttempt, - statusCode: number, - responseHeaders: Headers -): void { - if (!attempt.logicalProvider || !attempt.credentialProtocol || !attempt.credentialChain?.length) { - return; - } - - const provider = findProviderByPublicOrInternalName(config, attempt.logicalProvider); - if (!provider) { - return; - } - - const responseCredentialId = responseHeaders.get("x-ccr-provider-credential-id")?.trim(); - const responseCredential = responseCredentialId - ? findProviderCredentialByRuntimeId(provider, responseCredentialId) - : undefined; - const fallbackCredential = providerCredentialFromInternalName(provider, attempt.credentialChain[0]); - const credential = responseCredential ?? fallbackCredential; - if (!credential) { - return; - } - - if (statusCode >= 200 && statusCode < 500 && statusCode !== 401 && statusCode !== 403 && statusCode !== 429) { - incrementProviderCredentialCounters(provider, credential, estimateLimitUsage(method, attempt.body ?? Buffer.alloc(0))); - clearProviderCredentialCooldown(provider, credential); - return; - } - - if (statusCode === 401 || statusCode === 403 || statusCode === 429 || statusCode >= 500) { - setProviderCredentialCooldown(provider, credential, providerCredentialCooldownMs, `HTTP ${statusCode}`); - } -} - -function providerCredentialFromInternalName( - provider: GatewayProviderConfig, - internalName: string | undefined -): ProviderCredentialConfig | undefined { - const parsed = parseProviderCredentialInternalName(internalName); - return parsed ? findProviderCredentialBySlug(provider, parsed.credentialSlug) : undefined; -} - -function incrementProviderCredentialCounters( - provider: GatewayProviderConfig, - credential: ProviderCredentialConfig, - usage: ApiKeyLimitUsage -): void { - const rules = limitRules(credential.limits, usage); - const now = Date.now(); - for (const rule of rules) { - const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; - readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now).value += rule.requested; - } -} - -function providerCredentialCounterKey( - provider: GatewayProviderConfig, - credential: ProviderCredentialConfig, - rule: ApiKeyLimitRule, - windowStart: number -): string { - return ["provider-credential", provider.name, providerCredentialRuntimeId(provider, credential), rule.name, rule.metric, rule.windowMs, windowStart].join("|"); -} - -function readProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): { reason: string; until: number } | undefined { - const key = providerCredentialStateKey(provider, credential); - const cooldown = providerCredentialCooldowns.get(key); - if (!cooldown) { - return undefined; - } - if (cooldown.until > Date.now()) { - return cooldown; - } - providerCredentialCooldowns.delete(key); - return undefined; -} - -function setProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig, cooldownMs: number, reason: string): void { - providerCredentialCooldowns.set(providerCredentialStateKey(provider, credential), { - reason, - until: Date.now() + cooldownMs - }); -} - -function clearProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): void { - providerCredentialCooldowns.delete(providerCredentialStateKey(provider, credential)); -} - -function providerCredentialStateKey(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): string { - return `${provider.name}::${providerCredentialRuntimeId(provider, credential)}`; -} - -function addApiKeyLimitRule( - rules: ApiKeyLimitRule[], - name: string, - metric: ApiKeyLimitRule["metric"], - windowMs: number, - limit: number | undefined, - requested: number -): void { - if (!limit || limit <= 0 || windowMs <= 0) { - return; - } - rules.push({ - limit, - metric, - name, - requested, - windowMs - }); -} - -function readApiKeyWindowCounter(key: string, windowStart: number, windowMs: number, now = Date.now()): ApiKeyWindowCounter { - pruneExpiredApiKeyLimitCounters(now); - const existing = apiKeyLimitCounters.get(key); - if (existing && existing.windowStart === windowStart) { - return existing; - } - const fresh = { - expiresAt: windowStart + windowMs * apiKeyLimitCounterRetentionWindows, - value: 0, - windowStart - }; - apiKeyLimitCounters.set(key, fresh); - return fresh; -} - -function pruneExpiredApiKeyLimitCounters(now: number): void { - for (const [key, counter] of apiKeyLimitCounters) { - if (counter.expiresAt <= now) { - apiKeyLimitCounters.delete(key); - } - } -} - -function estimateApiKeyLimitUsage(request: IncomingMessage, requestBody: Buffer): ApiKeyLimitUsage { - return estimateLimitUsage(request.method ?? "GET", requestBody); -} - -function estimateLimitUsage(method: string, requestBody: Buffer): ApiKeyLimitUsage { - if (method.toUpperCase() !== "POST" || requestBody.byteLength === 0) { - return { - imageCount: 0, - totalTokens: 0 - }; - } - - const body = parseJsonObject(requestBody); - const inputCharacters = countUnknownCharacters(body.messages) + countUnknownCharacters(body.system) + countUnknownCharacters(body.tools); - const inputTokens = Math.ceil(inputCharacters / 4); - const outputTokens = readPositiveNumber(body.max_tokens) ?? readPositiveNumber(body.max_output_tokens) ?? 1024; - return { - imageCount: countImageInputs(body), - totalTokens: Math.max(1, inputTokens + outputTokens) - }; -} - -function countUnknownCharacters(value: unknown): number { - if (value === undefined || value === null) { - return 0; - } - if (typeof value === "string") { - return value.length; - } - try { - return JSON.stringify(value)?.length || 0; - } catch { - return String(value).length; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function rawStringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function stringListValue(value: unknown): string[] { - return Array.isArray(value) ? value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) : []; -} - -function numberValue(value: unknown): number | undefined { - const number = Number(value); - return Number.isFinite(number) ? Math.trunc(number) : undefined; -} - -function countImageInputs(value: unknown): number { - if (Array.isArray(value)) { - return value.reduce((sum, item) => sum + countImageInputs(item), 0); - } - if (!isRecord(value)) { - return 0; - } - const type = typeof value.type === "string" ? value.type.toLowerCase() : ""; - const isImage = type === "image" || type === "image_url" || type === "input_image" || value.image_url !== undefined || value.input_image !== undefined; - return (isImage ? 1 : 0) + Object.values(value).reduce((sum, item) => sum + countImageInputs(item), 0); -} - -function readPositiveNumber(value: unknown): number | undefined { - const number = Number(value); - return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined; -} - -function shouldServeGatewayModelsResponse(method: string, path: string): boolean { - return (method || "GET").toUpperCase() === "GET" && - normalizeGatewayPathname(path) === "/v1/models"; -} - -function prepareClaudeCodeDiscoveredModelRequest( - config: AppConfig, - headers: IncomingHttpHeaders, - method: string, - path: string, - body: Buffer | undefined -): { body: Buffer; diagnostic: string } | undefined { - if ( - (method || "GET").toUpperCase() !== "POST" || - normalizeGatewayPathname(path) !== "/v1/messages" || - !isClaudeCodeUserAgent(headers) - ) { - return undefined; - } - - const parsedBody = parseJsonObjectSafe(body); - const model = stringValue(parsedBody?.model); - const rewrittenModel = resolveClaudeCodeDiscoveredModelId(model, config); - if (!parsedBody || !rewrittenModel || rewrittenModel === model) { - return undefined; - } - - return { - body: serializeJsonBodyWithModel(parsedBody, rewrittenModel), - diagnostic: `${model}->${rewrittenModel}` - }; -} - -function prepareClaudeAppFallbackModelRequest( - config: AppConfig, - method: string, - path: string, - body: Buffer | undefined -): { body: Buffer; diagnostic: string; routedModel: string } | undefined { - if ( - (method || "GET").toUpperCase() !== "POST" || - normalizeGatewayPathname(path) !== "/v1/messages" - ) { - return undefined; - } - - const parsedBody = parseJsonObjectSafe(body); - const model = stringValue(parsedBody?.model); - const normalizedModel = normalizeRouteSelector(model); - if (!parsedBody || !normalizedModel) { - return undefined; - } - - const routeModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions); - const routedModel = routeModel ?? - (normalizedModel.toLowerCase() === CLAUDE_APP_FALLBACK_MODEL ? inferClaudeAppGatewayTargetModel(config) : undefined); - if (!routedModel || routedModel.toLowerCase() === normalizedModel.toLowerCase()) { - return undefined; - } - if (isConfiguredGatewayModelSelector(normalizedModel, config) && !routeModel) { - return undefined; - } - - return { - body: serializeJsonBodyWithModel(parsedBody, routedModel), - diagnostic: `${model}->${routedModel}`, - routedModel - }; -} - -function createGatewayModelsResponse(config: AppConfig, headers: IncomingHttpHeaders, apiKey?: ApiKeyConfig): Record { - if (isClaudeAppApiKey(apiKey) || isClaudeCodeUserAgent(headers)) { - return createClaudeAppGatewayModelsResponse(config); - } - return createOpenAICompatibleGatewayModelsResponse(config); -} - -function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record { - const data = buildGatewayDiscoverableModelIds(config).map((id) => { - const catalogEntry = findModelCatalogEntry(id); - return { - id, - object: "model", - created: 0, - owned_by: gatewayModelOwner(id), - type: "model", - ...(catalogEntry?.displayName ? { display_name: catalogEntry.displayName } : {}) - }; - }); - - return { - object: "list", - data - }; -} - -function createClaudeAppGatewayModelsResponse(config: AppConfig): Record { - const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); - const data = routes.map((route) => { - const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); - const catalogEntry = findModelCatalogEntry(catalogId); - const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext); - const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); - return { - id: route.id, - capabilities: createClaudeCodeModelCapabilities(catalogEntry, { - maxInputTokens, - oneMillionContext: route.oneMillionContext - }), - created_at: "1970-01-01T00:00:00Z", - display_name: route.displayName, - max_input_tokens: maxInputTokens, - max_tokens: maxOutputTokens, - type: "model" - }; - }); - - return { - data, - first_id: data[0]?.id ?? null, - has_more: false, - last_id: data[data.length - 1]?.id ?? null - }; -} - -function createClaudeCodeModelsResponse(config: AppConfig): Record { - const models = buildClaudeCodeDiscoverableModels(config); - const data = models.map((model) => { - const claudeId = claudeCodeDiscoveryModelId(model.id); - const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id); - const catalogEntry = findModelCatalogEntry(catalogId); - const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext); - const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); - return { - id: claudeId, - capabilities: createClaudeCodeModelCapabilities(catalogEntry, { - maxInputTokens, - oneMillionContext: model.oneMillionContext - }), - created_at: "1970-01-01T00:00:00Z", - display_name: formatClaudeCodeModelDisplayName(claudeId, catalogEntry, model.oneMillionContext), - max_input_tokens: maxInputTokens, - max_tokens: maxOutputTokens, - type: "model" - }; - }); - - return { - data, - first_id: data[0]?.id ?? null, - has_more: false, - last_id: data[data.length - 1]?.id ?? null - }; -} - -function claudeGatewayModelContextWindow(entry: ModelCatalogEntry | undefined, oneMillionContext: boolean): number { - const contextWindow = modelCatalogMaxInputTokens(entry); - if (contextWindow > 0) { - return contextWindow; - } - return oneMillionContext ? 1_000_000 : 0; -} - -function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] { - return buildGatewayDiscoverableModelIds(config); -} - -function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { - const baseEntries: Array<{ modelName: string; providerName: string }> = []; - for (const provider of config.Providers) { - const providerName = provider.name?.trim(); - if (!providerName || !Array.isArray(provider.models)) { - continue; - } - for (const rawModel of provider.models) { - const modelName = rawModel.trim(); - if (!modelName) { - continue; - } - baseEntries.push({ modelName, providerName }); - } - } - - const ids = baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`); - for (const profile of config.virtualModelProfiles ?? []) { - if (!isVisibleVirtualModelProfile(profile)) { - continue; - } - - for (const entry of baseEntries) { - for (const prefix of profile.match?.prefixes ?? []) { - const normalizedPrefix = prefix.trim(); - if (normalizedPrefix) { - ids.push(`${entry.providerName}/${normalizedPrefix}${entry.modelName}`); - } - } - for (const suffix of profile.match?.suffixes ?? []) { - const normalizedSuffix = suffix.trim(); - if (normalizedSuffix) { - ids.push(`${entry.providerName}/${entry.modelName}${normalizedSuffix}`); - } - } - } - - for (const alias of profile.match?.exactAliases ?? []) { - const normalizedAlias = alias.trim(); - if (!normalizedAlias) { - continue; - } - ids.push(fusionModelSelector(normalizedAlias)); - } - } - - return uniqueStrings(ids); -} - -function gatewayModelOwner(id: string): string { - const separator = id.indexOf("/"); - return separator > 0 ? id.slice(0, separator).trim() || "ccr" : "ccr"; -} - -function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscoverableModel[] { - const seen = new Set(); - const models: ClaudeCodeDiscoverableModel[] = []; - - const pushModel = (id: string, oneMillionContext: boolean) => { - const normalized = id.trim(); - if (!normalized) { - return; - } - const key = normalized.toLowerCase(); - if (seen.has(key)) { - return; - } - seen.add(key); - models.push({ id: normalized, oneMillionContext }); - }; - - for (const id of buildClaudeCodeDiscoverableModelIds(config)) { - pushModel(id, hasClaudeCodeOneMillionContextSuffix(id)); - const baseId = stripClaudeCodeOneMillionContextSuffix(id); - if (!hasClaudeCodeOneMillionContextSuffix(id) && findModelCatalogEntry(baseId)?.limits?.supports1MContext) { - pushModel(claudeCodeOneMillionContextModelId(baseId), true); - } - } - - return models; -} - -function isVisibleVirtualModelProfile(profile: NonNullable[number]): boolean { - return profile.enabled !== false && - profile.materialization?.enabled !== false && - profile.materialization?.includeInGatewayModels !== false; -} - -function resolveClaudeCodeDiscoveredModelId(model: string | undefined, config: AppConfig): string | undefined { - const normalized = normalizeRouteSelector(model); - if (!normalized || !normalized.toLowerCase().startsWith("claude-")) { - return undefined; - } - - if (isConfiguredGatewayModelSelector(normalized, config)) { - return undefined; - } - - const unprefixed = normalized.slice("claude-".length); - if (isConfiguredGatewayModelSelector(unprefixed, config)) { - return unprefixed; - } - - const withoutOneMillionContextSuffix = stripClaudeCodeOneMillionContextSuffix(unprefixed); - return withoutOneMillionContextSuffix !== unprefixed && - isConfiguredGatewayModelSelector(withoutOneMillionContextSuffix, config) - ? withoutOneMillionContextSuffix - : undefined; -} - -function resolveGatewayPublicModelId(model: string | undefined, config: AppConfig): string | undefined { - const normalized = normalizeRouteSelector(model); - if (!normalized || !normalized.toLowerCase().startsWith("claude-")) { - return undefined; - } - if (isConfiguredGatewayModelSelector(normalized, config)) { - return undefined; - } - return resolveClaudeCodeDiscoveredModelId(normalized, config) ?? - resolveClaudeAppGatewayRouteModel(normalized, config, claudeAppGatewayModelRouteOptions); -} - -function isConfiguredGatewayModelSelector(model: string, config: AppConfig): boolean { - const normalized = normalizeRouteSelector(model)?.toLowerCase(); - if (!normalized) { - return false; - } - - for (const id of buildClaudeCodeDiscoverableModelIds(config)) { - if (id.toLowerCase() === normalized) { - return true; - } - } - - for (const provider of config.Providers) { - if (provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized)) { - return true; - } - } - - return false; -} - -function claudeCodeDiscoveryModelId(value: string): string { - return value.toLowerCase().startsWith("claude-") ? value : `claude-${value}`; -} - -function claudeCodeOneMillionContextModelId(id: string): string { - return hasClaudeCodeOneMillionContextSuffix(id) ? id : `${id}${claudeCodeOneMillionContextSuffix}`; -} - -function hasClaudeCodeOneMillionContextSuffix(id: string): boolean { - return id.trim().toLowerCase().endsWith(claudeCodeOneMillionContextSuffix); -} - -function stripClaudeCodeOneMillionContextSuffix(id: string): string { - return id.trim().replace(/\[1m\]$/i, "").trim(); -} - -function formatClaudeCodeModelDisplayName( - id: string, - entry?: ModelCatalogEntry, - oneMillionContext = hasClaudeCodeOneMillionContextSuffix(id) -): string { - if (entry?.displayName) { - return oneMillionContext ? `${entry.displayName} (1M context)` : entry.displayName; - } - - const normalized = stripClaudeCodeOneMillionContextSuffix(id.replace(/^claude-/i, "")); - const model = normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized; - const words = model - .split(/[-_]+/) - .map((part) => part.trim()) - .filter(Boolean) - .map((part) => (/^\d+$/.test(part) ? part : part.slice(0, 1).toUpperCase() + part.slice(1))); - const displayName = ["Claude", ...words].filter(Boolean).join(" "); - return oneMillionContext ? `${displayName} (1M context)` : displayName; -} - -function createClaudeCodeModelCapabilities( - entry?: ModelCatalogEntry, - options: { maxInputTokens?: number; oneMillionContext?: boolean } = {} -): Record { - if (!entry) { - return createDefaultClaudeCodeModelCapabilities(); - } - - const capabilities = entry.capabilities ?? {}; - const inputModalities = new Set((entry.modalities?.input ?? []).map((item) => item.toLowerCase())); - const outputModalities = new Set((entry.modalities?.output ?? []).map((item) => item.toLowerCase())); - const supportsReasoning = readCatalogCapability(capabilities, "reasoning"); - const supportsImageInput = readCatalogCapability(capabilities, "imageInput") || inputModalities.has("image"); - const supportsPdfInput = readCatalogCapability(capabilities, "pdfInput") || inputModalities.has("pdf"); - const supportsStructuredOutput = - readCatalogCapability(capabilities, "structuredOutput") || - readCatalogCapability(capabilities, "nativeStructuredOutput") || - readCatalogCapability(capabilities, "responseSchema"); - const supportsCodeExecution = readCatalogCapability(capabilities, "codeExecution"); - const supportsAdaptiveThinking = readCatalogCapability(capabilities, "adaptiveThinking"); - const supportsToolUse = - readCatalogCapability(capabilities, "toolCalling") || - readCatalogCapability(capabilities, "functionCalling"); - const supportsBatch = readCatalogCapability(capabilities, "batch"); - const supportsCitations = readCatalogCapability(capabilities, "citations"); - const supportsAudioInput = readCatalogCapability(capabilities, "audioInput") || inputModalities.has("audio"); - const supportsAudioOutput = readCatalogCapability(capabilities, "audioOutput") || outputModalities.has("audio"); - const supportsVideoInput = readCatalogCapability(capabilities, "videoInput") || inputModalities.has("video"); - const maxInputTokens = options.maxInputTokens ?? modelCatalogMaxInputTokens(entry); - const supportsOneMillionContext = Boolean(entry.limits?.supports1MContext); - - return { - audio_input: { supported: supportsAudioInput }, - audio_output: { supported: supportsAudioOutput }, - batch: { supported: supportsBatch }, - citations: { supported: supportsCitations }, - code_execution: { supported: supportsCodeExecution }, - context_management: { - clear_thinking_20251015: { supported: supportsReasoning }, - clear_tool_uses_20250919: { supported: supportsToolUse }, - compact_20260112: { supported: maxInputTokens > 0 }, - max_input_tokens: maxInputTokens, - supported: maxInputTokens > 0 - }, - context_window: { - max_input_tokens: maxInputTokens, - supported: maxInputTokens > 0, - supports_1m_context: supportsOneMillionContext, - one_million_context_variant: options.oneMillionContext === true - }, - effort: { - high: { supported: supportsReasoning }, - low: { supported: supportsReasoning }, - max: { supported: supportsReasoning }, - medium: { supported: supportsReasoning }, - supported: supportsReasoning, - xhigh: { supported: supportsReasoning } - }, - image_input: { supported: supportsImageInput }, - pdf_input: { supported: supportsPdfInput }, - structured_outputs: { supported: supportsStructuredOutput }, - thinking: { - supported: supportsReasoning, - types: { - adaptive: { supported: supportsAdaptiveThinking }, - enabled: { supported: supportsReasoning } - } - }, - tool_use: { supported: supportsToolUse }, - video_input: { supported: supportsVideoInput } - }; -} - -function createDefaultClaudeCodeModelCapabilities(): Record { - return { - batch: { supported: true }, - citations: { supported: true }, - code_execution: { supported: true }, - context_management: { - clear_thinking_20251015: { supported: true }, - clear_tool_uses_20250919: { supported: true }, - compact_20260112: { supported: true }, - supported: true - }, - effort: { - high: { supported: true }, - low: { supported: true }, - max: { supported: true }, - medium: { supported: true }, - supported: true, - xhigh: { supported: true } - }, - image_input: { supported: true }, - pdf_input: { supported: true }, - structured_outputs: { supported: true }, - thinking: { - supported: true, - types: { - adaptive: { supported: true }, - enabled: { supported: true } - } - } - }; -} - -function normalizeGatewayPathname(path: string): string { - const normalized = path.trim().replace(/\/+$/, ""); - return normalized || "/"; -} - -function isClaudeCodeUserAgent(headers: IncomingHttpHeaders): boolean { - const userAgent = readHeader(headers["user-agent"]); - if (!userAgent) { - return false; - } - const normalized = userAgent.toLowerCase(); - return normalized.includes("claude"); -} - -function isClaudeAppApiKey(apiKey: ApiKeyConfig | undefined): boolean { - const name = apiKey?.name?.trim().toLowerCase(); - return name === "claude app"; -} - -function prepareCursorOpenAICompatChatBody( - config: AppConfig, - client: string | undefined, - method: string, - path: string, - requestBody: Buffer -): CursorOpenAICompatPreparation | undefined { - if ((method || "GET").toUpperCase() !== "POST" || !isOpenAICompatChatCompletionsPath(path) || client !== "Cursor") { - return undefined; - } - - let body: Record; - try { - body = parseJsonObject(requestBody); - } catch { - return undefined; - } - if (!isSimplifiedCursorOpenAICompatChat(body)) { - return undefined; - } - - const context = readCursorOpenAICompatContext(config); - let changed = false; - if (context.systemPrompt) { - body.messages = [ - { content: context.systemPrompt, role: "system" }, - ...(Array.isArray(body.messages) ? body.messages : []) - ]; - changed = true; - } - if (context.tools.length > 0) { - body.tools = context.tools; - changed = true; - } - if (context.toolChoice !== undefined && context.tools.length > 0) { - body.tool_choice = context.toolChoice; - changed = true; - } - - if (!changed) { - if (!warnedMissingCursorOpenAICompatContext) { - warnedMissingCursorOpenAICompatContext = true; - console.warn( - "[gateway] Cursor sent an OpenAI-compatible chat request with only user messages and no system/tools. " + - "Configure plugins[].id=\"cursor-proxy\" config.systemPrompt/config.tools to inject fallback context, " + - "or route Cursor native Agent traffic through the proxy." - ); - } - return { diagnostic: "simplified-missing-context" }; - } - - return { - body: Buffer.from(`${JSON.stringify(body)}\n`, "utf8"), - diagnostic: "fallback-injected" - }; -} - -function isOpenAICompatChatCompletionsPath(path: string): boolean { - return path === "/chat/completions" || - path === "/v1/chat/completions" || - path.endsWith("/chat/completions"); -} - -function isSimplifiedCursorOpenAICompatChat(body: Record): boolean { - if (body.system !== undefined || body.systemPrompt !== undefined || body.instructions !== undefined) { - return false; - } - if (Array.isArray(body.tools) && body.tools.length > 0) { - return false; - } - if (!Array.isArray(body.messages) || body.messages.length === 0) { - return false; - } - return body.messages.every((message) => - isRecord(message) && - stringValue(message.role)?.toLowerCase() === "user" - ); -} - -function readCursorOpenAICompatContext(config: AppConfig): CursorOpenAICompatContext { - const plugin = config.plugins.find((item) => item.enabled !== false && item.id === "cursor-proxy"); - const pluginConfig = isRecord(plugin?.config) ? plugin.config : {}; - return { - systemPrompt: - stringValue(pluginConfig.systemPrompt) || - stringValue(pluginConfig.openaiSystemPrompt) || - stringValue(pluginConfig.defaultSystemPrompt), - toolChoice: normalizeCursorToolChoice( - pluginConfig.toolChoice ?? pluginConfig.openaiToolChoice ?? pluginConfig.defaultToolChoice - ), - tools: normalizeCursorTools(pluginConfig.tools ?? pluginConfig.openaiTools ?? pluginConfig.defaultTools) - }; -} - -function normalizeCursorTools(value: unknown): unknown[] { - if (Array.isArray(value)) { - return value.map(normalizeCursorTool).filter((tool): tool is Record => Boolean(tool)); - } - if (isRecord(value)) { - if (Array.isArray(value.tools) || isRecord(value.tools)) { - return normalizeCursorTools(value.tools); - } - return Object.entries(value) - .map(([name, item]) => normalizeCursorTool(isRecord(item) ? { ...item, name: stringValue(item.name) || name } : { description: stringValue(item), name })) - .filter((tool): tool is Record => Boolean(tool)); - } - return []; -} - -function normalizeCursorTool(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const type = stringValue(value.type); - if (type && type.toLowerCase().startsWith("web_search")) { - return { ...value, type }; - } - - const fn = isRecord(value.function) ? value.function : value; - const name = - stringValue(fn.name) || - stringValue(value.name) || - stringValue(value.toolName) || - stringValue(value.functionName); - if (!name) { - return undefined; - } - return { - function: compactRecord({ - description: stringValue(fn.description) || stringValue(value.description), - name, - parameters: normalizeCursorToolParameters( - fn.parameters ?? - value.parameters ?? - fn.input_schema ?? - value.input_schema ?? - fn.inputSchema ?? - value.inputSchema ?? - fn.schema ?? - value.schema - ) - }), - type: "function" - }; -} - -function normalizeCursorToolParameters(value: unknown): Record { - if (isRecord(value)) { - return value; - } - if (typeof value === "string") { - try { - const parsed = JSON.parse(value) as unknown; - if (isRecord(parsed)) { - return parsed; - } - } catch { - // Fall through to an empty object schema. - } - } - return { properties: {}, type: "object" }; -} - -function normalizeCursorToolChoice(value: unknown): unknown { - if (typeof value === "string" && value.trim()) { - const normalized = value.trim().toLowerCase(); - if (normalized === "auto" || normalized === "none" || normalized === "required") { - return normalized; - } - return { function: { name: value.trim() }, type: "function" }; - } - if (!isRecord(value)) { - return undefined; - } - const type = stringValue(value.type); - if (type && ["auto", "none", "required"].includes(type.toLowerCase())) { - return type.toLowerCase(); - } - const fn = isRecord(value.function) ? value.function : value; - const name = stringValue(fn.name) || stringValue(value.name) || stringValue(value.toolName); - return name ? { function: { name }, type: "function" } : undefined; -} - -function compactRecord(value: Record): Record { - return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); -} - -function inferGatewayClient(apiKey: ApiKeyConfig | undefined, headers: IncomingHttpHeaders): string | undefined { - const explicit = - readHeader(headers["x-ccr-client"]) ?? - readHeader(headers["x-client-name"]) ?? - readHeader(headers["x-forwarded-client-cert"]); - if (explicit) { - return explicit; - } - - const apiKeyClient = apiKey?.name?.trim() || apiKey?.id?.trim(); - const userAgentClient = inferClientFromUserAgent(headers); - if (readHeader(headers["x-ccr-proxy-mode"]) === "gateway") { - return userAgentClient ?? apiKeyClient; - } - return apiKeyClient ?? userAgentClient; -} - -function inferClientFromUserAgent(headers: IncomingHttpHeaders): string | undefined { - const userAgent = readHeader(headers["user-agent"]); - if (!userAgent) { - return undefined; - } - - const normalized = userAgent.toLowerCase(); - if (normalized.includes("codex")) { - return "Codex"; - } - if (normalized.includes("@anthropic-ai/claude-code") || normalized.includes("claude-code") || normalized.includes("claude code")) { - return "Claude Code"; - } - if (normalized.includes("claude")) { - return "Claude"; - } - if (normalized.includes("curl")) { - return "curl"; - } - if (normalized.includes("python")) { - return "Python"; - } - if (normalized.includes("node")) { - return "Node.js"; - } - if (normalized.includes("chrome")) { - return "Google Chrome"; - } - if (normalized.includes("safari") && !normalized.includes("chrome")) { - return "Safari"; - } - return userAgent.split(/[ /]/)[0]?.trim() || undefined; -} - -function readAuthToken(headers: IncomingHttpHeaders): string | undefined { - const raw = readHeader(headers.authorization) || readHeader(headers["x-api-key"]); - if (!raw) { - return undefined; - } - return raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw; -} - -function readRemoteControlQueryAuthToken(request: IncomingMessage): string | undefined { - const url = new URL(request.url || "/", "http://127.0.0.1"); - if (url.pathname !== ccrRemoteControlPathPrefix && !url.pathname.startsWith(`${ccrRemoteControlPathPrefix}/`)) { - return undefined; - } - return url.searchParams.get("api_key")?.trim() || url.searchParams.get("key")?.trim() || undefined; -} - -function forwardHeaders(headers: IncomingHttpHeaders): Record { - const forwarded: Record = {}; - for (const [key, value] of Object.entries(headers)) { - const normalized = key.toLowerCase(); - if (proxyHeaderDenyList.has(normalized) || value === undefined) { - continue; - } - forwarded[normalized] = Array.isArray(value) ? value.join(",") : String(value); - } - return forwarded; -} - -function stripLocalGatewayAuthHeaders(headers: Record): void { - delete headers.authorization; - delete headers["x-api-key"]; - delete headers["api-key"]; -} - -function omitLocalObservabilityHeaders(headers: Record): Record { - const forwarded = { ...headers }; - for (const name of localObservabilityHeaderNames) { - delete forwarded[name]; - } - return forwarded; -} - -function withCoreGatewayAuthHeader(headers: Record, token: string): Record { - if (!token) { - throw new Error("Core gateway auth token is not initialized."); - } - return { - ...headers, - [coreGatewayAuthHeader]: token - }; -} - -function filteredResponseHeaders(headers: Headers): Array<[string, string]> { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - if (!responseHeaderDenyList.has(key.toLowerCase())) { - entries.push([key, value]); - } - }); - return entries; -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function abortSignalMessage(signal: AbortSignal): string { - const reason = signal.reason as unknown; - if (reason instanceof Error && reason.message) { - return reason.message; - } - if (typeof reason === "string" && reason.trim()) { - return reason.trim(); - } - return "Upstream request was aborted."; -} - -function parseJsonObject(buffer: Buffer): Record { - if (buffer.length === 0) { - return {}; - } - const parsed = JSON.parse(buffer.toString("utf8")) as unknown; - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record; - } - throw new Error("Request body must be a JSON object."); -} - -function readHeader(value: string | string[] | undefined): string | undefined { - if (Array.isArray(value)) { - return value[0]?.trim(); - } - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function readRequestBody(request: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - request.on("data", (chunk) => 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 { - response.writeHead(statusCode, { "content-type": "application/json" }); - response.end(`${JSON.stringify(payload)}\n`); -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => { - let settled = false; - let timeout: NodeJS.Timeout | undefined; - const finish = () => { - if (settled) { - return; - } - settled = true; - if (timeout) { - clearTimeout(timeout); - } - resolve(); - }; - - try { - server.closeIdleConnections?.(); - timeout = setTimeout(() => { - server.closeAllConnections?.(); - finish(); - }, 800); - server.close(() => finish()); - } catch { - finish(); - } - }); -} - -function shouldSendBody(method: string | undefined): boolean { - const normalized = method?.toUpperCase(); - return normalized !== "GET" && normalized !== "HEAD"; -} - -function shouldCaptureGatewayUsage(method: string, _path: string): boolean { - return shouldSendBody(method); -} +/** + * Public gateway facade. + * + * Runtime orchestration and protocol features live in focused modules; this file keeps + * the historical import surface stable for Electron, CLI, web management, and tests. + */ +export { gatewayService } from "@ccr/core/gateway/application/gateway-service"; +export { prepareCodexApplyPatchBridgeRequest, transformCodexApplyPatchBridgeRequestBody, transformCodexApplyPatchBridgeResponseValue, transformCodexApplyPatchBridgeSseEvent } from "@ccr/core/gateway/features/codex-patch-bridge"; +export { normalizeClaudeCodeOauthProviderPlugins, normalizeCoreGatewayVirtualModelProfiles } from "@ccr/core/gateway/core-runtime/config-compiler"; +export { fusionBuiltinToolArtifactsForTest, fusionFallbackToolDefinitions, fusionToolNamesBackedByMcpServers } from "@ccr/core/mcp/fusion-config"; +export type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, BrowserWebSearchMcpRegistration, BrowserWebSearchProtocolRecord, BrowserWebSearchProtocolResult } from "@ccr/core/gateway/internal/shared"; +export { prepareGatewayUpstreamAttemptForTest } from "@ccr/core/gateway/upstream/executor"; +export { fallbackRetryDelayAfterNetworkErrorForTest, fallbackRetryDelayAfterStatusForTest } from "@ccr/core/gateway/upstream/retry-policy"; +export { shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints"; +export { extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hostedWebSearchProtocolResponseStream, prepareAnthropicWebSearchProtocolRequestBody, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, transformAnthropicWebSearchProtocolResponseValue, transformAnthropicWebSearchProtocolSseText, transformGeminiHostedWebSearchResponseValue, transformGeminiHostedWebSearchSseText, transformOpenAiChatHostedWebSearchResponseValue, transformOpenAiChatHostedWebSearchSseText, transformOpenAiResponsesHostedWebSearchResponseValue, transformOpenAiResponsesHostedWebSearchSseText } from "@ccr/core/gateway/features/hosted-web-search/index"; diff --git a/packages/core/src/gateway/upstream/executor.ts b/packages/core/src/gateway/upstream/executor.ts new file mode 100644 index 00000000..61dcf518 --- /dev/null +++ b/packages/core/src/gateway/upstream/executor.ts @@ -0,0 +1,892 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import { Readable } from "node:stream"; +import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, ProviderCredentialConfig, RouterFallbackConfig } from "@ccr/core/contracts/app"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; +import { createRouteExecutionPlan } from "@ccr/core/routing/execution-plan"; +import { rewriteRouteModelInUrl } from "@ccr/core/routing/protocol-adapter"; +import { modelRegistryForConfig, normalizeRouteSelector, parseProviderModelSelector, providerRuntimeId } from "@ccr/core/routing/model-registry"; +import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints"; +import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution"; +import { estimateLimitUsage } from "@ccr/core/gateway/limits/window-limiter"; +import { providerCredentialLimitState, readProviderCredentialCooldown, recordProviderCredentialOutcome } from "@ccr/core/providers/credential-pool"; +import { isRecord, stringValue } from "@ccr/core/gateway/internal/value"; +import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin"; +import { abortSignalMessage, formatError, omitLocalObservabilityHeaders, shouldSendBody, withCoreGatewayAuthHeader } from "@ccr/core/gateway/http/io"; +import { parseJsonObjectSafe, serializeJsonBodyWithModel } from "@ccr/core/gateway/http/body"; +import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery"; +import { activeProviderCredentials, findProviderByPublicOrInternalName, findProviderCredentialBySlug, normalizedProviderCapabilities, parseProviderCredentialInternalName, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCapabilityNameMatches, providerCredentialInternalName, providerCredentialPriority, providerCredentialRuntimeId, providerCredentialSlug, providerProtocolForClientProtocol, sanitizeHeaderValue } from "@ccr/core/providers/runtime-topology"; +import { delay } from "@ccr/core/gateway/internal/clock"; +import { retryDelayAfterNetworkError, retryDelayAfterStatus, shouldFallbackAfterStatus } from "@ccr/core/gateway/upstream/retry-policy"; +import { claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, UpstreamRequestError } from "@ccr/core/gateway/internal/shared"; +import type { ApiKeyLimitUsage, ProviderCredentialRoutingTarget, UpstreamAttempt, UpstreamFailedAttempt, UpstreamFetchResult } from "@ccr/core/gateway/internal/shared"; + +const providerCredentialSpilloverThreshold = 0.8; + + +export function applyProviderCapabilityRouting(input: { + body?: Buffer; + config: AppConfig; + fallback: RouterFallbackConfig; + headers: Record; + path: string; + routedModel?: string; +}): { body?: Buffer; fallback: RouterFallbackConfig; routedModel?: string } { + const protocol = requestProtocolForPath(input.path); + if (!protocol) { + return { + body: input.body, + fallback: input.fallback, + routedModel: input.routedModel + }; + } + + rewriteProviderHeader(input.headers, "x-target-provider", input.config, protocol); + rewriteProviderListHeader(input.headers, "x-target-providers", input.config, protocol); + rewriteProviderHeader(input.headers, "x-gateway-target-provider", input.config, protocol); + + const routedModel = rewriteModelSelectorForProtocol(input.routedModel, input.config, protocol); + const fallback = rewriteFallbackForProtocol(input.fallback, input.config, protocol); + const body = rewriteBodyModelForProtocol(input.body, input.config, protocol); + clearTargetProviderHeadersForModelSelector(input.headers, input.config, body, routedModel); + + return { + body, + fallback, + routedModel + }; +} + + +export function prepareGatewayUpstreamAttemptForTest(input: { + body: Record; + config: AppConfig; + fallback?: RouterFallbackConfig; + headers: Record; + method: string; + path: string; + routedModel?: string; +}): { + body?: Record; + credentialChain?: string[]; + credentialIds?: string[]; + credentialProtocol?: GatewayProviderProtocol; + fallback: RouterFallbackConfig; + headers?: Record; + logicalProvider?: string; + model?: string; + routedModel?: string; +} { + const headers = { ...input.headers }; + const providerCapabilityRouting = applyProviderCapabilityRouting({ + body: Buffer.from(`${JSON.stringify(input.body)}\n`, "utf8"), + config: input.config, + fallback: input.fallback ?? input.config.Router.fallback, + headers, + path: input.path, + routedModel: input.routedModel + }); + const attempt = prepareUpstreamCredentialAttempt({ + attempt: { + body: providerCapabilityRouting.body, + index: 0, + model: normalizeRouteSelector(providerCapabilityRouting.routedModel) + }, + config: input.config, + headers, + method: input.method, + path: input.path + }); + return { + body: parseJsonObjectSafe(attempt.body), + credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, + credentialProtocol: attempt.credentialProtocol, + fallback: providerCapabilityRouting.fallback, + headers: attempt.headers, + logicalProvider: attempt.logicalProvider, + model: attempt.model, + routedModel: providerCapabilityRouting.routedModel + }; +} + + +function rewriteProviderHeader( + headers: Record, + headerName: string, + config: AppConfig, + protocol: GatewayProviderProtocol +): void { + const value = headers[headerName]; + if (!value) { + return; + } + headers[headerName] = rewriteProviderSelectorForProtocol(value, config, protocol); +} + + +function rewriteProviderListHeader( + headers: Record, + headerName: string, + config: AppConfig, + protocol: GatewayProviderProtocol +): void { + const value = headers[headerName]; + if (!value) { + return; + } + headers[headerName] = value + .split(",") + .map((item) => rewriteProviderSelectorForProtocol(item.trim(), config, protocol)) + .filter(Boolean) + .join(","); +} + + +function rewriteProviderSelectorForProtocol(value: string, config: AppConfig, protocol: GatewayProviderProtocol): string { + const provider = findProviderByPublicOrInternalName(config, value); + const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined; + return provider && capability ? providerCapabilityInternalName(provider, capability.type) : value; +} + + +function rewriteFallbackForProtocol(fallback: RouterFallbackConfig, config: AppConfig, protocol: GatewayProviderProtocol): RouterFallbackConfig { + const models = fallback.models.map((model) => rewriteModelSelectorForProtocol(model, config, protocol) ?? model); + return models.every((model, index) => model === fallback.models[index]) + ? fallback + : { + ...fallback, + models + }; +} + + +function rewriteBodyModelForProtocol(body: Buffer | undefined, config: AppConfig, protocol: GatewayProviderProtocol): Buffer | undefined { + const parsedBody = parseJsonObjectSafe(body); + if (!parsedBody) { + return body; + } + const model = stringValue(parsedBody.model); + const rewrittenModel = rewriteModelSelectorForProtocol(model, config, protocol); + if (!rewrittenModel || rewrittenModel === model) { + return body; + } + return Buffer.from(`${JSON.stringify({ ...parsedBody, model: rewrittenModel })}\n`, "utf8"); +} + + +function clearTargetProviderHeadersForModelSelector( + headers: Record, + config: AppConfig, + body: Buffer | undefined, + routedModel: string | undefined +): void { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model) || routedModel; + if (!resolveConfiguredProviderModelSelector(model, config)) { + return; + } + + delete headers["x-target-provider"]; + delete headers["x-target-providers"]; + delete headers["x-gateway-target-provider"]; +} + + +function rewriteModelSelectorForProtocol( + model: string | undefined, + config: AppConfig, + protocol: GatewayProviderProtocol +): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized) { + return model; + } + const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; + const selector = + resolveConfiguredProviderModelSelector(publicModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(publicModel, config); + const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined; + return selector && capability + ? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}` + : publicModel; +} + + +export function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): Headers { + const providerName = headers.get("x-gateway-target-provider-name")?.trim(); + if (!providerName) { + return headers; + } + const credentialInternalName = parseProviderCredentialInternalName(providerName); + if (credentialInternalName) { + const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerId); + if (!provider) { + return headers; + } + const credential = findProviderCredentialBySlug(provider, credentialInternalName.credentialSlug); + const rewritten = new Headers(headers); + rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); + rewritten.set("x-ccr-provider-protocol", credentialInternalName.protocol); + rewritten.set("x-ccr-provider-credential-provider", providerRuntimeId(provider)); + rewritten.set("x-ccr-provider-credential-id", providerCredentialSlug(credential ? providerCredentialRuntimeId(provider, credential) : credentialInternalName.credentialSlug)); + return rewritten; + } + const provider = findProviderByPublicOrInternalName(config, providerName); + if (!provider) { + return headers; + } + const capability = normalizedProviderCapabilities(provider).find((item) => + providerCapabilityNameMatches(provider, item.type, providerName) + ); + const rewritten = new Headers(headers); + rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); + if (capability) { + rewritten.set("x-ccr-provider-protocol", capability.type); + } + return rewritten; +} + + +export async function fetchUpstreamWithFallback(input: { + body?: Buffer; + config: AppConfig; + coreAuthToken: string; + fallback: RouterFallbackConfig; + headers: Record; + method: string; + path: string; + routedModel?: string; + signal?: AbortSignal; + upstreamUrl: string; +}): Promise { + const fallbackMode = input.fallback.mode; + const attempts = buildUpstreamAttempts( + input.config, + input.fallback, + input.method, + input.path, + input.body, + input.routedModel + ); + const failedAttempts: UpstreamFailedAttempt[] = []; + + for (let index = 0; index < attempts.length; index += 1) { + if (input.signal?.aborted) { + throw new UpstreamRequestError(abortSignalMessage(input.signal), { + failedAttempts + }); + } + + const attempt = prepareUpstreamCredentialAttempt({ + attempt: attempts[index], + config: input.config, + headers: input.headers, + method: input.method, + path: input.path + }); + const hasNextAttempt = index < attempts.length - 1; + + try { + const response = await fetchWithSystemProxy(rewriteRouteModelInUrl(input.upstreamUrl, attempt.model), { + body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined, + headers: withCoreGatewayAuthHeader(omitLocalObservabilityHeaders(attempt.headers ?? input.headers), input.coreAuthToken), + method: input.method, + signal: input.signal + }); + + if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) { + const delayMs = retryDelayAfterStatus(response.headers, failedAttempts.length); + failedAttempts.push({ + credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, + delayMs, + model: attempt.model, + statusCode: response.status + }); + recordProviderCredentialOutcome(input.config, input.method, attempt, response.status, response.headers); + await drainResponseBody(response); + if (delayMs > 0) { + await delay(delayMs); + } + continue; + } + + return { + attempt, + failedAttempts, + response + }; + } catch (error) { + const message = formatError(error); + const delayMs = hasNextAttempt && !input.signal?.aborted + ? retryDelayAfterNetworkError(failedAttempts.length) + : 0; + failedAttempts.push({ + credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, + delayMs, + error: message, + model: attempt.model + }); + if (input.signal?.aborted) { + throw new UpstreamRequestError(abortSignalMessage(input.signal), { + attempt, + cause: error, + failedAttempts + }); + } + if (hasNextAttempt) { + if (delayMs > 0) { + await delay(delayMs); + } + continue; + } + throw new UpstreamRequestError(message, { + attempt, + cause: error, + failedAttempts + }); + } + } + + throw new UpstreamRequestError("Gateway request failed before reaching an upstream provider.", { + failedAttempts + }); +} + + +function prepareUpstreamCredentialAttempt(input: { + attempt: UpstreamAttempt; + config: AppConfig; + headers: Record; + method: string; + path: string; +}): UpstreamAttempt { + const normalizedBody = normalizeConfiguredProviderModelBody(input.attempt.body, input.config); + const target = resolvePlannedProviderCredentialRoutingTarget(input.attempt, input.path) ?? + resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body); + const attemptBody = (body: Buffer | undefined) => usageAwareOpenAiChatAttemptBody({ + body, + config: input.config, + path: input.path, + target + }); + if (!target) { + const body = bodyHasConfiguredProviderModelSelector(input.attempt.body, input.config) + ? input.attempt.body + : normalizedBody?.body ?? input.attempt.body; + return { + ...input.attempt, + body: attemptBody(body), + headers: input.headers + }; + } + + const attemptHeaders = withClaudeCodeOauthBetaHeader(input.headers, input.config, target); + + const credentials = activeProviderCredentials(target.provider); + if (credentials.length === 0) { + const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); + return { + ...input.attempt, + body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), + headers: preserveModelSelector + ? clearTargetProviderHeaders(attemptHeaders) + : targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol) + }; + } + + const usage = estimateLimitUsage(input.method, input.attempt.body ?? Buffer.alloc(0)); + const selection = selectProviderCredentials(target.provider, target.protocol, credentials, usage); + if (selection.credentials.length === 0) { + const preserveModelSelector = shouldPreserveCapabilityModelSelector(input.attempt.body, target); + return { + ...input.attempt, + body: attemptBody(preserveModelSelector ? input.attempt.body : target.body ?? normalizedBody?.body ?? input.attempt.body), + headers: preserveModelSelector + ? clearTargetProviderHeaders(attemptHeaders) + : targetProviderFallbackHeaders(attemptHeaders, target.provider, target.protocol) + }; + } + + const headers: Record = { + ...attemptHeaders, + "x-target-providers": selection.credentials.map((candidate) => candidate.internalName).join(","), + "x-ccr-logical-provider": providerRuntimeId(target.provider), + "x-ccr-provider-credential-chain": selection.credentials.map((candidate) => candidate.credentialId).join(",") + }; + delete headers["x-target-provider"]; + if (selection.saturated) { + headers["x-ccr-provider-credential-saturated"] = "true"; + } + + return { + ...input.attempt, + body: attemptBody(target.body ?? normalizedBody?.body ?? input.attempt.body), + credentialChain: selection.credentials.map((candidate) => candidate.internalName), + credentialIds: selection.credentials.map((candidate) => candidate.credentialId), + credentialProtocol: target.protocol, + headers, + logicalProvider: target.provider.name + }; +} + + +function withClaudeCodeOauthBetaHeader( + headers: Record, + config: AppConfig, + target: ProviderCredentialRoutingTarget +): Record { + if ( + target.protocol !== "anthropic_messages" || + !claudeCodeOauthPluginMatchesTarget(config, target.provider, target.protocol) + ) { + return headers; + } + + const existingEntry = Object.entries(headers) + .find(([name]) => name.trim().toLowerCase() === claudeCodeOauthBetaHeader); + const merged = mergeAnthropicBetaValues(existingEntry?.[1], claudeCodeOauthRequiredBeta); + if (existingEntry?.[0] === claudeCodeOauthBetaHeader && existingEntry[1] === merged) { + return headers; + } + + const next = Object.fromEntries( + Object.entries(headers).filter(([name]) => name.trim().toLowerCase() !== claudeCodeOauthBetaHeader) + ); + next[claudeCodeOauthBetaHeader] = merged; + return next; +} + + +function claudeCodeOauthPluginMatchesTarget( + config: AppConfig, + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol +): boolean { + const targetNames = new Set([ + provider.name, + providerRuntimeId(provider), + providerCapabilityInternalName(provider, protocol) + ].map((name) => name.trim().toLowerCase())); + return (config.providerPlugins ?? []).some((plugin) => { + if (!isLocalClaudeCodeOauthProviderPlugin(plugin)) { + return false; + } + const providerName = stringValue(plugin.providerName)?.toLowerCase(); + return Boolean(providerName && targetNames.has(providerName)); + }); +} + + +function targetProviderFallbackHeaders( + headers: Record, + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol +): Record { + const next = { ...headers }; + next["x-target-provider"] = targetProviderHeaderValue(provider, protocol); + delete next["x-target-providers"]; + delete next["x-gateway-target-provider"]; + return next; +} + + +function clearTargetProviderHeaders(headers: Record): Record { + const next = { ...headers }; + delete next["x-target-provider"]; + delete next["x-target-providers"]; + delete next["x-gateway-target-provider"]; + return next; +} + + +function shouldPreserveCapabilityModelSelector(body: Buffer | undefined, target: ProviderCredentialRoutingTarget): boolean { + if (target.source === "header" || target.protocol !== "gemini_interactions") { + return false; + } + return Boolean(parseProviderModelSelector(stringValue(parseJsonObjectSafe(body)?.model))); +} + + +function resolvePlannedProviderCredentialRoutingTarget( + attempt: UpstreamAttempt, + path: string +): ProviderCredentialRoutingTarget | undefined { + if (attempt.target?.kind !== "provider") { + return undefined; + } + const clientProtocol = requestProtocolForPath(path); + const protocol = clientProtocol + ? providerProtocolForClientProtocol(attempt.target.provider, clientProtocol) + : undefined; + if (!protocol) { + return undefined; + } + const parsedBody = parseJsonObjectSafe(attempt.body); + return { + body: parsedBody && clientProtocol !== "gemini_generate_content" + ? serializeJsonBodyWithModel(parsedBody, attempt.target.model) + : attempt.body, + model: attempt.target.model, + provider: attempt.target.provider, + protocol, + source: "plan" + }; +} + + +function targetProviderHeaderValue(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string { + const capability = normalizedProviderCapabilities(provider).find((item) => item.type === protocol); + return capability ? providerCapabilityInternalName(provider, capability.type) : provider.name || providerRuntimeId(provider); +} + + +function usageAwareOpenAiChatAttemptBody(input: { + body: Buffer | undefined; + config: AppConfig; + path: string; + target?: { protocol: GatewayProviderProtocol }; +}): Buffer | undefined { + const clientProtocol = requestProtocolForPath(input.path); + const parsedBody = parseJsonObjectSafe(input.body); + const modelSelector = resolveConfiguredProviderModelSelector(stringValue(parsedBody?.model), input.config); + const providerProtocol = input.target?.protocol ?? ( + modelSelector && clientProtocol + ? providerProtocolForClientProtocol(modelSelector.provider, clientProtocol) + : undefined + ); + if (providerProtocol !== "openai_chat_completions" && providerProtocol !== "openai_responses") { + return input.body; + } + const sanitizedBody = stripUnsupportedOpenAiRequestParameters(input.body); + return providerProtocol === "openai_chat_completions" + ? usageAwareOpenAiChatBody(sanitizedBody) + : sanitizedBody; +} + + +function stripUnsupportedOpenAiRequestParameters(body: Buffer | undefined): Buffer | undefined { + const parsedBody = parseJsonObjectSafe(body); + if (!parsedBody || (!("thinking" in parsedBody) && !("reasoning_split" in parsedBody))) { + return body; + } + const next = { ...parsedBody }; + delete next.thinking; + delete next.reasoning_split; + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); +} + + +function usageAwareOpenAiChatBody(body: Buffer | undefined): Buffer | undefined { + const parsedBody = parseJsonObjectSafe(body); + if (!parsedBody || parsedBody.stream !== true) { + return body; + } + const streamOptions = isRecord(parsedBody.stream_options) + ? parsedBody.stream_options + : isRecord(parsedBody.streamOptions) + ? parsedBody.streamOptions + : {}; + if (streamOptions.include_usage === true || streamOptions.includeUsage === true) { + return body; + } + return Buffer.from(`${JSON.stringify({ + ...parsedBody, + stream_options: { + ...streamOptions, + include_usage: true + } + })}\n`, "utf8"); +} + + +function normalizeConfiguredProviderModelBody( + body: Buffer | undefined, + config: AppConfig +): { body: Buffer; model: string } | undefined { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const selector = resolveConfiguredProviderModelSelector(model, config); + if (!parsedBody || !selector || selector.model === model) { + return undefined; + } + return { + body: serializeJsonBodyWithModel(parsedBody, selector.model), + model: selector.model + }; +} + + +function bodyHasConfiguredProviderModelSelector(body: Buffer | undefined, config: AppConfig): boolean { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + return Boolean(resolveConfiguredProviderModelSelector(model, config)); +} + + +function resolveProviderCredentialRoutingTarget( + config: AppConfig, + headers: Record, + path: string, + body: Buffer | undefined +): ProviderCredentialRoutingTarget | undefined { + const protocol = requestProtocolForPath(path); + if (!protocol) { + return undefined; + } + + const parsedBody = parseJsonObjectSafe(body); + const bodyModel = stringValue(parsedBody?.model); + const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(bodyModel, config); + if (modelSelector) { + const provider = modelSelector.provider; + const providerProtocol = provider ? providerProtocolForClientProtocol(provider, protocol) : undefined; + if (provider && providerProtocol) { + return { + body: parsedBody ? serializeJsonBodyWithModel(parsedBody, modelSelector.model) : body, + model: modelSelector.model, + provider, + protocol: providerProtocol, + source: "model" + }; + } + } + + const targetProviderName = firstTargetProviderHeader(headers); + if (!targetProviderName) { + return undefined; + } + + const provider = findProviderByPublicOrInternalName(config, targetProviderName); + if (!provider) { + return undefined; + } + const providerProtocol = providerProtocolForClientProtocol(provider, protocol); + if (!providerProtocol) { + return undefined; + } + const providerModel = resolveModelForProvider(bodyModel, provider); + + return { + body: parsedBody && providerModel && providerModel !== bodyModel + ? serializeJsonBodyWithModel(parsedBody, providerModel) + : body, + model: providerModel ?? bodyModel, + provider, + protocol: providerProtocol, + source: "header" + }; +} + + +function resolveModelForProvider( + value: string | undefined, + provider: GatewayProviderConfig +): string | undefined { + const normalized = normalizeRouteSelector(value); + if (!normalized) { + return undefined; + } + if (providerHasModel(provider, normalized)) { + return normalized; + } + const parsed = parseProviderModelSelector(normalized); + return parsed && providerHasModel(provider, parsed.model) ? parsed.model : undefined; +} + + +function providerHasModel(provider: GatewayProviderConfig, model: string): boolean { + const normalized = model.trim().toLowerCase(); + return Boolean(normalized) && provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized); +} + + +function firstTargetProviderHeader(headers: Record): string | undefined { + const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; + if (provider?.trim()) { + return provider.trim(); + } + const providers = headers["x-target-providers"]; + return providers + ?.split(",") + .map((item) => item.trim()) + .find(Boolean); +} + + +function selectProviderCredentials( + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol, + credentials: ProviderCredentialConfig[], + usage: ApiKeyLimitUsage +): { credentials: Array<{ credential: ProviderCredentialConfig; credentialId: string; internalName: string }>; saturated: boolean } { + const candidates = credentials.map((credential, index) => { + const providerIndex = provider.credentials?.indexOf(credential) ?? index; + const limitState = providerCredentialLimitState(provider, credential, usage); + const cooldown = readProviderCredentialCooldown(provider, credential); + return { + cooldown, + credential, + credentialId: providerCredentialSlug(providerCredentialRuntimeId(provider, credential, providerIndex)), + index: providerIndex, + internalName: providerCredentialInternalName(provider, protocol, credential), + limitState, + priority: providerCredentialPriority(credential, providerIndex), + weight: Math.max(1, credential.weight ?? 1) + }; + }); + const available = candidates.filter((candidate) => !candidate.cooldown && !candidate.limitState.blocked); + const sorted = sortProviderCredentialCandidates(available.length > 0 ? available : candidates); + return { + credentials: sorted.map((candidate) => ({ + credential: candidate.credential, + credentialId: candidate.credentialId, + internalName: candidate.internalName + })), + saturated: available.length === 0 && candidates.length > 0 + }; +} + + +function sortProviderCredentialCandidates(candidates: T[]): T[] { + const prioritySorted = [...candidates].sort((left, right) => + left.priority - right.priority || + left.limitState.utilization - right.limitState.utilization || + right.weight - left.weight || + left.index - right.index + ); + const primaryPriority = prioritySorted[0]?.priority; + const primaryCandidates = prioritySorted.filter((candidate) => candidate.priority === primaryPriority); + const shouldSpillOver = primaryCandidates.length > 0 && + primaryCandidates.every((candidate) => candidate.limitState.utilization >= providerCredentialSpilloverThreshold); + + if (shouldSpillOver) { + return prioritySorted.sort((left, right) => + left.limitState.utilization - right.limitState.utilization || + left.priority - right.priority || + right.weight - left.weight || + left.index - right.index + ); + } + + return prioritySorted; +} + + +function buildUpstreamAttempts( + config: AppConfig, + fallback: RouterFallbackConfig, + method: string, + path: string, + body: Buffer | undefined, + routedModel: string | undefined +): UpstreamAttempt[] { + const parsedBody = parseJsonObjectSafe(body); + const modelInPath = requestProtocolForPath(path) === "gemini_generate_content"; + const plan = createRouteExecutionPlan({ + bodyModel: modelInPath ? undefined : stringValue(parsedBody?.model), + fallback, + hasRequestBody: shouldSendBody(method) && (fallback.mode !== "model-chain" || Boolean(parsedBody)), + modelRegistry: modelRegistryForConfig(config), + primaryModel: routedModel + }); + return plan.attempts.map((attempt) => ({ + body: parsedBody && !modelInPath && fallback.mode === "model-chain" && attempt.model + ? serializeJsonBodyWithModel(parsedBody, attempt.model) + : body, + index: attempt.index, + model: attempt.model, + target: attempt.target + })); +} + + +async function drainResponseBody(response: Response): Promise { + try { + await response.arrayBuffer(); + } catch { + // The failed attempt is already being skipped; body drain errors should not block the next attempt. + } +} + + +export async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // The client already disconnected; best-effort upstream cleanup must not mask that expected path. + } +} + + +export function uniqueStreams(streams: Readable[]): Readable[] { + return [...new Set(streams)]; +} + + +export function destroyResponseStreams(streams: Readable[]): void { + for (const stream of streams) { + if (!stream.destroyed) { + // A downstream client close is an expected abort path. Destroying with + // an Error would emit another error event on Readable/Transform stages, + // and intermediate stages may not be the final responseBody listener. + stream.destroy(); + } + } +} + + +export function mergeFallbackResponseHeaders(headers: Headers, result: UpstreamFetchResult): Headers { + const credentialIds = result.attempt.credentialIds ?? []; + const credentialSaturated = result.attempt.headers?.["x-ccr-provider-credential-saturated"] === "true"; + if (result.failedAttempts.length === 0 && credentialIds.length === 0 && !credentialSaturated) { + return headers; + } + + const merged = new Headers(headers); + if (result.failedAttempts.length > 0) { + merged.set("x-ccr-fallback-attempts", String(result.failedAttempts.length + 1)); + merged.set("x-ccr-fallback-failures", formatFallbackFailures(result.failedAttempts)); + if (result.failedAttempts.some((attempt) => (attempt.delayMs ?? 0) > 0)) { + merged.set("x-ccr-fallback-delays-ms", formatFallbackDelays(result.failedAttempts)); + } + if (result.attempt.model) { + merged.set("x-ccr-fallback-model", sanitizeHeaderValue(result.attempt.model)); + } + } + if (credentialIds.length) { + merged.set("x-ccr-provider-credential-chain", credentialIds.join(",")); + } + if (credentialSaturated) { + merged.set("x-ccr-provider-credential-saturated", "true"); + } + return merged; +} + + +export function upstreamResponseHeaders(result: UpstreamFetchResult): Headers { + return result.response.headers; +} + + +function formatFallbackFailures(failedAttempts: UpstreamFailedAttempt[]): string { + return failedAttempts + .map((attempt) => attempt.statusCode ? String(attempt.statusCode) : attempt.error ? "network" : "failed") + .join(","); +} + + +function formatFallbackDelays(failedAttempts: UpstreamFailedAttempt[]): string { + return failedAttempts + .map((attempt) => String(Math.max(0, attempt.delayMs ?? 0))) + .join(","); +} diff --git a/packages/core/src/gateway/upstream/retry-policy.ts b/packages/core/src/gateway/upstream/retry-policy.ts new file mode 100644 index 00000000..66bcd4df --- /dev/null +++ b/packages/core/src/gateway/upstream/retry-policy.ts @@ -0,0 +1,53 @@ +import type { RouterFallbackMode } from "@ccr/core/contracts/app"; +import { classifyRouteFailure } from "@ccr/core/routing/failure-classifier"; +import { clampNumber } from "@ccr/core/gateway/internal/collections"; + +const upstreamRetryBackoffBaseMs = 1_000; +const upstreamRetryBackoffMaxMs = 30_000; +const upstreamRetryAfterMaxMs = 60_000; + +export function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode): boolean { + return classifyRouteFailure(statusCode, mode).shouldFallback; +} + +export function retryDelayAfterStatus(headers: Headers, failedAttemptIndex: number): number { + const retryAfterMs = parseRetryAfterHeaderMs(headers.get("retry-after")); + if (retryAfterMs !== undefined && retryAfterMs > 0) { + return clampNumber(retryAfterMs, 1, upstreamRetryAfterMaxMs); + } + return exponentialRetryBackoffMs(failedAttemptIndex); +} + +export function retryDelayAfterNetworkError(failedAttemptIndex: number): number { + return exponentialRetryBackoffMs(failedAttemptIndex); +} + +export function fallbackRetryDelayAfterStatusForTest(input: { + failedAttemptIndex?: number; + retryAfter?: string | null; + statusCode: number; +}): number { + const headers = new Headers(); + if (input.retryAfter !== undefined && input.retryAfter !== null) { + headers.set("retry-after", input.retryAfter); + } + return retryDelayAfterStatus(headers, input.failedAttemptIndex ?? 0); +} + +export function fallbackRetryDelayAfterNetworkErrorForTest(failedAttemptIndex = 0): number { + return retryDelayAfterNetworkError(failedAttemptIndex); +} + +function parseRetryAfterHeaderMs(value: string | null): number | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + const seconds = Number(trimmed); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; + const retryAt = Date.parse(trimmed); + return Number.isFinite(retryAt) ? Math.max(0, retryAt - Date.now()) : undefined; +} + +function exponentialRetryBackoffMs(failedAttemptIndex: number): number { + const exponent = Math.min(10, Math.max(0, failedAttemptIndex)); + return Math.min(upstreamRetryBackoffMaxMs, upstreamRetryBackoffBaseMs * 2 ** exponent); +} diff --git a/packages/core/src/mcp/fusion-config.ts b/packages/core/src/mcp/fusion-config.ts new file mode 100644 index 00000000..0bd850bb --- /dev/null +++ b/packages/core/src/mcp/fusion-config.ts @@ -0,0 +1,733 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import { join as pathJoin } from "node:path"; +import type { AppConfig, GatewayMcpServerConfig, VirtualModelFusionVisionConfig, VirtualModelFusionWebSearchConfig, VirtualModelFusionWebSearchProvider } from "@ccr/core/contracts/app"; +import { BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "@ccr/core/contracts/app"; +import { TOOL_HUB_MCP_SERVER_NAME, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config"; +import { isRecord, numberValue, stringListValue, stringValue } from "@ccr/core/gateway/internal/value"; +import { defaultFusionWebSearchProvider, fusionModelProviderName } from "@ccr/core/gateway/internal/shared"; +import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared"; +import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; + + +export async function fusionBuiltinToolArtifacts( + profiles: unknown[], + coreEndpoint: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { + const providers: CoreGatewayProvider[] = []; + const mcpServers: GatewayMcpServerConfig[] = []; + const toolServerKeys = new Set(); + const entry = bundledFusionBuiltinMcpEntryPath(); + + for (const [index, profile] of profiles.entries()) { + if (!isRecord(profile) || profile.enabled === false) { + continue; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const profileId = stringValue(profile.id) || stringValue(profile.key) || `fusion-${index + 1}`; + const sanitizedProfileId = sanitizeMcpServerName(profileId); + + const visionConfig = readFusionVisionConfig(metadata?.fusionVision) ?? legacyFusionVisionConfig(profile); + if (visionConfig?.toolName) { + const resolvedVision = resolveFusionVisionRuntime(visionConfig); + providers.push(...resolvedVision.providers); + const toolServerKey = `vision:${visionConfig.toolName}`; + if (!toolServerKeys.has(toolServerKey)) { + toolServerKeys.add(toolServerKey); + const useGatewayVisionRuntime = !visionConfig.baseUrl; + mcpServers.push(fusionBuiltinMcpServer({ + entry, + env: { + FUSION_BUILTIN_TOOL_KIND: "vision", + FUSION_TOOL_NAME: visionConfig.toolName, + ...(useGatewayVisionRuntime ? { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` } : { VISION_BASE_URL: visionConfig.baseUrl || "" }), + ...(useGatewayVisionRuntime && coreAuthToken ? { VISION_GATEWAY_API_KEY: coreAuthToken } : {}), + ...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}), + ...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}), + ...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {}) + }, + name: `fusion-vision-${sanitizedProfileId}` + })); + } + } + + const webSearchConfig = readFusionWebSearchConfig(metadata?.fusionWebSearch) ?? legacyFusionWebSearchConfig(profile); + if (webSearchConfig?.toolName) { + const toolServerKey = `web_search:${webSearchConfig.toolName}`; + if (!toolServerKeys.has(toolServerKey)) { + toolServerKeys.add(toolServerKey); + 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 }; +} + + +export async function fusionBuiltinToolArtifactsForTest( + profiles: unknown[], + coreEndpoint: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> { + return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); +} + + +function fusionBuiltinMcpServer({ + entry, + env, + name +}: { + entry: string; + env: Record; + name: string; +}): GatewayMcpServerConfig { + return { + args: [entry], + command: process.execPath, + env: { + ELECTRON_RUN_AS_NODE: "1", + ...env + }, + name, + protocolVersion: "2024-11-05", + requestTimeoutMs: 600000, + startupTimeoutMs: 600000, + stdioMessageMode: "content-length", + transport: "stdio" + }; +} + + +function bundledFusionBuiltinMcpEntryPath(): string { + return pathJoin(__dirname, "fusion-vision-mcp.js"); +} + + +export function fusionToolFallbackMcpServer( + profiles: unknown[], + existingServers: unknown[] +): GatewayMcpServerConfig | undefined { + const tools = fusionFallbackToolDefinitions(profiles, fusionToolNamesBackedByMcpServers(existingServers)); + if (tools.length === 0) { + return undefined; + } + + return { + args: [bundledFusionToolFallbackMcpEntryPath()], + command: process.execPath, + env: { + ELECTRON_RUN_AS_NODE: "1", + FUSION_FALLBACK_TOOLS_JSON: JSON.stringify(tools) + }, + name: uniqueMcpServerName("ccr-fusion-tool-fallback", existingServers), + protocolVersion: "2024-11-05", + requestTimeoutMs: 600000, + startupTimeoutMs: 600000, + stdioMessageMode: "content-length", + transport: "stdio" + }; +} + + +function bundledFusionToolFallbackMcpEntryPath(): string { + return pathJoin(__dirname, "fusion-tool-fallback-mcp.js"); +} + + +export function toolHubMcpServer(config: AppConfig, backendServers: unknown[]): GatewayMcpServerConfig | undefined { + const toolHub = config.toolHub; + const runtimeBackendServers = [ + ...toolHubBuiltInBackendServers(config), + ...backendServers + ]; + const runtimeConfig = toolHubMcpRuntimeConfig(config, runtimeBackendServers); + if (!toolHub?.enabled || !runtimeConfig) { + return undefined; + } + + return { + ...runtimeConfig, + name: uniqueMcpServerName(TOOL_HUB_MCP_SERVER_NAME, runtimeBackendServers), + protocolVersion: "2024-11-05", + requestTimeoutMs: toolHubRequestTimeoutMs(config, runtimeBackendServers), + startupTimeoutMs: 600000, + stdioMessageMode: "content-length", + transport: "stdio" + }; +} + + +export function fusionFallbackToolDefinitions( + profiles: unknown[], + backedToolNames: Set = new Set() +): FusionFallbackToolDefinition[] { + const byName = new Map(); + + for (const profile of profiles) { + if (!isRecord(profile) || profile.enabled === false) { + continue; + } + + if (Array.isArray(profile.tools)) { + for (const tool of profile.tools) { + if (!isRecord(tool)) { + continue; + } + const name = stringValue(tool.name); + if (!name) { + continue; + } + if (backedToolNames.has(name)) { + continue; + } + + const existing = byName.get(name); + const description = stringValue(tool.description); + const inputSchema = isRecord(tool.inputSchema) + ? tool.inputSchema + : isRecord(tool.input_schema) + ? tool.input_schema + : undefined; + const unavailableMessage = fusionFallbackToolUnavailableMessage(profile, name); + if (existing) { + if (!existing.description && description) { + existing.description = description; + } + if (!existing.inputSchema && inputSchema) { + existing.inputSchema = inputSchema; + } + if (!existing.unavailableMessage && unavailableMessage) { + existing.unavailableMessage = unavailableMessage; + } + continue; + } + + byName.set(name, { + ...(description ? { description } : {}), + ...(inputSchema ? { inputSchema } : {}), + ...(unavailableMessage ? { unavailableMessage } : {}), + name + }); + } + } + + const browserFallback = browserWebSearchFallbackToolDefinition(profile, backedToolNames); + if (browserFallback && !byName.has(browserFallback.name)) { + byName.set(browserFallback.name, browserFallback); + } + } + + return [...byName.values()]; +} + + +type FusionFallbackToolDefinition = { + description?: string; + inputSchema?: Record; + name: string; + unavailableMessage?: string; +}; + + +function fusionFallbackToolUnavailableMessage(profile: unknown, toolName: string): string | undefined { + if (!isRecord(profile)) { + return undefined; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (webSearchConfig?.provider !== "browser" || webSearchConfig.toolName !== toolName) { + return undefined; + } + return browserWebSearchUnavailableMessage(toolName); +} + + +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." + ].join(" "); +} + + +function browserWebSearchFallbackToolDefinition( + profile: Record, + backedToolNames: Set +): FusionFallbackToolDefinition | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (webSearchConfig?.provider !== "browser" || !webSearchConfig.toolName || backedToolNames.has(webSearchConfig.toolName)) { + return undefined; + } + return { + description: "Fallback registration for CCR In-app Browser web search when the Electron browser integration is unavailable.", + inputSchema: { + additionalProperties: true, + properties: { + count: { maximum: 20, minimum: 1, type: "number" }, + prompt: { type: "string" }, + query: { type: "string" } + }, + required: ["prompt"], + type: "object" + }, + name: webSearchConfig.toolName, + unavailableMessage: fusionFallbackToolUnavailableMessage(profile, webSearchConfig.toolName) + }; +} + + +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 + .map((server) => isRecord(server) ? stringValue(server.name)?.toLowerCase() : undefined) + .filter((name): name is string => Boolean(name)) + ); + if (!used.has(baseName.toLowerCase())) { + return baseName; + } + for (let index = 2; ; index += 1) { + const candidate = `${baseName}-${index}`; + if (!used.has(candidate.toLowerCase())) { + return candidate; + } + } +} + + +export function withFusionVirtualModelAliases(profiles: unknown[]): unknown[] { + return profiles.map((profile) => { + if (!isRecord(profile)) { + return profile; + } + const match = isRecord(profile.match) ? profile.match : {}; + const exactAliases = stringListValue(match.exactAliases); + const catalogNames = exactAliases.length > 0 + ? exactAliases + : [stringValue(profile.key) || stringValue(profile.displayName)].filter((value): value is string => Boolean(value)); + const fusionAliases = catalogNames.flatMap(fusionModelSelectors).filter(Boolean); + if (fusionAliases.length === 0) { + return profile; + } + return { + ...profile, + match: { + ...match, + exactAliases: uniqueStrings([...exactAliases, ...fusionAliases]) + } + }; + }); +} + + +export function withCodexCompatibleVirtualModelProfiles(profiles: unknown[]): unknown[] { + return profiles.map((profile) => { + if (!isRecord(profile) || profile.enabled === false) { + return profile; + } + const materialization = isRecord(profile.materialization) ? profile.materialization : {}; + if (materialization.enabled === false || materialization.includeInGatewayModels === false) { + return profile; + } + const execution = isRecord(profile.execution) ? profile.execution : {}; + if (execution.clientToolsPolicy === "allow") { + return profile; + } + return { + ...profile, + execution: { + ...execution, + clientToolsPolicy: "allow" + } + }; + }); +} + + +export function fusionModelSelector(model: string): string { + const normalized = fusionModelNameFromSelector(model); + return normalized ? `${fusionModelProviderName}/${normalized}` : ""; +} + + +function fusionModelSelectors(model: string): string[] { + const normalized = fusionModelNameFromSelector(model); + if (!normalized) { + return []; + } + const lowerModel = normalized.toLowerCase(); + return uniqueStrings([ + fusionModelSelector(normalized), + lowerModel, + `${fusionModelProviderName}/${lowerModel}`, + `${fusionModelProviderName.toLowerCase()}/${lowerModel}` + ]); +} + + +export function fusionModelNameFromSelector(model: string): string { + const trimmed = model.trim(); + const prefix = `${fusionModelProviderName}/`; + return trimmed.toLowerCase().startsWith(prefix.toLowerCase()) + ? trimmed.slice(prefix.length).trim() + : trimmed; +} + + +function legacyFusionVisionConfig(profile: Record): VirtualModelFusionVisionConfig | undefined { + const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_VISION_TOOL_NAME, "matchMultimodal"); + return toolName ? { toolName } : undefined; +} + + +function legacyFusionWebSearchConfig(profile: Record): VirtualModelFusionWebSearchConfig | undefined { + const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, "matchWebSearch"); + return toolName ? { provider: defaultFusionWebSearchProvider, toolName } : undefined; +} + + +function legacyFusionBuiltinToolName( + profile: Record, + baseToolName: string, + executionFlag: "matchMultimodal" | "matchWebSearch" +): string | undefined { + const tools = Array.isArray(profile.tools) ? profile.tools : []; + const toolName = tools + .map((tool) => isRecord(tool) ? stringValue(tool.name) ?? "" : "") + .find((name) => fusionBuiltinToolNameMatches(name, baseToolName)); + if (toolName) { + return toolName; + } + const execution = isRecord(profile.execution) ? profile.execution : {}; + 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); +} + + +export 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 } : {}) + }; +} + + +export 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; + } + const toolName = stringValue(value.toolName); + if (!toolName) { + return undefined; + } + const config: VirtualModelFusionVisionConfig = { + toolName, + apiKey: stringValue(value.apiKey), + baseUrl: stringValue(value.baseUrl), + model: stringValue(value.model), + modelSelector: stringValue(value.modelSelector) + }; + const timeoutMs = numberValue(value.timeoutMs); + if (timeoutMs) { + config.timeoutMs = timeoutMs; + } + return config; +} + + +export function readFusionWebSearchConfig(value: unknown): VirtualModelFusionWebSearchConfig | undefined { + if (!isRecord(value)) { + return undefined; + } + const toolName = stringValue(value.toolName); + if (!toolName) { + return undefined; + } + const config: VirtualModelFusionWebSearchConfig = { + toolName, + env: isRecord(value.env) ? stringRecordFromUnknown(value.env) : undefined, + provider: parseFusionWebSearchProvider(value.provider) + }; + const resultCount = numberValue(value.resultCount); + if (resultCount) { + config.resultCount = resultCount; + } + const timeoutMs = numberValue(value.timeoutMs); + if (timeoutMs) { + config.timeoutMs = timeoutMs; + } + return config; +} + + +function resolveFusionVisionRuntime( + config: VirtualModelFusionVisionConfig +): { model?: string; providers: CoreGatewayProvider[] } { + const selector = config.modelSelector || config.model; + if (config.baseUrl) { + return { + model: config.model || config.modelSelector, + providers: [] + }; + } + + const parsed = parseFusionModelSelector(selector); + if (!parsed) { + return { + model: selector ? normalizeGatewayModelSelector(selector) : undefined, + providers: [] + }; + } + + return { + model: `${parsed.providerName}/${parsed.model}`, + providers: [] + }; +} + + +function parseFusionModelSelector(value: string | undefined): { model: string; providerName: string } | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const commaIndex = trimmed.indexOf(","); + if (commaIndex > 0 && commaIndex < trimmed.length - 1) { + const providerName = trimmed.slice(0, commaIndex).trim(); + const model = trimmed.slice(commaIndex + 1).trim(); + return providerName && model ? { model, providerName } : undefined; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex > 0 && slashIndex < trimmed.length - 1) { + const providerName = trimmed.slice(0, slashIndex).trim(); + const model = trimmed.slice(slashIndex + 1).trim(); + return providerName && model ? { model, providerName } : undefined; + } + return undefined; +} + + +function normalizeGatewayModelSelector(value: string): string { + const parsed = parseFusionModelSelector(value); + return parsed ? `${parsed.providerName}/${parsed.model}` : value.trim(); +} + + +function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSearchProvider | undefined { + const normalized = stringValue(value)?.toLowerCase(); + if ( + normalized === "brave" || + normalized === "bing" || + normalized === "google_cse" || + normalized === "serper" || + normalized === "serpapi" || + normalized === "tavily" || + normalized === "exa" || + normalized === "browser" + ) { + return normalized; + } + return undefined; +} + + +function stringRecordFromUnknown(value: Record): Record | undefined { + const result: Record = {}; + for (const [key, rawValue] of Object.entries(value)) { + const normalizedKey = key.trim(); + const normalizedValue = stringValue(rawValue); + if (normalizedKey && normalizedValue) { + result[normalizedKey] = normalizedValue; + } + } + return Object.keys(result).length ? result : undefined; +} + + +function sanitizeMcpServerName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "fusion"; +} diff --git a/packages/core/src/observability/raw-trace-sync.ts b/packages/core/src/observability/raw-trace-sync.ts new file mode 100644 index 00000000..481c5896 --- /dev/null +++ b/packages/core/src/observability/raw-trace-sync.ts @@ -0,0 +1,281 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import { readFileSync, rmSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { dirname, resolve as pathResolve, sep as pathSep } from "node:path"; +import type { AppConfig } from "@ccr/core/contracts/app"; +import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants"; +import { updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store"; +import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value"; +import { formatError, parseJsonObject, readHeader, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; +import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor"; +import { maxUsageCaptureBytes, rawTraceSyncHeader, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared"; +import type { RawTracePartText } from "@ccr/core/gateway/internal/shared"; + +type PendingRawTraceUpdate = RequestLogRawTraceUpdateInput & { receivedAt: number }; +const maxPendingRawTraceUpdates = 200; +const pendingRawTraceMaxAgeMs = 5 * 60 * 1000; + +export class RawTraceSynchronizer { + readonly token = randomUUID(); + private readonly pendingUpdates = new Map(); + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + if (request.method !== "POST") { + sendJson(response, 405, { error: { message: "Method not allowed." } }); + return; + } + if (readHeader(request.headers[rawTraceSyncHeader]) !== this.token) { + sendJson(response, 401, { error: { message: "Unauthorized raw trace sync." } }); + return; + } + + const manifest = parseJsonObject(await readRequestBody(request)); + const update = readRawTraceRequestLogUpdate(manifest); + cleanupRawTraceBundle(manifest); + if (!update) { + sendJson(response, 202, { applied: false, ok: true }); + return; + } + + const applied = await updateGatewayRequestLogFromRawTrace(update); + if (!applied) this.store(update); + sendJson(response, 200, { applied, ok: true }); + } + + take(requestId: string): RequestLogRawTraceUpdateInput | undefined { + const update = this.pendingUpdates.get(requestId); + if (!update) return undefined; + this.pendingUpdates.delete(requestId); + const { receivedAt: _receivedAt, ...input } = update; + return input; + } + + private store(update: RequestLogRawTraceUpdateInput): void { + this.prune(); + this.pendingUpdates.set(update.requestId, { ...update, receivedAt: Date.now() }); + while (this.pendingUpdates.size > maxPendingRawTraceUpdates) { + const oldestKey = this.pendingUpdates.keys().next().value; + if (!oldestKey) break; + this.pendingUpdates.delete(oldestKey); + } + } + + private prune(): void { + const cutoff = Date.now() - pendingRawTraceMaxAgeMs; + for (const [requestId, update] of this.pendingUpdates) { + if (update.receivedAt < cutoff) this.pendingUpdates.delete(requestId); + } + } +} + + +export function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string): Record { + const enabled = rawTraceEnabledFromEnv() && shouldRecordRequestLogs(config); + return { + deleteLocalAfterUpload: false, + enabled, + maxPartBytes: maxUsageCaptureBytes, + mode: "wire_raw", + spoolDir: RAW_TRACE_SPOOL_DIR, + sync: { + enabled, + endpoint: `${endpoint(config.gateway.host, config.gateway.port)}${rawTraceSyncPath}`, + headers: { + [rawTraceSyncHeader]: rawTraceSyncToken + }, + timeoutMs: 5000 + } + }; +} + + +export function shouldRecordRequestLogs(config: AppConfig): boolean { + return Boolean(config.observability?.requestLogs || config.observability?.agentAnalysis); +} + + +function rawTraceEnabledFromEnv(): boolean { + const value = (process.env.CCR_RAW_TRACE_ENABLED ?? process.env.CCR_RAW_TRACE ?? "").trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + + +export function readRawTraceRequestLogUpdate(manifest: Record): RequestLogRawTraceUpdateInput | undefined { + const requestId = stringValue(manifest.turnKey); + const parts = Array.isArray(manifest.parts) + ? manifest.parts.filter((part): part is Record => isRecord(part)) + : []; + if (!requestId || parts.length === 0) { + return undefined; + } + + const upstreamRequestMetadata = readRawTraceJsonPart(parts, "upstream_request_metadata"); + const upstreamResponseMetadata = readRawTraceJsonPart(parts, "upstream_response_metadata"); + const upstreamRequestBody = readRawTraceTextPart(parts, "upstream_request"); + const upstreamResponseStream = readRawTraceTextPart(parts, "response_stream"); + const upstreamResponseBody = upstreamResponseStream ?? readRawTraceTextPart(parts, "upstream_response"); + const target = isRecord(manifest.target) ? manifest.target : {}; + const rawUrl = stringValue(upstreamRequestMetadata?.url); + const url = sanitizeUrlForLog(rawUrl); + + return { + method: stringValue(upstreamRequestMetadata?.method) || "POST", + model: stringValue(target.model), + path: pathFromUrl(url), + provider: stringValue(target.providerName) || stringValue(target.provider), + requestBodyContentType: upstreamRequestBody?.contentType, + requestBodyText: upstreamRequestBody?.text, + requestHeaders: headerRecordFromUnknown(upstreamRequestMetadata?.headers), + requestId, + isStream: upstreamResponseStream !== undefined, + responseBodyContentType: upstreamResponseBody?.contentType, + responseBodyText: upstreamResponseBody?.text, + responseHeaders: headerRecordFromUnknown(upstreamResponseMetadata?.headers), + statusCode: numberValue(upstreamResponseMetadata?.statusCode), + url + }; +} + + +function readRawTraceJsonPart(parts: Record[], partType: string): Record | undefined { + const text = readRawTraceTextPart(parts, partType)?.text; + if (!text) { + return undefined; + } + try { + const parsed = JSON.parse(text) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + + +function readRawTraceTextPart(parts: Record[], partType: string): RawTracePartText | undefined { + const part = parts.find((candidate) => stringValue(candidate.partType) === partType); + const filePath = stringValue(part?.filePath); + if (!filePath || !isRawTraceSpoolFile(filePath)) { + return undefined; + } + try { + return { + contentType: stringValue(part?.contentType), + text: readFileSync(filePath, "utf8") + }; + } catch (error) { + console.warn(`[gateway] Failed to read raw trace part ${partType}: ${formatError(error)}`); + return undefined; + } +} + + +export function cleanupRawTraceBundle(manifest: Record): void { + const parts = Array.isArray(manifest.parts) + ? manifest.parts.filter((part): part is Record => isRecord(part)) + : []; + const firstFilePath = parts.map((part) => stringValue(part.filePath)).find((value): value is string => Boolean(value)); + if (!firstFilePath || !isRawTraceSpoolFile(firstFilePath)) { + return; + } + try { + rmSync(dirname(firstFilePath), { force: true, recursive: true }); + } catch (error) { + console.warn(`[gateway] Failed to clean raw trace bundle: ${formatError(error)}`); + } +} + + +function isRawTraceSpoolFile(filePath: string): boolean { + const spoolDir = pathResolve(RAW_TRACE_SPOOL_DIR); + const resolvedFile = pathResolve(filePath); + return dirname(resolvedFile) !== spoolDir && resolvedFile.startsWith(`${spoolDir}${pathSep}`); +} + + +function headerRecordFromUnknown(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const headers: Record = {}; + for (const [key, headerValue] of Object.entries(value)) { + if (headerValue === undefined || headerValue === null) { + continue; + } + headers[key] = Array.isArray(headerValue) + ? headerValue.map((item) => String(item)).join(", ") + : String(headerValue); + } + return headers; +} + + +function sanitizeUrlForLog(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + const url = new URL(value); + for (const key of [...url.searchParams.keys()]) { + if (isSensitiveQueryParam(key)) { + url.searchParams.set(key, "[redacted]"); + } + } + return url.toString(); + } catch { + return value; + } +} + + +function isSensitiveQueryParam(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized === "key" || normalized === "api_key" || normalized === "apikey" || normalized === "access_token"; +} + + +function pathFromUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + return new URL(value).pathname || undefined; + } catch { + return undefined; + } +} + + +export function createBodySampler() { + const chunks: Buffer[] = []; + let totalBytes = 0; + let truncated = false; + + return { + append(chunk: Buffer | string) { + if (truncated) { + return; + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (totalBytes + buffer.byteLength > maxUsageCaptureBytes) { + const remaining = Math.max(0, maxUsageCaptureBytes - totalBytes); + if (remaining > 0) { + chunks.push(buffer.subarray(0, remaining)); + totalBytes += remaining; + } + truncated = true; + return; + } + chunks.push(buffer); + totalBytes += buffer.byteLength; + }, + isTruncated() { + return truncated; + }, + read() { + return Buffer.concat(chunks, totalBytes).toString("utf8"); + } + }; +} diff --git a/packages/core/src/providers/credential-pool.ts b/packages/core/src/providers/credential-pool.ts new file mode 100644 index 00000000..301acf5d --- /dev/null +++ b/packages/core/src/providers/credential-pool.ts @@ -0,0 +1,112 @@ +import type { AppConfig, GatewayProviderConfig, ProviderCredentialConfig } from "@ccr/core/contracts/app"; +import { estimateLimitUsage, limitRules, readWindowCounter } from "@ccr/core/gateway/limits/window-limiter"; +import { + type ApiKeyLimitRule, + type ApiKeyLimitUsage, + type UpstreamAttempt +} from "@ccr/core/gateway/internal/shared"; +import { + findProviderByPublicOrInternalName, + findProviderCredentialByRuntimeId, + findProviderCredentialBySlug, + parseProviderCredentialInternalName, + providerCredentialRuntimeId +} from "@ccr/core/providers/runtime-topology"; + +const providerCredentialCooldownMs = 60_000; +const providerCredentialCooldowns = new Map(); + +export function providerCredentialLimitState( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + usage: ApiKeyLimitUsage +): { blocked: boolean; utilization: number } { + const rules = limitRules(credential.limits, usage); + if (rules.length === 0) return { blocked: false, utilization: 0 }; + + const now = Date.now(); + let blocked = false; + let utilization = 0; + for (const rule of rules) { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + const counter = readWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now); + blocked ||= counter.value + rule.requested > rule.limit; + utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit); + } + return { blocked, utilization }; +} + +export function recordProviderCredentialOutcome( + config: AppConfig, + method: string, + attempt: UpstreamAttempt, + statusCode: number, + responseHeaders: Headers +): void { + if (!attempt.logicalProvider || !attempt.credentialProtocol || !attempt.credentialChain?.length) return; + const provider = findProviderByPublicOrInternalName(config, attempt.logicalProvider); + if (!provider) return; + + const responseCredentialId = responseHeaders.get("x-ccr-provider-credential-id")?.trim(); + const responseCredential = responseCredentialId + ? findProviderCredentialByRuntimeId(provider, responseCredentialId) + : undefined; + const credential = responseCredential ?? providerCredentialFromInternalName(provider, attempt.credentialChain[0]); + if (!credential) return; + + if (statusCode >= 200 && statusCode < 500 && statusCode !== 401 && statusCode !== 403 && statusCode !== 429) { + incrementProviderCredentialCounters(provider, credential, estimateLimitUsage(method, attempt.body ?? Buffer.alloc(0))); + clearProviderCredentialCooldown(provider, credential); + return; + } + if (statusCode === 401 || statusCode === 403 || statusCode === 429 || statusCode >= 500) { + setProviderCredentialCooldown(provider, credential, providerCredentialCooldownMs, `HTTP ${statusCode}`); + } +} + +export function readProviderCredentialCooldown( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig +): { reason: string; until: number } | undefined { + const key = providerCredentialStateKey(provider, credential); + const cooldown = providerCredentialCooldowns.get(key); + if (!cooldown) return undefined; + if (cooldown.until > Date.now()) return cooldown; + providerCredentialCooldowns.delete(key); + return undefined; +} + +function providerCredentialFromInternalName(provider: GatewayProviderConfig, internalName: string | undefined): ProviderCredentialConfig | undefined { + const parsed = parseProviderCredentialInternalName(internalName); + return parsed ? findProviderCredentialBySlug(provider, parsed.credentialSlug) : undefined; +} + +function incrementProviderCredentialCounters(provider: GatewayProviderConfig, credential: ProviderCredentialConfig, usage: ApiKeyLimitUsage): void { + const rules = limitRules(credential.limits, usage); + const now = Date.now(); + for (const rule of rules) { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + readWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now).value += rule.requested; + } +} + +function providerCredentialCounterKey( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + rule: ApiKeyLimitRule, + windowStart: number +): string { + return ["provider-credential", provider.name, providerCredentialRuntimeId(provider, credential), rule.name, rule.metric, rule.windowMs, windowStart].join("|"); +} + +function setProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig, cooldownMs: number, reason: string): void { + providerCredentialCooldowns.set(providerCredentialStateKey(provider, credential), { reason, until: Date.now() + cooldownMs }); +} + +function clearProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): void { + providerCredentialCooldowns.delete(providerCredentialStateKey(provider, credential)); +} + +function providerCredentialStateKey(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): string { + return `${provider.name}::${providerCredentialRuntimeId(provider, credential)}`; +} diff --git a/packages/core/src/providers/oauth-plugin.ts b/packages/core/src/providers/oauth-plugin.ts new file mode 100644 index 00000000..37209087 --- /dev/null +++ b/packages/core/src/providers/oauth-plugin.ts @@ -0,0 +1,22 @@ +import { isRecord, stringValue } from "@ccr/core/gateway/internal/value"; + +export function isLocalClaudeCodeOauthProviderPlugin(value: unknown): value is Record { + if (!isRecord(value)) return false; + const key = stringValue(value.key)?.toLowerCase() ?? ""; + return key.startsWith("ccr-local-agent-") && key.includes("claude-code-oauth"); +} + +export function mergeAnthropicBetaValues(...values: Array): string { + const seen = new Set(); + const merged: string[] = []; + for (const value of values) { + for (const token of value?.split(",") ?? []) { + const normalized = token.trim(); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) continue; + seen.add(key); + merged.push(normalized); + } + } + return merged.join(","); +} diff --git a/packages/core/src/providers/runtime-topology.ts b/packages/core/src/providers/runtime-topology.ts new file mode 100644 index 00000000..338a1c38 --- /dev/null +++ b/packages/core/src/providers/runtime-topology.ts @@ -0,0 +1,487 @@ +/** + * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. + */ +import type { AppConfig, GatewayProviderCapability, GatewayProviderConfig, GatewayProviderProtocol, ProviderCredentialConfig } from "@ccr/core/contracts/app"; +import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index"; +import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "@ccr/core/providers/url"; +import { modelRegistryForConfig, parseProviderModelSelector, providerRuntimeId } from "@ccr/core/routing/model-registry"; +import { gatewayProviderProtocolFallbackOrder, type CoreGatewayProvider } from "@ccr/core/gateway/internal/shared"; + +export function providerCapabilityForClientProtocol( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): GatewayProviderCapability | undefined { + const capabilities = normalizedProviderCapabilities(provider); + for (const protocol of providerProtocolPreferenceForClient(clientProtocol)) { + const capability = capabilities.find((item) => item.type === protocol); + if (capability) { + return capability; + } + } + return undefined; +} + +export function providerProtocolForClientProtocol( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): GatewayProviderProtocol | undefined { + const capability = providerCapabilityForClientProtocol(provider, clientProtocol); + if (capability) { + return capability.type; + } + const directProtocol = + normalizeProviderProtocol(provider.type) ?? + normalizeProviderProtocol(provider.provider) ?? + inferProtocol(provider); + return providerProtocolPreferenceForClient(clientProtocol).includes(directProtocol) + ? directProtocol + : undefined; +} + +function providerProtocolPreferenceForClient(clientProtocol: GatewayProviderProtocol): GatewayProviderProtocol[] { + if (clientProtocol === "openai_responses") { + return ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_interactions"]; + } + if (clientProtocol === "anthropic_messages") { + return uniqueProviderProtocols([clientProtocol, ...gatewayProviderProtocolFallbackOrder]); + } + return [clientProtocol]; +} + +function uniqueProviderProtocols(protocols: GatewayProviderProtocol[]): GatewayProviderProtocol[] { + const seen = new Set(); + const output: GatewayProviderProtocol[] = []; + for (const protocol of protocols) { + if (seen.has(protocol)) { + continue; + } + seen.add(protocol); + output.push(protocol); + } + return output; +} + +export function findProviderByPublicOrInternalName(config: AppConfig, name: string): GatewayProviderConfig | undefined { + const normalized = name.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + const credentialInternalName = parseProviderCredentialInternalName(name); + if (credentialInternalName) { + const internalProviderId = credentialInternalName.providerId.toLowerCase(); + return config.Providers.find((provider) => + provider.name.trim().toLowerCase() === internalProviderId || + providerRuntimeId(provider).toLowerCase() === internalProviderId + ); + } + return modelRegistryForConfig(config).findProvider(normalized); +} + +export function activeProviderCredentials(provider: GatewayProviderConfig): ProviderCredentialConfig[] { + return (provider.credentials ?? []).filter((credential) => + credential.enabled !== false && + Boolean(providerCredentialApiKey(credential)) + ); +} + +export function providerCredentialPriority(credential: ProviderCredentialConfig, index: number): number { + return Number.isFinite(credential.priority) ? Number(credential.priority) : index + 1; +} + + +export function toCoreGatewayProviders(provider: GatewayProviderConfig): CoreGatewayProvider[] { + const capabilities = normalizedProviderCapabilities(provider); + if (capabilities.length === 0) { + return toCoreGatewayProvidersForCapability(provider); + } + + return capabilities + .flatMap((capability) => toCoreGatewayProvidersForCapability(provider, capability)) + .filter((item): item is CoreGatewayProvider => Boolean(item)); +} + + +function toCoreGatewayProvidersForCapability( + provider: GatewayProviderConfig, + capability?: GatewayProviderCapability +): CoreGatewayProvider[] { + const credentials = activeProviderCredentials(provider); + if (credentials.length === 0) { + const coreProvider = toCoreGatewayProvider(provider, capability); + return coreProvider ? [coreProvider] : []; + } + + return sortProviderCredentialsForConfig(credentials) + .map((credential) => toCoreGatewayProvider(provider, capability, credential)) + .filter((item): item is CoreGatewayProvider => Boolean(item)); +} + + +function toCoreGatewayProvider( + provider: GatewayProviderConfig, + capability?: GatewayProviderCapability, + credential?: ProviderCredentialConfig +): CoreGatewayProvider | undefined { + const type = + capability?.type ?? + normalizeProviderProtocol(provider.type) ?? + normalizeProviderProtocol(provider.provider) ?? + inferProtocol(provider); + const baseurl = normalizeProviderRuntimeBaseUrl(capability?.baseUrl ?? readBaseUrl(provider), type); + const apikey = credential ? providerCredentialApiKey(credential) : provider.apikey || provider.apiKey || provider.api_key; + + if (!provider.name || provider.models.length === 0) { + return undefined; + } + const safetyIssue = providerApiKeySafetyIssue({ + apiKey: apikey, + baseUrl: baseurl ?? "", + name: provider.name + }); + if (safetyIssue) { + throw new Error(safetyIssue.message); + } + + return { + apikey, + baseurl, + billing: provider.billing, + extraBody: provider.extraBody, + extraHeaders: provider.extraHeaders, + models: provider.models, + name: credential + ? providerCredentialInternalName(provider, type, credential) + : capability + ? providerCapabilityInternalName(provider, type) + : providerRuntimeId(provider), + type + }; +} + + +export function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[]): ProviderCredentialConfig[] { + return [...credentials].sort((left, right) => + providerCredentialPriority(left, 0) - providerCredentialPriority(right, 0) || + providerCredentialSortKey(left).localeCompare(providerCredentialSortKey(right)) + ); +} + + +export function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] { + const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : []; + const normalized: GatewayProviderCapability[] = []; + const byProtocol = new Map(); + for (const capability of capabilities) { + const type = normalizeProviderProtocol(capability.type); + const baseUrl = capability.baseUrl?.trim(); + if (!type || !baseUrl) { + continue; + } + const item = { + ...capability, + baseUrl, + type + }; + const existing = byProtocol.get(type); + if (!existing || providerCapabilityPriority(item) < providerCapabilityPriority(existing)) { + byProtocol.set(type, item); + } + } + for (const capability of capabilities) { + const type = normalizeProviderProtocol(capability.type); + const selected = type ? byProtocol.get(type) : undefined; + if (selected && !normalized.includes(selected)) { + normalized.push(selected); + } + } + return applyPresetProtocolLock(provider, normalized); +} + + +function applyPresetProtocolLock( + provider: GatewayProviderConfig, + capabilities: GatewayProviderCapability[] +): GatewayProviderCapability[] { + const lockedProtocols = lockedProviderPresetProtocols(provider, capabilities); + if (lockedProtocols.length === 0) { + return capabilities; + } + + const lockedProtocolSet = new Set(lockedProtocols); + const lockedCapabilities = capabilities.filter((capability) => lockedProtocolSet.has(capability.type)); + if (lockedCapabilities.length > 0) { + return lockedCapabilities; + } + + const lockedProtocol = lockedProtocols[0]; + const baseUrl = readBaseUrl(provider); + const normalizedBaseUrl = normalizeProviderRuntimeBaseUrl(baseUrl, lockedProtocol); + return normalizedBaseUrl + ? [{ baseUrl: normalizedBaseUrl, source: "preset", type: lockedProtocol }] + : []; +} + + +function lockedProviderPresetProtocols( + provider: GatewayProviderConfig, + capabilities: GatewayProviderCapability[] +): GatewayProviderProtocol[] { + const baseUrls = [ + readBaseUrl(provider), + ...capabilities.map((capability) => capability.baseUrl) + ].filter((value): value is string => Boolean(value?.trim())); + + for (const baseUrl of baseUrls) { + if (findProviderPresetByBaseUrl(baseUrl)?.id === "gemini") { + return ["gemini_generate_content", "gemini_interactions"]; + } + } + + return []; +} + + +function providerCapabilityPriority(capability: GatewayProviderCapability): number { + if (capability.source === "preset") { + return 0; + } + if (capability.source === "detected") { + return 2; + } + return 1; +} + + +export function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string { + return `${providerRuntimeId(provider)}::${protocol}`; +} + + +function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderProtocol): string { + return `${providerName}::${protocol}`; +} + + +export function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol, value: string): boolean { + const normalized = value.trim().toLowerCase(); + return providerCapabilityInternalName(provider, protocol).toLowerCase() === normalized || + providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized; +} + + +export function sanitizeHeaderValue(value: unknown): string { + // HTTP header values must be ByteString (code point <= 255). Values derived + // from user-facing names — model selectors like "小米mimo/...", provider + // names, route reasons — can contain non-ASCII characters that crash Node's + // fetch/undici with "Cannot convert argument to a ByteString" (surfaced as + // 502). Normalize to ASCII while preserving case and printable punctuation. + const text = typeof value === "string" && value.trim() ? value : "unknown"; + const sanitized = text + .replace(/[^\x20-\x7E]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized || "unknown"; +} + + +export function providerCredentialInternalName( + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol, + credential: ProviderCredentialConfig +): string { + return `${providerCapabilityInternalName(provider, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`; +} + + +export function parseProviderCredentialInternalName(value: string | undefined): { + credentialSlug: string; + providerId: string; + protocol: GatewayProviderProtocol; +} | undefined { + const marker = "::cred:"; + const markerIndex = value?.lastIndexOf(marker) ?? -1; + if (!value || markerIndex <= 0) { + return undefined; + } + const baseName = value.slice(0, markerIndex); + const credentialSlug = value.slice(markerIndex + marker.length).trim(); + const protocolSeparator = baseName.lastIndexOf("::"); + if (!credentialSlug || protocolSeparator <= 0) { + return undefined; + } + const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2)); + const providerId = baseName.slice(0, protocolSeparator).trim(); + return protocol && providerId ? { credentialSlug, providerId, protocol } : undefined; +} + + +export function providerCredentialSlug(value: string | undefined): string { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") || "key"; +} + + +export function providerCredentialRuntimeId( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + index = provider.credentials?.indexOf(credential) ?? -1 +): string { + const explicitId = credential.id?.trim(); + if (explicitId) { + return explicitId; + } + const oneBasedIndex = index >= 0 ? index + 1 : 1; + const label = credential.name?.trim() || credential.label?.trim(); + return label ? `${providerCredentialSlug(label)}-${oneBasedIndex}` : `key-${oneBasedIndex}`; +} + + +function providerCredentialSortKey(credential: ProviderCredentialConfig): string { + return providerCredentialSlug(credential.id || credential.name || credential.label); +} + + +export function providerCredentialApiKey(credential: ProviderCredentialConfig): string { + return credential.api_key || credential.apiKey || credential.apikey || ""; +} + + +export function findProviderCredentialByRuntimeId( + provider: GatewayProviderConfig, + credentialId: string +): ProviderCredentialConfig | undefined { + const normalizedId = credentialId.trim(); + const normalizedSlug = providerCredentialSlug(normalizedId); + return (provider.credentials ?? []).find((credential, index) => { + const runtimeId = providerCredentialRuntimeId(provider, credential, index); + return runtimeId === normalizedId || providerCredentialSlug(runtimeId) === normalizedSlug || credential.id?.trim() === normalizedId; + }); +} + + +export function findProviderCredentialBySlug( + provider: GatewayProviderConfig, + credentialSlug: string +): ProviderCredentialConfig | undefined { + const normalizedSlug = providerCredentialSlug(credentialSlug); + return (provider.credentials ?? []).find((credential, index) => providerCredentialSlug(providerCredentialRuntimeId(provider, credential, index)) === normalizedSlug); +} + + +export function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "openai" || normalized === "openai_responses") { + return "openai_responses"; + } + if (normalized === "openai_chat" || normalized === "openai_chat_completions") { + return "openai_chat_completions"; + } + if (normalized === "anthropic" || normalized === "anthropic_messages") { + return "anthropic_messages"; + } + if (normalized === "gemini" || normalized === "gemini_generate_content") { + return "gemini_generate_content"; + } + if ( + normalized === "gemini_interactions" || + normalized === "gemini-interactions" || + normalized === "google_interactions" || + normalized === "google-interactions" || + normalized === "interactions" || + normalized === "interaction" + ) { + return "gemini_interactions"; + } + return undefined; +} + + +export function inferProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol { + const url = readBaseUrl(provider)?.toLowerCase() ?? ""; + const transformerNames = JSON.stringify(provider.transformer ?? "").toLowerCase(); + if (url.includes("/interactions") || transformerNames.includes("gemini_interactions")) { + return "gemini_interactions"; + } + if (url.includes("generativelanguage.googleapis.com") || transformerNames.includes("gemini")) { + return "gemini_generate_content"; + } + if (url.includes("anthropic") || transformerNames.includes("anthropic")) { + return "anthropic_messages"; + } + return "openai_chat_completions"; +} + + +export function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | undefined): GatewayProviderProtocol | undefined { + const ccrProtocol = normalizeProviderProtocol(headers.get("x-ccr-provider-protocol")); + if (ccrProtocol) { + return ccrProtocol; + } + const providerName = + headers.get("x-gateway-target-provider-name")?.trim() || + headers.get("x-gateway-target-provider")?.trim(); + if (!providerName) { + return undefined; + } + const credentialInternalName = parseProviderCredentialInternalName(providerName); + if (credentialInternalName) { + return credentialInternalName.protocol; + } + const provider = config ? findProviderByPublicOrInternalName(config, providerName) : undefined; + if (!provider) { + return normalizeProviderProtocol(providerName); + } + const capability = normalizedProviderCapabilities(provider).find((item) => + providerCapabilityNameMatches(provider, item.type, providerName) + ); + if (capability) { + return capability.type; + } + return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider); +} + + +export function resolveProviderLogName(headers: Headers, config: AppConfig | undefined, fallbackModel?: string): string | undefined { + const providerSelector = + headers.get("x-gateway-target-provider-name")?.trim() || + headers.get("x-gateway-target-provider")?.trim(); + const headerProvider = providerSelector && config + ? findProviderByPublicOrInternalName(config, providerSelector) + : undefined; + if (headerProvider) { + return headerProvider.name; + } + + const routeProvider = parseProviderModelSelector(fallbackModel)?.provider; + const modelProvider = routeProvider && config + ? findProviderByPublicOrInternalName(config, routeProvider) + : undefined; + return modelProvider?.name; +} + + +function providerMatchesName(provider: GatewayProviderConfig, name: string): boolean { + const normalizedName = name.trim().toLowerCase(); + return [provider.id, provider.name, provider.provider] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .some((value) => value.trim().toLowerCase() === normalizedName); +} + + +function normalizeProviderRuntimeBaseUrl(value: string | undefined, type: GatewayProviderProtocol): string | undefined { + if (!value) { + return undefined; + } + return normalizeProviderBaseUrlInput(value, type) || undefined; +} + + +function readBaseUrl(provider: GatewayProviderConfig): string | undefined { + return provider.baseurl || provider.baseUrl || provider.api_base_url; +} diff --git a/packages/core/src/routing/model-resolution.ts b/packages/core/src/routing/model-resolution.ts new file mode 100644 index 00000000..92b0dfca --- /dev/null +++ b/packages/core/src/routing/model-resolution.ts @@ -0,0 +1,16 @@ +import type { AppConfig, GatewayProviderConfig } from "@ccr/core/contracts/app"; +import { modelRegistryForConfig } from "@ccr/core/routing/model-registry"; + +export function resolveConfiguredProviderModelSelector( + value: string | undefined, + config: AppConfig +): { model: string; provider: GatewayProviderConfig } | undefined { + return modelRegistryForConfig(config).resolveProviderModel(value); +} + +export function resolveUniqueConfiguredProviderModelSelector( + value: string | undefined, + config: AppConfig +): { model: string; provider: GatewayProviderConfig } | undefined { + return modelRegistryForConfig(config).resolveUniqueProviderModel(value); +} diff --git a/packages/core/src/routing/protocol-endpoints.ts b/packages/core/src/routing/protocol-endpoints.ts new file mode 100644 index 00000000..3cbb602f --- /dev/null +++ b/packages/core/src/routing/protocol-endpoints.ts @@ -0,0 +1,32 @@ +import type { GatewayProviderProtocol } from "@ccr/core/contracts/app"; + +export function requestProtocolForPath(path: string): GatewayProviderProtocol | undefined { + const normalized = path.toLowerCase(); + if (normalized === "/v1/messages" || normalized === "/messages" || normalized.endsWith("/v1/messages")) { + return "anthropic_messages"; + } + if (normalized === "/v1/chat/completions" || normalized === "/chat/completions" || normalized.endsWith("/chat/completions")) { + return "openai_chat_completions"; + } + if (normalized === "/v1/responses" || normalized === "/responses" || normalized.endsWith("/responses")) { + return "openai_responses"; + } + if (/\/v1(?:beta)?\/models\/[^/]+:(?:generatecontent|streamgeneratecontent)$/i.test(normalized)) { + return "gemini_generate_content"; + } + if (/\/v1(?:beta)?\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i.test(normalized)) { + return "gemini_interactions"; + } + return undefined; +} + +export function shouldApplyGatewayRouting(method: string, path: string): boolean { + if (method.toUpperCase() !== "POST") { + return false; + } + const protocol = requestProtocolForPath(path); + if (protocol === "gemini_interactions") { + return /\/v1(?:beta)?\/interactions$/i.test(path); + } + return Boolean(protocol); +} diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index cf3f7870..e7ab2fb8 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -10,6 +10,7 @@ import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/c import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; +import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; import { detectProviderIcon } from "@ccr/core/providers/icons"; @@ -481,9 +482,11 @@ function logProfileApplyResult(result: ProfileApplyResult): void { } function getCliAppInfo(): AppInfo { + const chatgptAppPath = findInstalledCodexAppExecutable().executable; return { appConfigDbFile: APP_CONFIG_DB_FILE, apiKeysDbFile: API_KEYS_DB_FILE, + ...(chatgptAppPath ? { chatgptAppPath } : {}), configDir: CONFIGDIR, configFile: CONFIG_FILE, dataDir: DATADIR, diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 062f754d..ec9386ef 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -9,6 +9,7 @@ import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/ import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-qr-window-service"; import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; +import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; import { deepLinkService } from "./deep-link"; @@ -56,9 +57,11 @@ const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; const imageExportTargets = new Map(); ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { + const chatgptAppPath = findInstalledCodexAppExecutable().executable; return { appConfigDbFile: APP_CONFIG_DB_FILE, apiKeysDbFile: API_KEYS_DB_FILE, + ...(chatgptAppPath ? { chatgptAppPath } : {}), configDir: CONFIGDIR, configFile: CONFIG_FILE, dataDir: DATADIR, diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 6be6f106..bbd9b55c 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -24,7 +24,7 @@ import { OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft, providerCredentialsFromDraft, persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, - probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, + probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileDraftWithDetectedAppPath, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, @@ -208,6 +208,7 @@ function App() { const [profileDraft, setProfileDraft] = useState(() => createProfileDraft()); const [profileEditDraft, setProfileEditDraft] = useState(() => createProfileDraft()); const [profileEditIndex, setProfileEditIndex] = useState(); + const [profileDeleteIndex, setProfileDeleteIndex] = useState(); const [profileOpenDialog, setProfileOpenDialog] = useState(); const [profileActionBusy, setProfileActionBusy] = useState(); const [profileRuntimeStatus, setProfileRuntimeStatus] = useState({ profiles: [] }); @@ -362,6 +363,14 @@ function App() { }; }, []); + useEffect(() => { + if (!appInfo.chatgptAppPath) { + return; + } + setProfileDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath)); + setProfileEditDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath)); + }, [appInfo.chatgptAppPath]); + useEffect(() => { if (!window.ccr) { return; @@ -637,6 +646,7 @@ function App() { const dirty = draftConfig !== savedConfig; const apiKeys = useMemo(() => createApiKeyList(draftConfig), [draftConfig.APIKEY, draftConfig.APIKEYS]); const apiKeyEditItem = apiKeyEditIndex === undefined ? undefined : apiKeys.find((apiKey) => apiKey.index === apiKeyEditIndex); + const profileDeleteItem = profileDeleteIndex === undefined ? undefined : draftConfig.profile.profiles[profileDeleteIndex]; const providerDeleteItem = providerDeleteIndex === undefined ? undefined : draftConfig.Providers[providerDeleteIndex]; const routingDeleteRule = routingDeleteIndex === undefined ? undefined : draftConfig.Router.rules[routingDeleteIndex]; const extensionDeleteItem = useMemo(() => { @@ -763,7 +773,10 @@ function App() { onboardingProfileDraftSource.current = source; setProfileAgentTab(profile.agent); - setProfileDraft(createProfileDraftFromProfile(profile, draftConfig.botConfigs)); + setProfileDraft(profileDraftWithDetectedAppPath( + createProfileDraftFromProfile(profile, draftConfig.botConfigs), + appInfo.chatgptAppPath + )); setProfileActionError(""); }, [activeView, onboardingStep, onboardingProfileConfirmed, configLoaded, draftConfig.profile.profiles, draftConfig.botConfigs, profileDraft.agent]); @@ -2390,7 +2403,7 @@ function App() { function openAddProfileDialog(agent: ProfileConfig["agent"] = profileAgentTab) { setProfileAgentTab(agent); - setProfileDraft(createProfileDraft(agent)); + setProfileDraft(profileDraftWithDetectedAppPath(createProfileDraft(agent), appInfo.chatgptAppPath)); setProfileActionError(""); setProfileAddOpen(true); } @@ -2401,7 +2414,10 @@ function App() { return; } setProfileEditIndex(index); - setProfileEditDraft(createProfileDraftFromProfile(profile, draftConfig.botConfigs)); + setProfileEditDraft(profileDraftWithDetectedAppPath( + createProfileDraftFromProfile(profile, draftConfig.botConfigs), + appInfo.chatgptAppPath + )); setProfileActionError(""); } @@ -2623,10 +2639,10 @@ function App() { const next = { ...current, ...patch }; if (patch.agent && patch.agent !== current.agent) { const name = current.name === profileAgentLabel(current.agent) ? undefined : next.name; - return { + return profileDraftWithDetectedAppPath({ ...createProfileDraft(patch.agent, name), envRows: profileEnvRowsForAgent(patch.agent, current.envRows) - }; + }, appInfo.chatgptAppPath); } return next; }); @@ -2638,10 +2654,10 @@ function App() { const next = { ...current, ...patch }; if (patch.agent && patch.agent !== current.agent) { const name = current.name === profileAgentLabel(current.agent) ? undefined : next.name; - return { + return profileDraftWithDetectedAppPath({ ...createProfileDraft(patch.agent, name), envRows: profileEnvRowsForAgent(patch.agent, current.envRows) - }; + }, appInfo.chatgptAppPath); } return next; }); @@ -2769,6 +2785,14 @@ function App() { })); } + function confirmProfileDelete() { + if (profileDeleteIndex === undefined) { + return; + } + removeProfile(profileDeleteIndex); + setProfileDeleteIndex(undefined); + } + return ( @@ -2894,7 +2918,7 @@ function App() { openProfileApp: (index) => void openProfileAppFromList(index), profileActionBusy, profileRuntimeStatus, - removeProfile, + removeProfile: setProfileDeleteIndex, stopProfileApp: (index) => void stopProfileAppFromList(index), updateProfileItem }, @@ -3032,6 +3056,11 @@ function App() { virtualModelProfiles: draftConfig.virtualModelProfiles ?? [], onSubmit: submitProfileDraft } : undefined} + profileDelete={profileDeleteItem ? { + onClose: () => setProfileDeleteIndex(undefined), + onConfirm: confirmProfileDelete, + profile: profileDeleteItem + } : undefined} profileEdit={profileEditIndex !== undefined ? { botConfigs: draftConfig.botConfigs, canSubmit: canSubmitProfileEdit, diff --git a/packages/ui/src/pages/home/components/dialog-stack.tsx b/packages/ui/src/pages/home/components/dialog-stack.tsx index cc33a3fd..b802133d 100644 --- a/packages/ui/src/pages/home/components/dialog-stack.tsx +++ b/packages/ui/src/pages/home/components/dialog-stack.tsx @@ -2,7 +2,7 @@ import type { ComponentProps, ReactElement } from "react"; import { AnimatePresence, DialogStackLayer } from "../shared/index"; import { AddApiKeyDialog, ApiKeyCreatedDialog, EditApiKeyDialog } from "./api-keys"; import { ConfigureClaudeDesignDialog, DeleteExtensionDialog, PluginSettingsDialog } from "./extensions"; -import { AddProfileDialog, ProfileOpenDialog } from "./profiles"; +import { AddProfileDialog, DeleteProfileDialog, ProfileOpenDialog } from "./profiles"; import { AddProviderDialog, DeleteProviderDialog, ProviderDeepLinkDialog } from "./providers"; import { AddRoutingRuleDialog, DeleteRoutingRuleDialog } from "./routing"; import { AppSettingsDialog } from "./settings"; @@ -19,6 +19,7 @@ export function AppDialogStack({ extensionInstall, extensionSettings, profileAdd, + profileDelete, profileEdit, profileOpen, providerDeepLink, @@ -39,6 +40,7 @@ export function AppDialogStack({ extensionInstall?: ComponentProps; extensionSettings?: ComponentProps; profileAdd?: ComponentProps; + profileDelete?: ComponentProps; profileEdit?: ComponentProps; profileOpen?: ComponentProps; providerDeepLink?: ComponentProps; @@ -56,6 +58,7 @@ export function AppDialogStack({ profileAdd ? { key: "profile-add", node: } : null, profileEdit ? { key: "profile-edit", node: } : null, profileOpen ? { key: "profile-open", node: } : null, + profileDelete ? { key: "profile-delete", node: } : null, apiKeyEdit ? { key: "api-key-edit", node: } : null, providerDeepLink ? { key: "provider-deep-link", node: } : null, providerUpsert ? { key: "provider-upsert", node: } : null, diff --git a/packages/ui/src/pages/home/components/index.ts b/packages/ui/src/pages/home/components/index.ts index 4bb5f887..c2a188d2 100644 --- a/packages/ui/src/pages/home/components/index.ts +++ b/packages/ui/src/pages/home/components/index.ts @@ -6,7 +6,7 @@ export { AppSettingsDialog } from "./settings"; export { UpdateDialog } from "./update"; export { OverviewView, AgentAnalysisView } from "./dashboard"; export { ApiKeysView, AddApiKeyDialog, EditApiKeyDialog } from "./api-keys"; -export { ProfileView, AddProfileForm, AddProfileDialog } from "./profiles"; +export { ProfileView, AddProfileForm, AddProfileDialog, DeleteProfileDialog } from "./profiles"; export { NetworkingView, LogsView } from "./network-logs"; export { ProvidersView, ModelsView, DeleteProviderDialog, ProviderDeepLinkDialog, AddProviderForm, AddProviderDialog } from "./providers"; export { RoutingView, DeleteRoutingRuleDialog, AddRoutingRuleDialog } from "./routing"; diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 424429ac..1d96c70e 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -1,6 +1,6 @@ import { AddProfileDraft, AgentLogo, AnimatedIconSwap, AnimatedPopover, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, BotHandoffScanTarget, Button, - Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy, + Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, CircleAlert, Copy, cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion, normalizeProfileScope, normalizeProfileSurface, Pencil, Plus, PopoverContent, @@ -176,6 +176,63 @@ export function ProfileView({ ); } +export function DeleteProfileDialog({ + onClose, + onConfirm, + profile +}: { + onClose: () => void; + onConfirm: () => void; + profile: ProfileConfig; +}) { + const t = useAppText(); + const name = profile.name || t("Unnamed"); + const agent = t(profileAgentLabel(profile.agent)); + + return ( + !open && onClose()}> + + +
+ {t("Delete Profile")} +
+ +
+ + +
+
+ + {t("Delete this agent profile from the configuration?")} +
+
+
+ {t("Name")}: {name} +
+
+ {t("Agent")}: {agent} +
+
{t("This action is applied immediately to the draft config and will auto-save with other changes.")}
+
+
+
+ + + + + +
+
+ ); +} + export function ProfileOpenDialog({ appRunning = false, busy, diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 129e9434..78178ee9 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -767,10 +767,12 @@ export const appCopy: Record = { "Delete": "删除", "Delete bot": "删除 Bot", "Delete Extension": "删除扩展", + "Delete Profile": "删除 Agent 配置", "Delete Provider": "删除供应商", "Delete Routing Rule": "删除路由规则", "Delete this bot?": "删除这个 Bot?", "Delete this extension from the configuration?": "从配置中删除这个扩展?", + "Delete this agent profile from the configuration?": "从配置中删除这个 Agent 配置档案?", "Delete this provider from the configuration?": "从配置中删除这个供应商?", "Delete this routing rule from the configuration?": "从配置中删除这条路由规则?", "Dependencies": "依赖", diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 346d1c6c..f08ae551 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -756,6 +756,14 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code" }; } +export function profileDraftWithDetectedAppPath(draft: AddProfileDraft, chatgptAppPath?: string): AddProfileDraft { + const detectedPath = chatgptAppPath?.trim() || ""; + if (draft.agent !== "codex" || draft.appPath.trim() || !detectedPath) { + return draft; + } + return { ...draft, appPath: detectedPath }; +} + export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs: BotGatewaySavedConfig[] = []): AddProfileDraft { const botDraft = createBotGatewayDraft(profile.botGateway); const botConfigId = profile.botConfigId || matchingBotConfigId(profile.botGateway, botConfigs); diff --git a/tests/main/gateway-service-architecture.test.mjs b/tests/main/gateway-service-architecture.test.mjs new file mode 100644 index 00000000..9bd160b7 --- /dev/null +++ b/tests/main/gateway-service-architecture.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const gatewayRoot = path.join(process.cwd(), "packages", "core", "src", "gateway"); + +function typescriptFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { + return typescriptFiles(file); + } + return entry.isFile() && entry.name.endsWith(".ts") ? [file] : []; + }); +} + +test("gateway service remains a compatibility facade", () => { + const serviceFile = path.join(gatewayRoot, "service.ts"); + const source = readFileSync(serviceFile, "utf8"); + const implementationLines = source + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("*") && !line.startsWith("/*")); + + assert.ok(implementationLines.length <= 20); + assert.doesNotMatch(source, /class\s+GatewayService/); + assert.doesNotMatch(source, /node:http/); + assert.match(source, /gateway\/application\/gateway-service/); + assert.match(source, /routing\/protocol-endpoints/); +}); + +test("gateway implementation modules do not depend on the public facade", () => { + const serviceFile = path.join(gatewayRoot, "service.ts"); + const reverseDependencies = typescriptFiles(gatewayRoot) + .filter((file) => file !== serviceFile) + .filter((file) => /(?:@ccr\/core|\.\.)\/gateway\/service/.test(readFileSync(file, "utf8"))); + + assert.deepEqual(reverseDependencies, []); +}); + +test("core config compilation is separated from filesystem persistence", () => { + const compiler = readFileSync( + path.join(gatewayRoot, "core-runtime", "config-compiler.ts"), + "utf8" + ); + const writer = readFileSync( + path.join(gatewayRoot, "core-runtime", "config-writer.ts"), + "utf8" + ); + + assert.doesNotMatch(compiler, /node:fs|writeFileSync|mkdirSync/); + assert.match(writer, /compileCoreGatewayConfig/); + assert.match(writer, /writeFileSync/); +}); diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index fcaad7c6..3602da67 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -9,7 +9,17 @@ import { function createRouterPlugin(options = {}) { const agent = options.agent ?? "claude-code"; - return new ClaudeCodeRouterPlugin({ + const profiles = options.profiles ?? [ + { + agent, + enabled: options.profileEnabled ?? true, + id: `${agent}-profile`, + model: options.profileModel ?? "", + name: agent, + scope: "global" + } + ]; + const plugin = new ClaudeCodeRouterPlugin({ CUSTOM_ROUTER_PATH: "", Providers: options.providers ?? [ { @@ -30,20 +40,22 @@ function createRouterPlugin(options = {}) { }, profile: { enabled: options.profileRuntimeEnabled ?? true, - profiles: [ - { - agent, - enabled: options.profileEnabled ?? true, - id: `${agent}-profile`, - model: options.profileModel ?? "", - name: agent, - scope: "global" - } - ] + profiles }, toolHub: options.toolHub, virtualModelProfiles: options.virtualModelProfiles ?? [] }); + return { + routeRequest(input) { + if (options.authenticatedProfileId !== null && input.headers["x-auth-api-key-id"] === undefined) { + const authenticatedProfileId = options.authenticatedProfileId ?? profiles[0]?.id; + if (authenticatedProfileId) { + input.headers["x-auth-api-key-id"] = `profile:${authenticatedProfileId}`; + } + } + return plugin.routeRequest(input); + } + }; } test("fallback retry delay backs off retryable HTTP statuses", () => { @@ -274,6 +286,79 @@ test("built-in Claude Code route matches user-agent case-insensitively", async ( assert.equal(result.decision.reason, "builtin:claude-code"); }); +test("built-in Codex route uses the authenticated profile instead of the first Codex profile", async () => { + const plugin = createRouterPlugin({ + agent: "codex", + authenticatedProfileId: "bs-2", + profiles: [ + { + agent: "codex", + enabled: true, + id: "codex", + model: "Codex API/gpt-5.6-sol", + name: "Codex", + scope: "ccr" + }, + { + agent: "codex", + enabled: true, + id: "bs-2", + model: "uuroute/gpt-5.5", + name: "bs", + scope: "ccr" + } + ], + providers: [ + { + models: ["gpt-5.6-sol"], + name: "Codex API", + type: "openai_responses" + }, + { + models: ["gpt-5.5"], + name: "uuroute", + type: "openai_responses" + } + ] + }); + const result = await plugin.routeRequest({ + body: { + model: "gpt-5" + }, + headers: { + "user-agent": "Codex Desktop/0.144.0" + }, + method: "POST", + url: "/v1/responses" + }); + + assert.equal(result.body.model, "uuroute/gpt-5.5"); + assert.equal(result.decision.model, "uuroute/gpt-5.5"); + assert.equal(result.decision.reason, "builtin:codex"); +}); + +test("built-in Codex route preserves the requested model when the authenticated profile does not match", async () => { + const plugin = createRouterPlugin({ + agent: "codex", + authenticatedProfileId: "missing-profile", + profileModel: "Provider/gpt-5-codex" + }); + const result = await plugin.routeRequest({ + body: { + model: "Provider/gpt-5-codex" + }, + headers: { + "user-agent": "Codex Desktop/0.144.0" + }, + method: "POST", + url: "/v1/responses" + }); + + assert.equal(result.body.model, "Provider/gpt-5-codex"); + assert.equal(result.decision.model, "Provider/gpt-5-codex"); + assert.equal(result.decision.reason, "default"); +}); + test("built-in Claude Code route does not inject Claude Code native tool search", async () => { const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet", @@ -808,6 +893,7 @@ test("issue 1480 raw user config reproduces the old failure precondition when Ro } }); const headers = { + "x-auth-api-key-id": "profile:claude-code-main", "user-agent": "claude-cli/2.1.187 (external, cli)" }; const result = await plugin.routeRequest({ @@ -831,6 +917,7 @@ test("issue 1480 raw user config ignores an unreplaced Provider/model subagent p const config = createIssue1480UserConfig(); const plugin = new ClaudeCodeRouterPlugin(config); const headers = { + "x-auth-api-key-id": "profile:claude-code-main", "user-agent": "claude-cli/2.1.187 (external, cli)" }; const result = await plugin.routeRequest({ @@ -856,6 +943,7 @@ test("issue 1480 config routes Claude Code profile traffic through the user defa const config = createIssue1480RouterConfig(); const plugin = new ClaudeCodeRouterPlugin(config); const headers = { + "x-auth-api-key-id": "profile:claude-code-main", "user-agent": "claude-cli/2.1.187 (external, cli)" }; const result = await plugin.routeRequest({ diff --git a/tests/renderer/profiles.test.tsx b/tests/renderer/profiles.test.tsx new file mode 100644 index 00000000..96d66660 --- /dev/null +++ b/tests/renderer/profiles.test.tsx @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ProfileConfig } from "../../packages/core/src/contracts/app.ts"; +import { DeleteProfileDialog } from "../../packages/ui/src/pages/home/components/profiles.tsx"; +import { AppI18nContext, appCopy } from "../../packages/ui/src/pages/home/shared/i18n.tsx"; +import { createProfileDraft, profileDraftWithDetectedAppPath } from "../../packages/ui/src/pages/home/shared/profiles.ts"; + +const profile: ProfileConfig = { + agent: "claude-code", + enabled: true, + id: "claude-code-main", + model: "openai/gpt-5.2", + name: "Claude Code Main" +}; + +test("DeleteProfileDialog identifies the profile and requires an explicit confirmation", () => { + const html = renderToStaticMarkup( + undefined} onConfirm={() => undefined} profile={profile} /> + ); + + assert.match(html, /Delete Profile/); + assert.match(html, /Delete this agent profile from the configuration\?/); + assert.match(html, /Claude Code Main/); + assert.match(html, /Claude Code/); + assert.match(html, />Cancel<\/button>/); + assert.match(html, />Delete<\/button>/); +}); + +test("DeleteProfileDialog renders the Chinese confirmation copy", () => { + const html = renderToStaticMarkup( + + undefined} onConfirm={() => undefined} profile={profile} /> + + ); + + assert.match(html, /删除 Agent 配置/); + assert.match(html, /从配置中删除这个 Agent 配置档案?/); + assert.match(html, />取消<\/button>/); + assert.match(html, />删除<\/button>/); +}); + +test("detected CHATGPT_APP_PATH is used as the Codex profile default", () => { + const detectedPath = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT"; + const draft = profileDraftWithDetectedAppPath(createProfileDraft("codex"), ` ${detectedPath} `); + + assert.equal(draft.appPath, detectedPath); + assert.equal(profileDraftWithDetectedAppPath({ ...draft, appPath: "/custom/chatgpt" }, detectedPath).appPath, "/custom/chatgpt"); + assert.equal(profileDraftWithDetectedAppPath(createProfileDraft("claude-code"), detectedPath).appPath, ""); +}); From a8bfa6363e0a773d1a5b3f7a1d670dc64afaad01 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 20:02:45 +0800 Subject: [PATCH 18/38] Improve router configuration and request handling --- README.md | 8 +- README_zh.md | 8 +- build/dev.mjs | 89 +++- .../content/docs/en/configuration/profiles.md | 26 +- docs/src/content/docs/en/guides.md | 8 +- .../content/docs/en/guides/agent-profile.md | 8 +- .../content/docs/zh/configuration/profile.md | 26 +- docs/src/content/docs/zh/guides.md | 8 +- .../content/docs/zh/guides/agent-profile.md | 8 +- packages/cli/src/cli.ts | 427 +++++++++++++----- .../core/src/agents/local-providers/grok.ts | 17 +- packages/core/src/config/config.ts | 19 + packages/core/src/contracts/app.ts | 4 +- .../gateway/application/gateway-service.ts | 7 +- .../src/gateway/claude-code-router-plugin.ts | 56 ++- .../gateway/core-runtime/config-compiler.ts | 17 +- .../src/observability/request-log-store.ts | 24 +- packages/core/src/profiles/launch-core.ts | 40 ++ packages/core/src/profiles/launch-service.ts | 11 +- packages/core/src/profiles/service.ts | 247 +++++++++- .../core/src/providers/runtime-topology.ts | 3 + .../ui/src/pages/home/components/profiles.tsx | 21 +- packages/ui/src/pages/home/shared/i18n.tsx | 2 + packages/ui/src/pages/home/shared/options.ts | 2 + packages/ui/src/pages/home/shared/profiles.ts | 47 +- packages/ui/src/pages/home/shared/routing.ts | 13 +- packages/ui/src/pages/home/shared/usage.ts | 5 +- tests/main/local-agent-provider-grok.test.mjs | 2 + tests/main/profile-launch-core.test.mjs | 27 +- tests/main/profile-service.test.mjs | 107 ++++- tests/main/request-log-store.test.mjs | 67 +++ tests/main/router-builtins.test.mjs | 92 ++++ tests/renderer/profiles.test.tsx | 26 +- tests/renderer/routing.test.ts | 69 +++ 34 files changed, 1357 insertions(+), 184 deletions(-) create mode 100644 tests/renderer/routing.test.ts diff --git a/README.md b/README.md index 09f87a76..966da243 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@
-Claude Code Router Desktop is a local control plane for coding agents. It gives Claude Code, Codex, ZCode, and compatible API clients one stable local endpoint, then lets you decide which provider, model, routing policy, tool stack, and account should handle each request. +Claude Code Router Desktop is a local control plane for coding agents. It gives Claude Code, Codex, Grok CLI, ZCode, and compatible API clients one stable local endpoint, then lets you decide which provider, model, routing policy, tool stack, and account should handle each request. Instead of wiring every agent to every model service by hand, CCR centralizes the model layer on your own machine: provider presets, custom endpoints, credential pools, fallback chains, Fusion-enhanced models, MCP tools, request logs, account usage, and desktop launch profiles all live in one app. @@ -53,7 +53,7 @@ Instead of wiring every agent to every model service by hand, CCR centralizes th | Goal | CCR gives you | | --- | --- | -| Keep the same agent workflow while switching models | Local profiles for Claude Code, Codex, and ZCode, with CLI/app launch entries and per-profile model selection | +| Keep the same agent workflow while switching models | Local profiles for Claude Code, Codex, Grok CLI, and ZCode, with CLI/app launch entries and per-profile model selection | | Try many providers without rebuilding config every time | Built-in provider presets, custom OpenAI/Anthropic/Gemini-compatible endpoints, protocol probing, model discovery, and connectivity checks | | Make routing a runtime policy | Built-in agent routing, conditional rules, request rewrites, model-prefix routing, retries, and fallback model chains | | Control cost and quota pressure | Credential pools, key rotation, local usage limits, account balance snapshots, token/cost dashboards, and tray status | @@ -71,7 +71,7 @@ Instead of wiring every agent to every model service by hand, CCR centralizes th ## Feature Highlights -- **Agent profiles**: create profiles for Claude Code, Codex, and ZCode with model overrides, scopes, CLI/app launch surfaces, environment settings, and multi-instance app workflows. +- **Agent profiles**: create profiles for Claude Code, Codex, Grok CLI, and ZCode with model overrides, scopes, CLI/app launch surfaces, environment settings, and multi-instance app workflows. - **Provider management**: add preset providers or custom endpoints; probe supported protocols; detect model lists; run real connectivity checks; manage single keys or credential pools; import local agent login state where supported. - **Model catalog**: search all configured models, edit model descriptions, and use those descriptions to guide Claude Code subagent, Task, and Workflow model selection. - **Routing engine**: combine built-in agent routing, request-header/body conditions, model-prefix routing, request rewrites, retry policy, and ordered fallback targets. @@ -122,7 +122,7 @@ Open **Server** and click **Start**. After the page shows Running, CCR listens o ### 4. Connect your agent tool -Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use **Open Agent** to launch the target app through CCR. +Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, Grok CLI, or ZCode, select the target model and effect scope, then apply the config. For app entries, use **Open Agent** to launch the target app through CCR. ### 5. Monitor and adjust diff --git a/README_zh.md b/README_zh.md index 74324a7f..2f2013b1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -41,7 +41,7 @@
-Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 Claude Code、Codex、ZCode 以及兼容 API 客户端提供一个稳定的本地入口,然后由你在 CCR 中决定每个请求应该走哪个供应商、哪个模型、哪套路由策略、哪些工具能力和哪组账号凭据。 +Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 Claude Code、Codex、Grok CLI、ZCode 以及兼容 API 客户端提供一个稳定的本地入口,然后由你在 CCR 中决定每个请求应该走哪个供应商、哪个模型、哪套路由策略、哪些工具能力和哪组账号凭据。 相比在每个 Agent、每个模型服务里反复改配置,CCR 把模型层收束到本机桌面应用里:供应商预设、自定义端点、凭据池、Fallback、Fusion 组合模型、MCP 工具、请求日志、账号用量和 Agent 启动配置都在一个地方管理。 @@ -53,7 +53,7 @@ Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 | 目标 | CCR 提供的能力 | | --- | --- | -| 保持 Agent 工作流不变,同时自由切换模型 | 为 Claude Code、Codex、ZCode 创建本地配置档案,支持 CLI / App 启动入口和按配置选择模型 | +| 保持 Agent 工作流不变,同时自由切换模型 | 为 Claude Code、Codex、Grok CLI、ZCode 创建本地配置档案,支持 CLI / App 启动入口和按配置选择模型 | | 快速接入多个模型供应商 | 内置供应商预设、自定义 OpenAI / Anthropic / Gemini 兼容端点、协议探测、模型发现和连通性检测 | | 把路由变成可配置策略 | 内置 Agent 路由、条件规则、请求改写、模型前缀路由、自动重试和 Fallback 模型链 | | 控制成本和额度压力 | 凭据池、Key 轮换、本地限额、账号余额快照、Token / 成本仪表盘和托盘状态 | @@ -71,7 +71,7 @@ Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 ## 功能亮点 -- **Agent 配置档案**:为 Claude Code、Codex 和 ZCode 创建配置档案,支持模型覆盖、作用范围、CLI / App 启动方式、环境变量和多开 App 工作流。 +- **Agent 配置档案**:为 Claude Code、Codex、Grok CLI 和 ZCode 创建配置档案,支持模型覆盖、作用范围、CLI / App 启动方式、环境变量和多开 App 工作流。 - **供应商管理**:添加预设供应商或自定义端点;探测协议;发现模型列表;运行真实连通性检测;管理单 Key 或凭据池;在支持时导入本机 Agent 登录态。 - **模型目录**:搜索全部已配置模型,编辑模型描述,并把这些描述用于 Claude Code Subagent、Task 和 Workflow 的模型选择提示。 - **路由引擎**:组合内置 Agent 路由、请求 Header / Body 条件、模型前缀路由、请求改写、重试策略和有序 Fallback 目标。 @@ -122,7 +122,7 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 ### 4. 连接 Agent 工具 -打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 通过 CCR 打开目标应用。 +打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex、Grok CLI 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 通过 CCR 打开目标应用。 ### 5. 日常查看和调整 diff --git a/build/dev.mjs b/build/dev.mjs index b91a341a..fd87ff51 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -35,6 +35,8 @@ import { let electronProcess = null; let restartTimer = null; +let restartInFlight = false; +let restartQueued = false; let pendingRestartReasons = []; const watchSignatures = new Map(); let shuttingDown = false; @@ -281,34 +283,81 @@ function scheduleRestart(reason = "unknown trigger") { restartTimer = setTimeout(restartElectron, restartDelayMs); } -function restartElectron() { +async function restartElectron() { + if (restartInFlight) { + restartQueued = true; + return; + } + restartInFlight = true; const reasons = Array.from(new Set(pendingRestartReasons)); pendingRestartReasons = []; restartTimer = null; - if (electronProcess) { - logDev(`stopping Electron pid=${electronProcess.pid ?? "unknown"}`); - electronProcess.kill(); - electronProcess = null; - } + try { + if (electronProcess) { + await stopElectron(electronProcess); + } - logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`); - const child = spawn(electron, ["."], { - cwd: projectRoot, - env: { - ...process.env, - NODE_ENV: "development" - }, - stdio: "inherit" - }); - electronProcess = child; - logDev(`Electron started pid=${child.pid ?? "unknown"}`); - child.on("exit", (code, signal) => { - logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`); + if (shuttingDown) { + return; + } + logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`); + const child = spawn(electron, ["."], { + cwd: projectRoot, + env: { + ...process.env, + NODE_ENV: "development" + }, + stdio: "inherit" + }); + electronProcess = child; + logDev(`Electron started pid=${child.pid ?? "unknown"}`); + child.on("exit", (code, signal) => { + logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`); + if (electronProcess === child) { + electronProcess = null; + } + }); + } finally { + restartInFlight = false; + if (restartQueued || pendingRestartReasons.length > 0) { + restartQueued = false; + scheduleRestart("changes queued during Electron restart"); + } + } +} + +async function stopElectron(child) { + logDev(`stopping Electron pid=${child.pid ?? "unknown"}`); + if (child.exitCode !== null || child.signalCode !== null) { if (electronProcess === child) { electronProcess = null; } + return; + } + + await new Promise((resolve) => { + let forceTimer = null; + let giveUpTimer = null; + const finish = () => { + if (forceTimer) clearTimeout(forceTimer); + if (giveUpTimer) clearTimeout(giveUpTimer); + child.off("exit", finish); + resolve(); + }; + child.once("exit", finish); + child.kill(); + forceTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + logDev(`force stopping Electron pid=${child.pid ?? "unknown"}`); + child.kill("SIGKILL"); + } + }, 2_000); + giveUpTimer = setTimeout(finish, 5_000); }); + if (electronProcess === child) { + electronProcess = null; + } } logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`); @@ -467,7 +516,7 @@ async function shutdown() { clearTimeout(restartTimer); } if (electronProcess) { - electronProcess.kill(); + await stopElectron(electronProcess); } if (styleBuildTimer) { clearTimeout(styleBuildTimer); diff --git a/docs/src/content/docs/en/configuration/profiles.md b/docs/src/content/docs/en/configuration/profiles.md index 06768113..f2094344 100644 --- a/docs/src/content/docs/en/configuration/profiles.md +++ b/docs/src/content/docs/en/configuration/profiles.md @@ -2,7 +2,7 @@ title: Agent Config pageTitle: Agent Config eyebrow: Detailed Configuration -lead: Create reusable launch configurations for Claude Code, Codex, and ZCode, and open separate agent instances from different configs. +lead: Create reusable launch configurations for Claude Code, Codex, Grok CLI, and ZCode, and open separate agent instances from different configs. --- ## Configuration Flow @@ -14,7 +14,7 @@ lead: Create reusable launch configurations for Claude Code, Codex, and ZCode, a 5. If the entry mode includes App, optionally bind a Bot and choose whether to forward agent messages or enable handoff. 6. Save the config, then open it from the Agent Config card: the terminal button copies the CLI command, and the play button starts the App instance. -During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, or ZCode setup you open directly from the system. +During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, Grok CLI, or ZCode setup you open directly from the system. ## Multi-Instance Mechanism @@ -23,7 +23,7 @@ Every Agent Config has its own `id` and name. When CCR opens an agent, it finds | Mechanism | Actual behavior | | --- | --- | | Separate config files | With **Only opened from CCR**, Claude Code and Codex write CCR-managed config files in directories separated by config `id` | -| Separate launchers | Claude Code uses a separate launch wrapper; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name | +| Separate launchers | Claude Code and Grok CLI use separate launch wrappers; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name | | Separate app data directories | When opening App mode, Claude App, ChatGPT (the renamed Codex desktop app), and ZCode App use user-data directories separated by config `id` | | Runtime state | CCR tracks running app instances by entry mode and config `id`; reopening the same config activates the existing window, while a different config can open a separate instance | @@ -33,11 +33,11 @@ This lets you create multiple configs for the same agent, such as "Claude Code - | Option | Applies to | Description | | --- | --- | --- | -| Agent | All | Claude Code, Codex, or ZCode. ZCode supports App only. | -| Config name | All | Identifies the config in CCR and can be used as the `ccr ` launch target. Names can contain spaces; copied commands are quoted automatically. | +| Agent | All | Claude Code, Codex, Grok CLI, or ZCode. Grok CLI supports CLI only; ZCode supports App only. | +| Config name | All | Identifies the config in CCR and can be used as the `ccr-app ` launch target. Names can contain spaces; copied commands are quoted automatically. | | Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. | | Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. | -| Entry mode | Claude Code, Codex | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. | +| Entry mode | Claude Code, Codex, Grok CLI | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI is fixed to `CLI only`. | | Model | All | Default model for the opened agent, either a provider model or Fusion model. For Claude Code, leaving it empty keeps the Claude Code default. | | Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. | | Environment variables | All | Extra environment variables injected into this config. Claude Code includes `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` by default so gateway model discovery is enabled. | @@ -77,10 +77,16 @@ Claude App and Claude Code CLI use different model-list adapters: | Environment variables | Injected into Codex CLI or ChatGPT. Claude Code-specific model discovery variables are not passed to Codex. | | Bot | Applies only to the ChatGPT app entry. | -After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display: an existing valid ChatGPT token is shown as the real ChatGPT account, while a profile without credentials uses a tokenless ChatGPT-shaped workspace identity so the desktop renderer keeps model selection available without storing a real user login. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported. +After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr-app "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display: an existing valid ChatGPT token is shown as the real ChatGPT account, while a profile without credentials uses a tokenless ChatGPT-shaped workspace identity so the desktop renderer keeps model selection available without storing a real user login. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported. Model and public plugin listings are not synthesized by the middleware. The native Codex app-server reads the generated `model_catalog_json` and handles `model/list` plus public `plugin/list` requests unchanged. This lets Codex refresh the official public [`openai/plugins`](https://github.com/openai/plugins) Git marketplace over the network. In a virtual workspace, only account-private marketplace requests are answered with an explicit empty result because the native service requires real ChatGPT authentication for those sections; they are never replaced with local plugins. Any downloaded Git checkout is owned only by Codex as its normal last-known-good data, not used by CCR as a replacement catalog. +### Grok CLI + +Grok CLI profiles are fixed to **Only opened from CCR** and **CLI only**. After saving, copy and run the card command, for example `ccr-app "Grok - Work"`. + +The generated wrapper sets Grok's model base URL and model-list URL to CCR's `/v1` gateway, supplies the profile-specific CCR API key, and sets the selected CCR model as the default. If the CCR Desktop gateway is not running, `ccr-app` starts a shared temporary service for Grok sessions and cleans it up after the last session exits. Grok CLI does not expose a separate user-config-file option, so CCR points `GROK_HOME` at a profile-specific directory. Its `config.toml` starts as a private copy of the user's config and can change independently, while `auth.json` is excluded to prevent a local xAI OAuth token from overriding the CCR key. Plugins, skills, and sessions remain shared with the original Grok home. Inside Grok CLI, use `/model` to switch among the provider and Fusion models returned by CCR; switched requests continue through CCR. + ### ZCode | Option | What it does | @@ -98,7 +104,7 @@ ZCode supports App only, so its entry mode is fixed to `App only`. The `Show all | Mode | How to open | Best for | Key differences | | --- | --- | --- | --- | -| CLI | Click the terminal button to copy the command, then run `ccr ` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. | +| CLI | Click the terminal button to copy the command, then run `ccr-app ` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. | | App | Click the play button in the CCR desktop app | Desktop windows, side-by-side instances, Bot forwarding, handoff | Uses a separate user-data directory per Agent Config; reopening the same config activates the existing window, while different configs can run in parallel. | | CLI & APP | One config exposes both CLI and App entry points | Reusing the same model config in both terminal and desktop App workflows | Both entries share the config name, model, effect scope, and environment variables, but launch differently. | @@ -116,6 +122,10 @@ Codex config writes `config.toml` and a model catalog file. With **Only opened f Codex supports CLI and App. CLI opens through the launcher for the selected config; App launches ChatGPT, uses a separate user-data directory, and passes the selected model and provider into the app. +### Grok CLI + +Grok CLI supports CLI only. CCR opens it through a profile-specific wrapper that injects the CCR model gateway, model discovery endpoint, API key, and default model. A profile-specific Grok home excludes xAI OAuth credentials so inference reliably uses the CCR key without rewriting the user's original Grok home. + ### ZCode ZCode supports App only. CCR writes ZCode CLI config, v2 config, and model cache based on ZCode home or a custom config file, then starts the App with the current Agent Config's model, provider, and separate user-data directory. diff --git a/docs/src/content/docs/en/guides.md b/docs/src/content/docs/en/guides.md index f554a274..4bdbf094 100644 --- a/docs/src/content/docs/en/guides.md +++ b/docs/src/content/docs/en/guides.md @@ -57,7 +57,7 @@ If you want the overview to show balance or remaining quota, open the provider's ## Connect Agent Config -Agent Config lets Claude Code, Codex, ZCode, and other agents use CCR's providers, routing, and model selection. +Agent Config lets Claude Code, Codex, Grok CLI, ZCode, and other agents use CCR's providers, routing, and model selection. General guidance: @@ -73,13 +73,17 @@ In **Agent Config**, choose Claude Code, set the model, small fast model, and se In **Agent Config**, choose Codex and confirm Provider ID, Provider Name, model, and config file. Only fill Codex CLI path and Codex home when you need a specific CLI or home directory. +### Grok CLI + +Choose Grok CLI and select a default model, then run the copied `ccr-app ` command. The command starts a shared temporary gateway service when CCR Desktop is not already serving one; concurrent Grok sessions keep it alive until the last session exits. CCR points Grok model discovery and inference at the local gateway; use `/model` inside Grok to switch CCR models. + ### ZCode ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields. ### Reuse A Locally Logged-In Agent -If Claude Code, Codex, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. +If Claude Code, Codex, Grok CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. ## Logs & Observability diff --git a/docs/src/content/docs/en/guides/agent-profile.md b/docs/src/content/docs/en/guides/agent-profile.md index d38fb7ed..e5980876 100644 --- a/docs/src/content/docs/en/guides/agent-profile.md +++ b/docs/src/content/docs/en/guides/agent-profile.md @@ -2,7 +2,7 @@ title: Connect Agent Config pageTitle: Connect Agent Config eyebrow: Quick Start -lead: Let Claude Code, Codex, ZCode, and other agents use CCR's providers, routing, and model selection. +lead: Let Claude Code, Codex, Grok CLI, ZCode, and other agents use CCR's providers, routing, and model selection. --- ## General Guidance @@ -23,10 +23,14 @@ In **Agent Config**, choose Codex and confirm Provider ID, Provider Name, model, Only fill Codex CLI path and Codex home when you need a specific CLI or home directory. +## Grok CLI + +Choose Grok CLI, select a model, and run the copied `ccr-app ` command. When the CCR Desktop gateway is not running, the command starts a shared temporary gateway service that remains available until the last concurrent Grok session exits. Use `/model` inside Grok to switch among models exposed by CCR. + ## ZCode ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields. ## Reuse A Locally Logged-In Agent -If Claude Code, Codex, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. +If Claude Code, Codex, Grok CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md index 6a7c91ca..20ddc009 100644 --- a/docs/src/content/docs/zh/configuration/profile.md +++ b/docs/src/content/docs/zh/configuration/profile.md @@ -2,7 +2,7 @@ title: Agent配置 pageTitle: Agent配置 eyebrow: 详细配置 -lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。 +lead: 为 Claude Code、Codex、Grok CLI、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。 --- ## 配置流程 @@ -14,7 +14,7 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过 5. 如果入口模式包含 App,可以绑定 Bot,并选择是否转发 Agent 消息或开启接力。 6. 保存后,从 Agent配置卡片打开:终端图标会复制 CLI 命令,播放图标会启动 App 实例。 -试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex 或 ZCode。 +试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex、Grok CLI 或 ZCode。 ## 多开机制 @@ -23,7 +23,7 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过 | 机制 | 实际行为 | | --- | --- | | 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 | -| 独立启动器 | Claude Code 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 | +| 独立启动器 | Claude Code 和 Grok CLI 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 | | 独立 App 数据目录 | 从 App 打开时,Claude App、ChatGPT(Codex 桌面端的新名称)、ZCode App 都会使用按配置 `id` 区分的用户数据目录 | | 运行状态 | CCR 按打开入口和配置 `id` 记录运行中的 App 实例;同一个配置再次打开会激活已有窗口,不同配置可以打开不同实例 | @@ -33,11 +33,11 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过 | 选项 | 适用范围 | 说明 | | --- | --- | --- | -| Agent | 全部 | 选择 Claude Code、Codex 或 ZCode。ZCode 只支持 App。 | -| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 | +| Agent | 全部 | 选择 Claude Code、Codex、Grok CLI 或 ZCode。Grok CLI 只支持 CLI,ZCode 只支持 App。 | +| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr-app <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 | | 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 | | 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 | -| 入口模式 | Claude Code、Codex | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。 | +| 入口模式 | Claude Code、Codex、Grok CLI | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI 固定为 `CLI only`。 | | 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 留空表示保留 Claude Code 默认模型。 | | Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 | | 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 | @@ -77,10 +77,16 @@ Claude App 和 Claude Code CLI 的模型列表适配方式不同: | 环境变量 | 注入 Codex CLI 或 ChatGPT。Claude Code 专用的模型发现变量不会传给 Codex。 | | Bot | 只在 ChatGPT App 入口生效。 | -保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。 +保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr-app "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。 模型和公共插件列表不再由中间层合成。原生 Codex app-server 读取生成的 `model_catalog_json`,并原样处理 `model/list` 与公共 `plugin/list` 请求,因此 Codex 可以自行联网刷新官方公开 [`openai/plugins`](https://github.com/openai/plugins) Git marketplace。虚拟 workspace 中,只有必须使用真实 ChatGPT 鉴权的账号私有 marketplace 请求会得到明确空结果,绝不会用本地插件替代。下载后的 Git checkout 只作为 Codex 自己的常规 last-known-good 数据,CCR 不会拿它替代远端目录。 +### Grok CLI + +Grok CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。保存后复制并运行配置卡片上的命令,例如 `ccr-app "Grok - Work"`。 + +生成的包装器会把 Grok 的模型网关和模型列表地址指向 CCR 的 `/v1`,注入该配置专属的 CCR API Key,并把选中的 CCR 模型设为默认模型。如果 CCR Desktop 网关尚未运行,`ccr-app` 会为 Grok 会话启动一个可共享的临时服务,并在最后一个会话退出后清理。Grok CLI 没有单独指定用户配置文件的选项,因此 CCR 会把 `GROK_HOME` 指向配置专属目录;其中的 `config.toml` 初始复制自用户配置,之后可以独立修改,不会回写原文件,同时隔离 `auth.json`,避免本机 xAI OAuth token 覆盖 CCR Key。插件、技能和会话目录仍与原 Grok home 共享。进入 Grok CLI 后可以使用 `/model` 切换 CCR 返回的普通供应商模型或 Fusion 模型,切换后的请求仍然经过 CCR。 + ### ZCode | 配置项 | 作用 | @@ -98,7 +104,7 @@ ZCode 只支持 App 打开,因此入口模式固定为 `App only`,也不会 | 模式 | 如何打开 | 适合场景 | 主要差异 | | --- | --- | --- | --- | -| CLI | 点击终端图标复制命令,然后在终端运行 `ccr <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 | +| CLI | 点击终端图标复制命令,然后在终端运行 `ccr-app <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 | | App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、多实例并存、Bot 消息转发或接力 | 每个 Agent配置使用独立用户数据目录;同一配置重复打开会激活已有窗口,不同配置可以并行打开。 | | CLI & APP | 同一个配置同时提供 CLI 和 App 入口 | 同一套模型配置既用于终端,也用于桌面 App | 两个入口共用配置名称、模型、作用范围和环境变量,但启动方式不同。 | @@ -116,6 +122,10 @@ Codex 配置会写入 `config.toml`,并生成模型目录文件。选择“仅 Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会启动 ChatGPT、使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 App。 +### Grok CLI + +Grok CLI 只支持 CLI。CCR 通过配置专属包装器启动它,注入 CCR 模型网关、模型发现地址、API Key 和默认模型,并通过不含 xAI OAuth 凭据的配置专属 Grok home 保证推理使用 CCR Key;用户原有的 Grok home 不会被改写。 + ### ZCode ZCode 只支持 App 打开。CCR 会根据 ZCode home 或自定义配置文件写入 ZCode 的 CLI 配置、v2 配置和模型缓存,并在 App 启动时使用当前 Agent配置的模型、供应商和独立用户数据目录。 diff --git a/docs/src/content/docs/zh/guides.md b/docs/src/content/docs/zh/guides.md index 82d5b315..dfb20a1f 100644 --- a/docs/src/content/docs/zh/guides.md +++ b/docs/src/content/docs/zh/guides.md @@ -57,7 +57,7 @@ lead: 从安装开始,逐步接入供应商、让 Agent 通过 CCR 发请求 ## 接入 Agent配置 -Agent配置让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 +Agent配置让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 通用建议: @@ -73,13 +73,17 @@ Agent配置让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、 在 **Agent配置** 中选择 Codex,确认供应商 ID、供应商名称、模型和配置文件。需要特定 CLI 时再填写 Codex CLI path 和 Codex home。 +### Grok CLI + +选择 Grok CLI 并设置默认模型,然后运行复制出的 `ccr-app <配置名称>` 命令。即使 CCR Desktop 网关尚未运行,该命令也会启动一个可共享的临时网关服务;并发 Grok 会话会共同保持服务运行,直到最后一个会话退出。CCR 会把 Grok 的模型发现和推理请求指向本地网关;进入 Grok 后可以用 `/model` 切换 CCR 模型。 + ### ZCode ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。 ### 复用本机已登录的 Agent -如果本机已经登录过 Claude Code、Codex 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。 +如果本机已经登录过 Claude Code、Codex、Grok CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。 ## 日志&观测 diff --git a/docs/src/content/docs/zh/guides/agent-profile.md b/docs/src/content/docs/zh/guides/agent-profile.md index d6acfee7..134184f1 100644 --- a/docs/src/content/docs/zh/guides/agent-profile.md +++ b/docs/src/content/docs/zh/guides/agent-profile.md @@ -2,7 +2,7 @@ title: 接入 Agent配置 pageTitle: 接入 Agent配置 eyebrow: 快速开始 -lead: 让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 +lead: 让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 --- ## 通用建议 @@ -23,10 +23,14 @@ lead: 让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由 需要特定 CLI 时再填写 Codex CLI path 和 Codex home。 +## Grok CLI + +选择 Grok CLI、设置模型,然后运行复制出的 `ccr-app <配置名称>` 命令。CCR Desktop 网关尚未运行时,该命令会启动一个可共享的临时网关服务,并保持运行到最后一个并发 Grok 会话退出。进入 Grok 后可以使用 `/model` 切换 CCR 暴露的模型。 + ## ZCode ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。 ## 复用本机已登录的 Agent -如果本机已经登录过 Claude Code、Codex 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。 +如果本机已经登录过 Claude Code、Codex、Grok CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。 diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index fe66d047..23fbce4b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; @@ -10,10 +10,10 @@ import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile } fro import { loadAppConfig } from "@ccr/core/config/config"; import { CONFIGDIR } from "@ccr/core/config/constants"; import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service"; -import { ensureProfileGateway } from "@ccr/core/profiles/launch-service"; -import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; +import { ensureProfileGateway, ProfileGatewayUnavailableError } from "@ccr/core/profiles/launch-service"; +import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface, shouldAutoStartProfileGateway } from "@ccr/core/profiles/launch-core"; import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server"; -import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app"; +import { assertAvailableGatewayModels, type AppConfig, type GatewayStatus, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app"; type ProfileCliOptions = { agentArgs: string[]; @@ -26,10 +26,12 @@ type ProfileCliOptions = { type WebCliOptions = { command: "start" | "ui" | "web"; daemonChild: boolean; + ensureGatewayRunning: boolean; help: boolean; host?: string; open: boolean; port?: number; + profileManaged: boolean; startGateway: boolean; }; @@ -43,6 +45,7 @@ type CliOptions = ProfileCliOptions | StopCliOptions | WebCliOptions; type ServiceState = { host?: string; pid: number; + profileManaged: boolean; serviceToken?: string; startedAt: string; startGateway: boolean; @@ -50,10 +53,14 @@ type ServiceState = { }; const serviceStateFileName = "service.json"; +const serviceStartLockFileName = "service-start.lock"; +const profileGatewayLeaseDirName = "profile-gateway-leases"; const serviceInstanceTokenEnv = "CCR_SERVICE_INSTANCE_TOKEN"; const serviceRpcTimeoutMs = 2_000; const serviceStartTimeoutMs = 30_000; const serviceStopTimeoutMs = 10_000; +const profileGatewayIdleGraceMs = 2_000; +const profileGatewayLeasePollMs = 500; const webAuthHeader = "x-ccr-web-auth"; const webAuthQueryParam = "ccr_web_token"; const defaultCliCommandName = "ccr"; @@ -113,71 +120,99 @@ async function main(): Promise { throw new Error("Claude App profiles do not support agent arguments."); } - const launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", { - reuseExisting: true, - startIfMissing: false - }); - if (resolvedSurface === "cli") { - const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY); - if (!runtimeResult.ok) { - throw new Error(runtimeResult.message); + const autoStartProfileGateway = shouldAutoStartProfileGateway(profile, resolvedSurface); + let profileGatewayLease = autoStartProfileGateway ? acquireManagedProfileGatewayLease() : undefined; + try { + let launchConfig: AppConfig; + try { + launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", { + reuseExisting: true, + startIfMissing: false + }); + } catch (error) { + if (!autoStartProfileGateway || !(error instanceof ProfileGatewayUnavailableError)) { + throw error; + } + profileGatewayLease ??= createProfileGatewayLease(); + await startService({ + command: "start", + daemonChild: false, + ensureGatewayRunning: true, + help: false, + open: false, + profileManaged: true, + startGateway: true + }); + launchConfig = await ensureProfileGateway(config, profile, profile.name || profile.id || "profile", { + reuseExisting: true, + startIfMissing: false + }); } - } - if (profile.agent === "claude-code" && resolvedSurface === "app") { - applyClaudeAppGatewayConfig(launchConfig); - applyClaudeAppGatewayConfig(launchConfig, { - backup: false, - dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile), - refreshModelDiscoveryCache: true + + if (resolvedSurface === "cli") { + const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY); + if (!runtimeResult.ok) { + throw new Error(runtimeResult.message); + } + } + if (profile.agent === "claude-code" && resolvedSurface === "app") { + applyClaudeAppGatewayConfig(launchConfig); + applyClaudeAppGatewayConfig(launchConfig, { + backup: false, + dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile), + refreshModelDiscoveryCache: true + }); + const launch = await launchClaudeAppProfile(configDir, profile, launchConfig); + const spawnError = await waitForImmediateSpawnError(launch.child, 500); + if (spawnError) { + throw new Error(`Failed to open Claude App: ${spawnError}`); + } + process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`); + return; + } + if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) { + if (profile.agent === "zcode") { + const launch = launchZcodeAppProfile(configDir, profile, launchConfig); + const spawnError = await waitForImmediateSpawnError(launch.child, 500); + if (spawnError) { + throw new Error(`Failed to open ZCode App: ${spawnError}`); + } + process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`); + } else { + const launch = launchCodexAppProfile(configDir, profile, launchConfig); + const spawnError = await waitForImmediateSpawnError(launch.child, 500); + if (spawnError) { + throw new Error(`Failed to open ${codexDesktopAppName}: ${spawnError}`); + } + process.stdout.write(`Opened ${codexDesktopAppName} with ${profile.name || profile.id}.\n`); + } + return; + } + + const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs); + + if (path.isAbsolute(plan.command) && !existsSync(plan.command)) { + throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`); + } + + const childEnv = { + ...process.env, + ...plan.env, + ...botGatewayProfileEnv(launchConfig, profile, resolvedSurface) + }; + delete childEnv.ELECTRON_RUN_AS_NODE; + + const launch = profileLaunchSpawnCommand(plan); + const child = spawn(launch.command, launch.args, { + env: childEnv, + stdio: "inherit", + windowsVerbatimArguments: !!launch.windowsVerbatimArguments }); - const launch = await launchClaudeAppProfile(configDir, profile, launchConfig); - const spawnError = await waitForImmediateSpawnError(launch.child, 500); - if (spawnError) { - throw new Error(`Failed to open Claude App: ${spawnError}`); - } - process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`); - return; + const code = await waitForChild(child); + process.exitCode = code; + } finally { + profileGatewayLease?.release(); } - if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) { - if (profile.agent === "zcode") { - const launch = launchZcodeAppProfile(configDir, profile, launchConfig); - const spawnError = await waitForImmediateSpawnError(launch.child, 500); - if (spawnError) { - throw new Error(`Failed to open ZCode App: ${spawnError}`); - } - process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`); - } else { - const launch = launchCodexAppProfile(configDir, profile, launchConfig); - const spawnError = await waitForImmediateSpawnError(launch.child, 500); - if (spawnError) { - throw new Error(`Failed to open ${codexDesktopAppName}: ${spawnError}`); - } - process.stdout.write(`Opened ${codexDesktopAppName} with ${profile.name || profile.id}.\n`); - } - return; - } - - const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs); - - if (path.isAbsolute(plan.command) && !existsSync(plan.command)) { - throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`); - } - - const childEnv = { - ...process.env, - ...plan.env, - ...botGatewayProfileEnv(launchConfig, profile, resolvedSurface) - }; - delete childEnv.ELECTRON_RUN_AS_NODE; - - const launch = profileLaunchSpawnCommand(plan); - const child = spawn(launch.command, launch.args, { - env: childEnv, - stdio: "inherit", - windowsVerbatimArguments: !!launch.windowsVerbatimArguments - }); - const code = await waitForChild(child); - process.exitCode = code; } function parseArgs(args: string[]): CliOptions { @@ -260,8 +295,10 @@ function parseWebArgs(args: string[], command: WebCliOptions["command"], default const options: WebCliOptions = { command, daemonChild: false, + ensureGatewayRunning: false, help: false, open: defaultOpen, + profileManaged: false, startGateway: true }; for (let index = 0; index < args.length; index += 1) { @@ -290,6 +327,10 @@ function parseWebArgs(args: string[], command: WebCliOptions["command"], default options.daemonChild = true; continue; } + if (arg === "--profile-managed") { + options.profileManaged = true; + continue; + } if (arg === "--host") { index += 1; options.host = requiredArg(args[index], "--host"); @@ -313,50 +354,75 @@ function parseWebArgs(args: string[], command: WebCliOptions["command"], default return options; } -async function startService(options: WebCliOptions): Promise { - const current = readServiceState(); - const currentVerification = current ? await verifyServiceState(current) : undefined; - if (current && currentVerification?.ok) { - process.stdout.write(`CCR service is already running at ${current.url} (pid ${current.pid}).\n`); - if (options.open) { - await openManagementUrl(current.url); +async function startService(options: WebCliOptions): Promise { + const releaseStartLock = await acquireServiceStartLock(); + try { + const current = readServiceState(); + const currentVerification = current ? await verifyServiceState(current) : undefined; + if (current && currentVerification?.ok) { + return reuseRunningService(current, options); + } + if (current) { + clearServiceState(current.pid); } - return; - } - if (current) { - clearServiceState(current.pid); - } - const serviceToken = generateServiceToken(); - const childArgs = [ - currentCliScript(), - "serve", - "--daemon-child", - ...(options.host ? ["--host", options.host] : []), - ...(options.port ? ["--port", String(options.port)] : []), - "--no-open", - ...(options.startGateway ? [] : ["--no-gateway"]) - ]; - const child = spawn(process.execPath, childArgs, { - detached: true, - env: serviceChildEnv(serviceToken), - stdio: "ignore", - windowsHide: true - }); - const spawnError = await waitForImmediateSpawnError(child, 1000); - if (spawnError) { - throw new Error(`Failed to start CCR service: ${spawnError}`); - } - child.unref(); + const serviceToken = generateServiceToken(); + const childArgs = [ + currentCliScript(), + "serve", + "--daemon-child", + ...(options.profileManaged ? ["--profile-managed"] : []), + ...(options.host ? ["--host", options.host] : []), + ...(options.port ? ["--port", String(options.port)] : []), + "--no-open", + ...(options.startGateway ? [] : ["--no-gateway"]) + ]; + const child = spawn(process.execPath, childArgs, { + detached: true, + env: serviceChildEnv(serviceToken), + stdio: "ignore", + windowsHide: true + }); + const spawnError = await waitForImmediateSpawnError(child, 1000); + if (spawnError) { + throw new Error(`Failed to start CCR service: ${spawnError}`); + } + child.unref(); - const state = await waitForServiceState(child.pid, serviceStartTimeoutMs); - if (!state) { - throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`); + const state = await waitForServiceState(child.pid, serviceStartTimeoutMs); + if (!state) { + throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`); + } + process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`); + if (options.open) { + await openManagementUrl(state.url); + } + return state; + } finally { + releaseStartLock(); } - process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`); +} + +async function reuseRunningService(current: ServiceState, options: WebCliOptions): Promise { + let state = current; + if (options.startGateway && (!state.startGateway || options.ensureGatewayRunning)) { + const gatewayStatus = await callServiceRpc(state, "startGateway"); + if (gatewayStatus.state !== "running") { + throw new Error(gatewayStatus.lastError || "CCR service did not start the gateway."); + } + state = { ...state, startGateway: true }; + } + if (!options.profileManaged && state.profileManaged) { + state = { ...state, profileManaged: false }; + } + if (state !== current) { + writeServiceState(state); + } + process.stdout.write(`CCR service is already running at ${state.url} (pid ${state.pid}).\n`); if (options.open) { await openManagementUrl(state.url); } + return state; } async function openManagementUi(options: WebCliOptions): Promise { @@ -399,6 +465,7 @@ async function runWebServer(options: WebCliOptions): Promise { writeServiceState({ host: options.host, pid: process.pid, + profileManaged: options.profileManaged, ...(serviceToken ? { serviceToken } : {}), startedAt: new Date().toISOString(), startGateway: options.startGateway, @@ -408,11 +475,16 @@ async function runWebServer(options: WebCliOptions): Promise { process.stdout.write(`CCR web management is running at ${runtime.url}\n`); let closing = false; + let profileLeaseMonitor: NodeJS.Timeout | undefined; + let profileGatewayIdleSince: number | undefined; const shutdown = (signal: NodeJS.Signals) => { if (closing) { return; } closing = true; + if (profileLeaseMonitor) { + clearInterval(profileLeaseMonitor); + } void runtime.close().finally(() => { if (options.daemonChild) { clearServiceState(process.pid); @@ -422,6 +494,27 @@ async function runWebServer(options: WebCliOptions): Promise { }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); + if (options.daemonChild && options.profileManaged) { + profileLeaseMonitor = setInterval(() => { + const state = readServiceState(); + if (!state || state.pid !== process.pid || !state.profileManaged) { + if (profileLeaseMonitor) { + clearInterval(profileLeaseMonitor); + profileLeaseMonitor = undefined; + } + return; + } + if (activeProfileGatewayLeaseCount() > 0) { + profileGatewayIdleSince = undefined; + return; + } + profileGatewayIdleSince ??= Date.now(); + if (Date.now() - profileGatewayIdleSince >= profileGatewayIdleGraceMs) { + shutdown("SIGTERM"); + } + }, profileGatewayLeasePollMs); + profileLeaseMonitor.unref?.(); + } await new Promise(() => undefined); } @@ -568,6 +661,7 @@ function readServiceState(): ServiceState | undefined { return { host: parsed.host, pid, + profileManaged: parsed.profileManaged === true, serviceToken: typeof parsed.serviceToken === "string" && parsed.serviceToken.trim() ? parsed.serviceToken.trim() : undefined, startedAt: parsed.startedAt || "", startGateway: parsed.startGateway !== false, @@ -600,6 +694,133 @@ function serviceStateFile(): string { return path.join(CONFIGDIR, serviceStateFileName); } +type ProfileGatewayLease = { + release: () => void; +}; + +function acquireManagedProfileGatewayLease(): ProfileGatewayLease | undefined { + const state = readServiceState(); + return state?.profileManaged && isProcessRunning(state.pid) + ? createProfileGatewayLease() + : undefined; +} + +function createProfileGatewayLease(): ProfileGatewayLease { + const dir = profileGatewayLeaseDir(); + mkdirSync(dir, { mode: 0o700, recursive: true }); + const file = path.join(dir, `${process.pid}-${randomBytes(12).toString("hex")}.json`); + writeFileSync(file, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600 + }); + let released = false; + return { + release: () => { + if (released) { + return; + } + released = true; + try { + unlinkSync(file); + } catch { + // The service also removes stale leases after an abnormal client exit. + } + } + }; +} + +function activeProfileGatewayLeaseCount(): number { + let entries: string[]; + try { + entries = readdirSync(profileGatewayLeaseDir()); + } catch { + return 0; + } + let active = 0; + for (const entry of entries) { + const file = path.join(profileGatewayLeaseDir(), entry); + const lease = readJsonRecord(file); + const pid = Number(lease?.pid); + if (!Number.isInteger(pid) || pid <= 0 || !isProcessRunning(pid)) { + try { + unlinkSync(file); + } catch { + // Stale lease cleanup is best effort. + } + continue; + } + active += 1; + } + return active; +} + +function profileGatewayLeaseDir(): string { + return path.join(CONFIGDIR, profileGatewayLeaseDirName); +} + +async function acquireServiceStartLock(): Promise<() => void> { + const file = path.join(CONFIGDIR, serviceStartLockFileName); + const token = randomBytes(16).toString("hex"); + const deadline = Date.now() + serviceStartTimeoutMs + 5_000; + mkdirSync(path.dirname(file), { recursive: true }); + + while (Date.now() < deadline) { + try { + writeFileSync(file, `${JSON.stringify({ pid: process.pid, token })}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600 + }); + return () => { + const lock = readJsonRecord(file); + if (lock?.token !== token) { + return; + } + try { + unlinkSync(file); + } catch { + // Lock release is best effort; stale owners are cleaned below. + } + }; + } catch (error) { + const code = errorCode(error); + if (code !== "EEXIST") { + throw error; + } + const lock = readJsonRecord(file); + const ownerPid = Number(lock?.pid); + if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !isProcessRunning(ownerPid)) { + try { + unlinkSync(file); + } catch { + // Another starter may have replaced the lock; retry normally. + } + continue; + } + await delay(100); + } + } + throw new Error(`Timed out waiting for the CCR service startup lock after ${serviceStartTimeoutMs + 5_000}ms.`); +} + +function readJsonRecord(file: string): Record | undefined { + try { + const value = JSON.parse(readFileSync(file, "utf8")) as unknown; + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; + } catch { + return undefined; + } +} + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + function currentCliScript(): string { return __filename; } diff --git a/packages/core/src/agents/local-providers/grok.ts b/packages/core/src/agents/local-providers/grok.ts index 04ab7bf0..dc87632a 100644 --- a/packages/core/src/agents/local-providers/grok.ts +++ b/packages/core/src/agents/local-providers/grok.ts @@ -39,6 +39,7 @@ const grokProviderId = "grok-cli-api"; const grokProviderName = "Grok CLI API"; const grokDefaultOidcIssuer = "https://auth.x.ai"; const grokOauthDefaultTimeoutMs = 8_000; +const grokFallbackClientVersion = "0.2.93"; const grokBillingResetPaths = [ "$.billingPeriodEnd", @@ -435,6 +436,7 @@ function importGrokProviderWithAuth( } export function grokProviderAccountConfig(): ProviderAccountConfig { + const clientVersion = grokClientVersion(); return { connectors: [ { @@ -442,7 +444,7 @@ export function grokProviderAccountConfig(): ProviderAccountConfig { endpoint: grokBillingEndpoint(), headers: { "x-grok-client-identifier": "xai-grok-cli", - "x-grok-client-version": "0.2.93" + "x-grok-client-version": clientVersion }, mapping: grokBillingMapping, type: "http-json" @@ -452,7 +454,7 @@ export function grokProviderAccountConfig(): ProviderAccountConfig { endpoint: grokSubscriptionEndpoint(), headers: { "x-grok-client-identifier": "xai-grok-cli", - "x-grok-client-version": "0.2.93" + "x-grok-client-version": clientVersion }, mapping: { meters: [] }, parser: "grok-subscription", @@ -521,6 +523,8 @@ function grokOauthPlugin(suffix: string, token: string, providerName?: string): ...bearerAuthPlugin(suffix, token, {}, providerName), request: { headers: { + "x-grok-client-identifier": "xai-grok-cli", + "x-grok-client-version": grokClientVersion(), "x-grok-model-override": "{{ model }}" }, strict: true @@ -528,6 +532,15 @@ function grokOauthPlugin(suffix: string, token: string, providerName?: string): }; } +export function grokClientVersion(): string { + const explicit = process.env.GROK_CLI_VERSION?.trim(); + if (explicit) { + return explicit; + } + const payload = readJsonRecord(path.join(grokStorageRoot(), "version.json")); + return readString(payload?.version) || grokFallbackClientVersion; +} + async function refreshGrokAuth(auth: GrokTokenSet): Promise { const refreshToken = auth.refreshToken; if (!refreshToken) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 47a6e76c..14ce0d82 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2341,6 +2341,19 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { }; } + if (agent === "grok") { + return { + agent, + enabled, + env: codexCompatibleProfileEnv(env), + id, + model, + name, + scope: "ccr", + surface: "cli" + }; + } + const appPath = readProfileAppPath(item, agent); return { agent, @@ -2397,6 +2410,9 @@ function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined { if (normalized === "codex") { return "codex"; } + if (normalized === "grok" || normalized === "grok-cli" || normalized === "grok cli") { + return "grok"; + } if (normalized === "zcode" || normalized === "z-code" || normalized === "z code") { return "zcode"; } @@ -2410,6 +2426,9 @@ function defaultProfileAgentName(agent: ProfileConfig["agent"]): string { if (agent === "zcode") { return "ZCode"; } + if (agent === "grok") { + return "Grok CLI"; + } return "Codex"; } diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 651fd5fb..20cbca98 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1126,7 +1126,7 @@ export const DEFAULT_TRAY_WIDGETS: TrayWidgetConfig[] = [ { id: "model-share", type: "model-share", variant: DEFAULT_TRAY_COMPONENT_VARIANTS.modelShare } ]; -export type ProfileClientKind = "claude-code" | "codex" | "zcode"; +export type ProfileClientKind = "claude-code" | "codex" | "grok" | "zcode"; export type CodexProfileConfigFormat = "legacy" | "separate_profile_files"; export type CodexRemoteFrontendMode = "app" | "cli" | "claude-code"; export type ProfileScope = "ccr" | "global" | "custom"; @@ -1784,7 +1784,7 @@ export type UsageStatsSnapshot = { totals: UsageTotals; }; -export type AgentKind = "claude-code" | "codex" | "zcode" | "claude-design" | "unknown"; +export type AgentKind = "claude-code" | "codex" | "grok" | "zcode" | "claude-design" | "unknown"; export type AgentAnalysisFilter = { agent?: AgentKind | "all"; diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts index d96f814c..29061591 100644 --- a/packages/core/src/gateway/application/gateway-service.ts +++ b/packages/core/src/gateway/application/gateway-service.ts @@ -80,7 +80,7 @@ class GatewayService { } await this.stop(); this.config = config; - this.coreAuthToken = generateCoreGatewayAuthToken(); + const coreAuthToken = generateCoreGatewayAuthToken(); this.plugin = new ClaudeCodeRouterPlugin(config); this.status = { coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), @@ -117,7 +117,7 @@ class GatewayService { } if (shouldRunGateway) { - await writeCoreGatewayConfig(config, this.rawTraceSynchronizer.token, this.coreAuthToken, this.browserWebSearchMcpIntegration); + await writeCoreGatewayConfig(config, this.rawTraceSynchronizer.token, coreAuthToken, 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}`); @@ -125,7 +125,8 @@ class GatewayService { await proxyService.refreshUpstreamProxyFromCurrentSystem(); const runtimeId = randomUUID(); const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config); - this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, this.coreAuthToken); + this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, coreAuthToken); + this.coreAuthToken = coreAuthToken; const managedChild = this.child; writeManagedCoreGatewayMarker(config, this.child, runtimeId); this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`)); diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts index b4214ceb..0d61ee3e 100644 --- a/packages/core/src/gateway/claude-code-router-plugin.ts +++ b/packages/core/src/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, type RouterBuiltInAgentRuleId, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "@ccr/core/contracts/app"; +import { type AppConfig, type ProfileClientKind, type RouterBuiltInAgentRuleId, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "@ccr/core/contracts/app"; import { CONFIGDIR } from "@ccr/core/config/constants"; import { applyAgentRequestEnrichers } from "@ccr/core/agents/request-enricher"; import { compileRouterConfig, type CompiledRouterConfig, type CompiledRouterRule } from "@ccr/core/routing/config-compiler"; @@ -304,6 +304,10 @@ function resolveBuiltInAgentRouteDecision( modelRegistry: ModelRegistry, fallback: RouterFallbackConfig ): ConfiguredRouteDecision | undefined { + const grokInternalDecision = resolveGrokInternalRouteDecision(request, config, modelRegistry, fallback); + if (grokInternalDecision) { + return grokInternalDecision; + } for (const agent of builtInAgentRuleIds) { if (!builtInAgentRouteMatches(request, config, agent)) { continue; @@ -327,6 +331,48 @@ function resolveBuiltInAgentRouteDecision( return undefined; } +function resolveGrokInternalRouteDecision( + request: MutableRequestLike, + config: AppConfig, + modelRegistry: ModelRegistry, + fallback: RouterFallbackConfig +): ConfiguredRouteDecision | undefined { + const requestedModel = normalizeRouteSelector(readString(request.body.model)); + if (requestedModel?.toLowerCase() !== "grok-build") { + return undefined; + } + const profile = resolveAuthenticatedProfile(request, config, "grok"); + const userAgent = readRequestHeader(request.headers, "user-agent")?.toLowerCase() ?? ""; + const target = userAgent.includes("grok") + ? modelRegistry.resolve(resolveGrokProfileRouteTarget(config, profile?.model)) + : undefined; + if (!target) { + return undefined; + } + return { + fallback, + model: target, + reason: "builtin:grok-internal", + rewrites: [{ + key: "request.body.model", + operation: "set", + value: target.selector + }], + source: "builtin" + }; +} + +function resolveGrokProfileRouteTarget(config: AppConfig, profileModel: string | undefined): string | undefined { + const configured = normalizeRouteSelector(profileModel); + if (configured) { + return configured; + } + const preferred = config.Providers.find((provider) => provider.name === config.preferredProvider) ?? config.Providers[0]; + return preferred?.name && preferred.models[0] + ? `${preferred.name}/${preferred.models[0]}` + : undefined; +} + const builtInAgentRuleIds: RouterBuiltInAgentRuleId[] = ["claude-code", "codex"]; function builtInAgentRouteMatches( @@ -348,6 +394,14 @@ function resolveBuiltInAgentProfile( request: MutableRequestLike, config: AppConfig, agent: RouterBuiltInAgentRuleId +) { + return resolveAuthenticatedProfile(request, config, agent); +} + +function resolveAuthenticatedProfile( + request: MutableRequestLike, + config: AppConfig, + agent: ProfileClientKind ) { if (config.profile.enabled === false) { return undefined; diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index 90b73bd7..1fa0bb17 100644 --- a/packages/core/src/gateway/core-runtime/config-compiler.ts +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -3,7 +3,7 @@ */ import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol } from "@ccr/core/contracts/app"; import { codexDefaultBaseUrl, readCodexAuth, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/service"; -import { grokAccessTokenExpired } from "@ccr/core/agents/local-providers/grok"; +import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok"; import { pluginService } from "@ccr/core/plugins/service"; import { normalizeRouteSelector, providerRuntimeId } from "@ccr/core/routing/model-registry"; import { isRecord, stringValue } from "@ccr/core/gateway/internal/value"; @@ -72,7 +72,8 @@ export async function compileCoreGatewayConfig( staticApiKeys: { keyBearerOnly: false, keyEnv: coreGatewayAuthTokenEnv, - keyHeader: coreGatewayAuthHeader + keyHeader: coreGatewayAuthHeader, + keys: [coreAuthToken] } }, billing: { @@ -255,6 +256,8 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise } const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; + const currentRequest = isRecord(plugin.request) ? plugin.request : {}; + const currentRequestHeaders = isRecord(currentRequest.headers) ? currentRequest.headers : {}; return { ...plugin, auth: { @@ -263,6 +266,16 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise ...currentHeaders, authorization: `Bearer ${grokAuth.accessToken}` } + }, + request: { + ...currentRequest, + headers: { + ...currentRequestHeaders, + "x-grok-client-identifier": "xai-grok-cli", + "x-grok-client-version": grokClientVersion(), + "x-grok-model-override": currentRequestHeaders["x-grok-model-override"] ?? "{{ model }}" + }, + strict: currentRequest.strict ?? true } }; }); diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index edd289e9..ecbe2162 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -160,6 +160,7 @@ type AgentLogDetails = { type AgentTextSignalOptions = { allowStandaloneCodex?: boolean; + allowStandaloneGrok?: boolean; }; type AgentToolCallDetail = { @@ -997,7 +998,10 @@ function inferAgentKind( stringifyForSearch(responsePayloads) ].join(" ").toLowerCase(); - const bodyAgent = inferAgentFromText(haystack, { allowStandaloneCodex: false }); + const bodyAgent = inferAgentFromText(haystack, { + allowStandaloneCodex: false, + allowStandaloneGrok: false + }); if (bodyAgent) { return bodyAgent; } @@ -1041,6 +1045,7 @@ function readAgentUserAgent(headers: Record): string function inferAgentFromText(value: string, options: AgentTextSignalOptions = {}): AgentKind | undefined { const normalized = value.toLowerCase(); const allowStandaloneCodex = options.allowStandaloneCodex ?? true; + const allowStandaloneGrok = options.allowStandaloneGrok ?? true; if (normalized.includes("claude design") || normalized.includes("claude-design") || normalized.includes("claude.ai/design")) { return "claude-design"; } @@ -1052,6 +1057,16 @@ function inferAgentFromText(value: string, options: AgentTextSignalOptions = {}) ) { return "zcode"; } + if ( + normalized.includes("xai-grok-cli") || + (allowStandaloneGrok && ( + normalized.includes("grok-cli") || + normalized.includes("grok cli") || + /(^|[^a-z0-9])grok([/_\s-]|$)/.test(normalized) + )) + ) { + return "grok"; + } if ( normalized.includes("openai-codex") || normalized.includes("codex_cli") || @@ -2475,11 +2490,11 @@ function normalizeAgentAnalysisRange(value: UsageStatsRange | undefined): UsageS } function normalizeAgentFilter(value: AgentAnalysisFilter["agent"] | undefined): AgentKind | "all" { - return value === "claude-code" || value === "codex" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; } function normalizeSessionAgentFilter(value: AgentAnalysisFilter["sessionAgent"] | undefined): AgentKind | undefined { - return value === "claude-code" || value === "codex" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; + return value === "claude-code" || value === "codex" || value === "grok" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; } function agentDisplayName(agent: AgentKind): string { @@ -2492,6 +2507,9 @@ function agentDisplayName(agent: AgentKind): string { if (agent === "codex") { return "Codex"; } + if (agent === "grok") { + return "Grok CLI"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/packages/core/src/profiles/launch-core.ts b/packages/core/src/profiles/launch-core.ts index ab187fdf..d2d13a40 100644 --- a/packages/core/src/profiles/launch-core.ts +++ b/packages/core/src/profiles/launch-core.ts @@ -49,6 +49,9 @@ export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[ if (profile.agent === "zcode") { return ["app"]; } + if (profile.agent === "grok") { + return ["cli"]; + } const surface = normalizeProfileSurface(profile.surface); if (surface === "cli") { return ["cli"]; @@ -74,6 +77,13 @@ export function defaultProfileOpenSurface(profile: Pick) return profile.agent === "zcode" ? "app" : "cli"; } +export function shouldAutoStartProfileGateway( + profile: Pick, + surface: ProfileOpenSurface +): boolean { + return profile.agent === "grok" && surface === "cli"; +} + export function profileOpenCommand( profile: ProfileConfig, surface: ProfileOpenSurface = defaultProfileOpenSurface(profile), @@ -95,12 +105,35 @@ export function buildProfileLaunchPlan( extraArgs: string[] = [] ): ProfileLaunchPlan { const resolvedSurface = resolveProfileOpenSurface(profile, surface); + if (profile.agent === "grok") { + return buildGrokLaunchPlan(configDir, profile, resolvedSurface, extraArgs); + } if (isCodexCompatibleAgent(profile.agent)) { return buildCodexLaunchPlan(configDir, profile, resolvedSurface, extraArgs); } return buildClaudeCodeLaunchPlan(configDir, profile, resolvedSurface, extraArgs); } +function buildGrokLaunchPlan( + configDir: string, + profile: ProfileConfig, + surface: ProfileOpenSurface, + extraArgs: string[] +): ProfileLaunchPlan { + if (surface !== "cli") { + throw new Error("Grok CLI profiles only support CLI opening."); + } + return { + args: extraArgs, + command: path.join(configDir, "bin", grokWrapperFilename(profile)), + env: { + CCR_PROFILE_SURFACE: "cli" + }, + profile, + surface + }; +} + export function profileLaunchSpawnCommand(plan: Pick): ProfileLaunchSpawnCommand { if (!isWindowsCommandScript(plan.command)) { return { @@ -209,6 +242,13 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string { : `ccr-claude-code-wrapper-${slug}`; } +function grokWrapperFilename(profile: ProfileConfig): string { + const slug = sanitizePathSegment(profile.id || profile.name || profile.agent) || "grok"; + return process.platform === "win32" + ? `ccr-grok-cli-wrapper-${slug}.cmd` + : `ccr-grok-cli-wrapper-${slug}`; +} + function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): string { const slug = sanitizeCodexProviderId(profile.id || profile.name || providerId) || "codex"; return process.platform === "win32" diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index 17cd4d57..83bd8c34 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -29,6 +29,13 @@ type ProfileOpenCommandOptions = { ensureLauncher?: boolean; }; +export class ProfileGatewayUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "ProfileGatewayUnavailableError"; + } +} + type ProfileAppLaunchResult = { child: ChildProcess; claudeDesignProxy?: boolean; @@ -227,7 +234,7 @@ async function ensureGatewayConfigRunning( } if (existingGateway.state === "unavailable") { if (!startIfMissing) { - throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + throw new ProfileGatewayUnavailableError(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); } } else { throw new Error(existingGatewayConflictMessage(existingGateway, appName)); @@ -235,7 +242,7 @@ async function ensureGatewayConfigRunning( } if (!startIfMissing) { - throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + throw new ProfileGatewayUnavailableError(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); } const startedStatus = await gatewayService.start(config); diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 7376260b..7fe8e0b0 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "@ccr/core/contracts/app"; @@ -86,6 +86,8 @@ export async function applyProfileConfig(config: AppConfig): Promise key !== "CCR_GROK_BIN" && !isGrokManagedEnvKey(key)) + .map(([key, value]) => `export ${key}=${shellQuote(value)}`); + const gatewayBaseUrl = `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`; + const noProxyHosts = grokGatewayNoProxyHosts(config); + return [ + "#!/bin/sh", + ...envExports, + `if [ -n "\${NO_PROXY:-}" ]; then NO_PROXY="$NO_PROXY,${noProxyHosts}"; else NO_PROXY=${shellQuote(noProxyHosts)}; fi`, + `if [ -n "\${no_proxy:-}" ]; then no_proxy="$no_proxy,${noProxyHosts}"; else no_proxy=${shellQuote(noProxyHosts)}; fi`, + "export NO_PROXY no_proxy", + `export GROK_MODELS_BASE_URL=${shellQuote(gatewayBaseUrl)}`, + `export GROK_MODELS_LIST_URL=${shellQuote(`${gatewayBaseUrl}/models`)}`, + `export XAI_API_KEY=${shellQuote(token)}`, + `export GROK_DEFAULT_MODEL=${shellQuote(model)}`, + `export GROK_HOME=${shellQuote(profileHome)}`, + `export CCR_PROFILE_SURFACE=cli`, + `exec ${shellQuote(realGrok)} "$@"`, + "" + ].join("\n"); +} + +function grokWrapperCmdScript(config: AppConfig, profile: ProfileConfig, token: string, model: string, profileHome: string): string { + const realGrok = profile.env?.CCR_GROK_BIN?.trim() || "grok"; + const envExports = Object.entries(profileEnv(profile)) + .filter(([key]) => key !== "CCR_GROK_BIN" && !isGrokManagedEnvKey(key)) + .map(([key, value]) => cmdSetLine(key, value)); + const gatewayBaseUrl = `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`; + const noProxyHosts = grokGatewayNoProxyHosts(config); + return [ + "@echo off", + ...envExports, + `set "NO_PROXY=%NO_PROXY%,${cmdValue(noProxyHosts)}"`, + `set "no_proxy=%no_proxy%,${cmdValue(noProxyHosts)}"`, + cmdSetLine("GROK_MODELS_BASE_URL", gatewayBaseUrl), + cmdSetLine("GROK_MODELS_LIST_URL", `${gatewayBaseUrl}/models`), + cmdSetLine("XAI_API_KEY", token), + cmdSetLine("GROK_DEFAULT_MODEL", model), + cmdSetLine("GROK_HOME", profileHome), + cmdSetLine("CCR_PROFILE_SURFACE", "cli"), + `${cmdQuote(realGrok)} %*`, + "exit /b %ERRORLEVEL%", + "" + ].join("\r\n"); +} + +function grokGatewayNoProxyHosts(config: AppConfig): string { + const configuredHost = config.gateway.host === "0.0.0.0" || config.gateway.host === "::" + ? "127.0.0.1" + : config.gateway.host?.trim().replace(/^\[|\]$/g, "") || "127.0.0.1"; + return [...new Set([configuredHost, "127.0.0.1", "localhost", "::1"])].join(","); +} + +function isGrokManagedEnvKey(key: string): boolean { + return key === "GROK_MODELS_BASE_URL" || + key === "GROK_MODELS_LIST_URL" || + key === "GROK_DEFAULT_MODEL" || + key === "GROK_HOME" || + key === "GROK_STORAGE_DIR" || + key === "GROK_CONFIG_DIR" || + key === "XAI_API_KEY" || + key === "CCR_PROFILE_SURFACE"; +} + +function ensureGrokProfileHome(profile: ProfileConfig): string { + const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent).toLowerCase() || "grok"; + const profileHome = path.join(CONFIGDIR, "profiles", slug, "grok"); + const sourceHome = resolveGrokSourceHome(profile); + mkdirSync(profileHome, { mode: privateDirMode, recursive: true }); + + if (path.resolve(sourceHome) === path.resolve(profileHome)) { + return profileHome; + } + + ensureGrokProfileConfigCopy( + path.join(sourceHome, "config.toml"), + path.join(profileHome, "config.toml") + ); + for (const entry of [ + "agents", + "commands", + "downloads", + "hooks", + "marketplace-cache", + "plugins", + "sessions", + "skills", + "upload_queue", + "worktrees.db" + ]) { + linkGrokProfileHomeEntry(path.join(sourceHome, entry), path.join(profileHome, entry)); + } + return profileHome; +} + +export function resolveGrokSourceHome(profile: Pick): string { + const explicitRoot = profile.env?.GROK_HOME?.trim() || + profile.env?.GROK_STORAGE_DIR?.trim() || + profile.env?.GROK_CONFIG_DIR?.trim() || + process.env.GROK_HOME?.trim() || + process.env.GROK_STORAGE_DIR?.trim() || + process.env.GROK_CONFIG_DIR?.trim(); + if (explicitRoot) { + return resolveUserPath(explicitRoot); + } + const internalHome = process.env.CCR_INTERNAL_HOME_DIR?.trim(); + return internalHome + ? path.join(internalHome, ".grok") + : resolveUserPath("~/.grok"); +} + +function ensureGrokProfileConfigCopy(source: string, target: string): void { + let targetStat: ReturnType | undefined; + try { + targetStat = lstatSync(target); + } catch { + targetStat = undefined; + } + + if (targetStat?.isSymbolicLink()) { + let content: Buffer | undefined; + try { + content = readFileSync(target); + } catch { + if (existsSync(source)) { + content = readFileSync(source); + } + } + rmSync(target, { force: true }); + if (content) { + writeFileSync(target, content, { mode: privateFileMode }); + chmodSync(target, privateFileMode); + } + return; + } + + if (targetStat || !existsSync(source)) { + return; + } + copyFileSync(source, target); + chmodSync(target, privateFileMode); +} + +function linkGrokProfileHomeEntry(source: string, target: string): void { + if (!existsSync(source) || pathEntryExists(target)) { + return; + } + const sourceStat = statSync(source); + try { + symlinkSync(source, target, sourceStat.isDirectory() && process.platform === "win32" ? "junction" : undefined); + } catch { + if (sourceStat.isFile()) { + copyFileSync(source, target); + chmodSync(target, privateFileMode); + } + } +} + +function pathEntryExists(file: string): boolean { + try { + const stat = lstatSync(file); + if (!stat.isSymbolicLink()) { + return true; + } + const target = readlinkSync(file); + return Boolean(target); + } catch { + return false; + } +} + function writeCodexCliMiddleware( config: AppConfig, profile: ProfileConfig, @@ -1344,6 +1579,7 @@ function isManagedGeneratedBinFile(fileName: string): boolean { normalized === codexMiddlewareRuntimeFilename() || normalized.startsWith("ccr-claude-code-api-key-") || normalized.startsWith("ccr-claude-code-wrapper-") || + normalized.startsWith("ccr-grok-cli-wrapper-") || normalized.startsWith("ccr-codex-cli-stdio-"); } @@ -1376,6 +1612,9 @@ function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus if (profile.agent === "zcode") { return restoreDisabledZcodeProfile(profile, resolveZcodeConfigFile(profile)); } + if (profile.agent === "grok") { + return disabledStatus("grok", grokWrapperPath(profile), "Grok CLI profile is disabled."); + } const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; return restoreDisabledGlobalProfile( profile, @@ -1643,6 +1882,9 @@ function disabledProfileMessage(profile: ProfileConfig): string { if (profile.agent === "claude-code") { return "Claude Code profile is disabled."; } + if (profile.agent === "grok") { + return "Grok CLI profile is disabled."; + } return `${codexCompatibleClientName(profile.agent)} profile is disabled.`; } @@ -1789,6 +2031,9 @@ function codexCompatibleClientName(agent: ProfileConfig["agent"]): string { if (agent === "claude-code") { return "Claude Code"; } + if (agent === "grok") { + return "Grok CLI"; + } return agent === "zcode" ? "ZCode" : "Codex"; } diff --git a/packages/core/src/providers/runtime-topology.ts b/packages/core/src/providers/runtime-topology.ts index 338a1c38..552c8cd1 100644 --- a/packages/core/src/providers/runtime-topology.ts +++ b/packages/core/src/providers/runtime-topology.ts @@ -42,6 +42,9 @@ function providerProtocolPreferenceForClient(clientProtocol: GatewayProviderProt if (clientProtocol === "openai_responses") { return ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_interactions"]; } + if (clientProtocol === "openai_chat_completions") { + return ["openai_chat_completions", "openai_responses"]; + } if (clientProtocol === "anthropic_messages") { return uniqueProviderProtocols([clientProtocol, ...gatewayProviderProtocolFallbackOrder]); } diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 1d96c70e..780394f2 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -402,7 +402,7 @@ function ProfileAgentTabs({ return (
{profileAgentOptions.map((option) => { @@ -601,7 +601,12 @@ export function AddProfileForm({ onChange({ scope: normalizeProfileScope(scope) })} - options={translateOptions(profileScopeOptions, t)} + options={translateOptions( + draft.agent === "grok" + ? profileScopeOptions.filter((option) => option.value === "ccr") + : profileScopeOptions, + t + )} value={draft.scope} /> @@ -621,6 +626,8 @@ export function AddProfileForm({ options={translateOptions( draft.agent === "zcode" ? profileSurfaceOptions.filter((option) => option.value === "app") + : draft.agent === "grok" + ? profileSurfaceOptions.filter((option) => option.value === "cli") : profileSurfaceOptions, t )} @@ -662,6 +669,16 @@ export function AddProfileForm({ /> + ) : draft.agent === "grok" ? ( + + onChange({ model })} + /> + ) : ( <> diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 78178ee9..ac9c6569 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -730,6 +730,8 @@ export const appCopy: Record = { "Code": "代码", "Codex": "Codex", "Codex model": "Codex 模型", + "Grok CLI": "Grok CLI", + "Grok model": "Grok 模型", "CLI only": "仅 CLI", "Concurrency": "并发", "Condition": "条件", diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index 5fa352ec..bb693555 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -109,6 +109,7 @@ export const agentFilterOptions: Array<{ label: string; value: AgentFilterValue { label: "All agents", value: "all" }, { label: "Claude Code", value: "claude-code" }, { label: "Codex", value: "codex" }, + { label: "Grok CLI", value: "grok" }, { label: "ZCode", value: "zcode" }, { label: "Claude Design", value: "claude-design" }, { label: "Unknown", value: "unknown" } @@ -117,6 +118,7 @@ export const agentFilterOptions: Array<{ label: string; value: AgentFilterValue export const profileAgentOptions: Array<{ label: string; value: ProfileConfig["agent"] }> = [ { label: "Claude Code", value: "claude-code" }, { label: "Codex", value: "codex" }, + { label: "Grok CLI", value: "grok" }, { label: "ZCode", value: "zcode" } ]; diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index f08ae551..6c747301 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -102,6 +102,7 @@ import { cn } from "@/lib/utils"; import appLogoUrl from "@/assets/logo.png"; import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; import codexLogoUrl from "@/assets/agent-logos/codex.png"; +import grokLogoUrl from "@/assets/agent-logos/grok.ico"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; @@ -784,6 +785,15 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs surface }; } + if (profile.agent === "grok") { + return { + ...createProfileDraft("grok", profile.name), + envRows: keyValueRowsFromRecord(codexCompatibleProfileEnv(profile.env ?? {})), + model: profile.model, + scope: "ccr", + surface: "cli" + }; + } const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurfaceForForm(profile.surface); return { ...createProfileDraft(profile.agent, profile.name), @@ -819,6 +829,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { if (draft.agent === "claude-code") { return true; } + if (draft.agent === "grok") { + return true; + } return ( Boolean(draft.providerId.trim()) && Boolean(draft.providerName.trim()) @@ -1407,6 +1420,13 @@ export function profileSummaryItems( ]; } + if (profile.agent === "grok") { + return [ + { label: t("Model"), value: modelValue }, + ...envSummaryItems + ]; + } + return [ { label: t("Model"), value: modelValue }, { label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" }, @@ -1444,6 +1464,18 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro surface }; } + if (agent === "grok") { + return { + agent: "grok", + enabled: profile.enabled, + env: codexCompatibleProfileEnv(env), + id: profile.id || `profile-${index + 1}`, + model, + name, + scope: "ccr", + surface: "cli" + }; + } return { agent: normalizeCodexCompatibleAgent(agent), ...(surface !== "cli" && agent !== "zcode" && profile.appPath?.trim() ? { appPath: profile.appPath.trim() } : {}), @@ -1517,6 +1549,8 @@ export function normalizeUnknownProfileItem(value: Record, inde ? "claude-code" : rawAgent === "codex" ? "codex" + : rawAgent === "grok" || rawAgent === "grok-cli" || rawAgent === "grok cli" + ? "grok" : rawAgent === "zcode" || rawAgent === "z-code" || rawAgent === "z code" ? "zcode" : undefined; @@ -1594,6 +1628,9 @@ export function profileAgentLabel(agent: ProfileConfig["agent"]): string { if (agent === "zcode") { return "ZCode"; } + if (agent === "grok") { + return "Grok CLI"; + } return "Codex"; } @@ -1621,6 +1658,9 @@ export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[ if (profile.agent === "zcode") { return ["app"]; } + if (profile.agent === "grok") { + return ["cli"]; + } const surface = normalizeProfileSurface(profile.surface); if (surface === "cli") { return ["cli"]; @@ -1649,6 +1689,9 @@ export function profileAgentLogoUrl(agent: ProfileConfig["agent"]): string { if (agent === "zcode") { return zcodeLogoUrl; } + if (agent === "grok") { + return grokLogoUrl; + } return codexLogoUrl; } @@ -1657,11 +1700,11 @@ function normalizeCodexCompatibleAgent(agent: ProfileConfig["agent"]): "codex" | } function normalizeProfileAgent(agent: ProfileConfig["agent"]): ProfileConfig["agent"] { - return agent === "zcode" ? "zcode" : agent === "codex" ? "codex" : "claude-code"; + return agent === "zcode" ? "zcode" : agent === "grok" ? "grok" : agent === "codex" ? "codex" : "claude-code"; } function normalizeProfileSurfaceForAgent(agent: ProfileConfig["agent"], surface: unknown): ProfileSurface { - return agent === "zcode" ? "app" : normalizeProfileSurface(surface); + return agent === "zcode" ? "app" : agent === "grok" ? "cli" : normalizeProfileSurface(surface); } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { diff --git a/packages/ui/src/pages/home/shared/routing.ts b/packages/ui/src/pages/home/shared/routing.ts index 69364b84..96d51e50 100644 --- a/packages/ui/src/pages/home/shared/routing.ts +++ b/packages/ui/src/pages/home/shared/routing.ts @@ -894,7 +894,11 @@ export function routerBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltI if (config.profile.enabled === false) { return undefined; } - return config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent); + return config.profile.profiles.find((profile) => + profile.enabled && + profile.agent === agent && + Boolean(profile.model.trim()) + ); } export function routerBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string { @@ -906,11 +910,12 @@ export function routerBuiltInAgentRuleDisabledReason(config: AppConfig, agent: R return "Agent profiles are disabled."; } const agentName = routerBuiltInAgentRuleName(agent); - const profile = routerBuiltInAgentProfile(config, agent); - if (!profile) { + const enabledProfile = config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent); + if (!enabledProfile) { return `Enable a ${agentName} profile before enabling this built-in route.`; } - if (!profile.model.trim()) { + const profile = routerBuiltInAgentProfile(config, agent); + if (!profile) { return `Set a model on the ${agentName} profile before enabling this built-in route.`; } return undefined; diff --git a/packages/ui/src/pages/home/shared/usage.ts b/packages/ui/src/pages/home/shared/usage.ts index ed29c9b8..fdcd437b 100644 --- a/packages/ui/src/pages/home/shared/usage.ts +++ b/packages/ui/src/pages/home/shared/usage.ts @@ -180,7 +180,7 @@ export function logSelectOptions(label: string, values: string[], selected: stri } export function normalizeAgentFilterValue(value: string): AgentFilterValue { - return value === "claude-code" || value === "codex" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; } export function agentKindLabel(agent: AgentKind): string { @@ -193,6 +193,9 @@ export function agentKindLabel(agent: AgentKind): string { if (agent === "codex") { return "Codex"; } + if (agent === "grok") { + return "Grok CLI"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/tests/main/local-agent-provider-grok.test.mjs b/tests/main/local-agent-provider-grok.test.mjs index 2ab93407..06ceabe3 100644 --- a/tests/main/local-agent-provider-grok.test.mjs +++ b/tests/main/local-agent-provider-grok.test.mjs @@ -63,6 +63,8 @@ test("Grok local provider imports bearer token and model override plugin", async assert.equal(result.provider.account?.connectors?.[1]?.parser, "grok-subscription"); assert.equal(result.providerPlugins.length, 2); assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer grok-access-token"); + assert.equal(result.providerPlugins[0].request.headers["x-grok-client-identifier"], "xai-grok-cli"); + assert.equal(result.providerPlugins[0].request.headers["x-grok-client-version"], "0.2.93"); assert.equal(result.providerPlugins[0].request.headers["x-grok-model-override"], "{{ model }}"); assert.equal(result.providerPlugins[1].providerName, "__CCR_PROVIDER_INTERNAL_NAME__"); }); diff --git a/tests/main/profile-launch-core.test.mjs b/tests/main/profile-launch-core.test.mjs index 38623ba5..a3712494 100644 --- a/tests/main/profile-launch-core.test.mjs +++ b/tests/main/profile-launch-core.test.mjs @@ -10,7 +10,8 @@ import { profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveCodexConfigFile, - resolveProfileOpenSurface + resolveProfileOpenSurface, + shouldAutoStartProfileGateway } from "../../packages/core/src/profiles/launch-core.ts"; const claudeProfile = { @@ -35,6 +36,16 @@ const codexProfile = { surface: "auto" }; +const grokProfile = { + agent: "grok", + enabled: true, + id: "grok-main", + model: "provider,model", + name: "Grok Main", + scope: "ccr", + surface: "cli" +}; + test("findProfileForOpen resolves enabled profiles and reports ambiguous names", () => { const config = { profile: { @@ -57,8 +68,10 @@ test("profile open surfaces enforce agent capabilities", () => { assert.deepEqual(profileOpenSurfaces(claudeProfile), ["cli", "app"]); assert.deepEqual(profileOpenSurfaces({ ...claudeProfile, surface: "cli" }), ["cli"]); assert.deepEqual(profileOpenSurfaces({ ...codexProfile, agent: "zcode" }), ["app"]); + assert.deepEqual(profileOpenSurfaces(grokProfile), ["cli"]); assert.equal(resolveProfileOpenSurface(codexProfile, "app"), "app"); assert.throws(() => resolveProfileOpenSurface({ ...claudeProfile, surface: "cli" }, "app"), /does not support APP/); + assert.throws(() => resolveProfileOpenSurface(grokProfile, "app"), /does not support APP/); }); test("default profile command surface is CLI unless the agent is app-only", () => { @@ -68,10 +81,17 @@ test("default profile command surface is CLI unless the agent is app-only", () = assert.equal(defaultProfileOpenSurface({ ...codexProfile, agent: "zcode" }), "app"); }); +test("Grok CLI starts a temporary CCR gateway when none is already running", () => { + assert.equal(shouldAutoStartProfileGateway(grokProfile, "cli"), true); + assert.equal(shouldAutoStartProfileGateway(codexProfile, "cli"), false); + assert.equal(shouldAutoStartProfileGateway(claudeProfile, "app"), false); +}); + test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { const configDir = path.join(path.sep, "tmp", "ccr-config"); const codexPlan = buildProfileLaunchPlan(configDir, codexProfile, "app"); const claudePlan = buildProfileLaunchPlan(configDir, claudeProfile, "cli", ["--debug"]); + const grokPlan = buildProfileLaunchPlan(configDir, grokProfile, "cli", ["--debug"]); assert.equal(codexPlan.surface, "app"); assert.deepEqual(codexPlan.args, ["app"]); @@ -88,6 +108,11 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { assert.equal(claudePlan.env.CODEXL_CLAUDE_CODE_MODEL, "provider/model"); assert.equal(claudePlan.env.ANTHROPIC_SMALL_FAST_MODEL, "provider/small"); + assert.equal(grokPlan.surface, "cli"); + assert.deepEqual(grokPlan.args, ["--debug"]); + assert.equal(path.basename(grokPlan.command), process.platform === "win32" ? "ccr-grok-cli-wrapper-grok-main.cmd" : "ccr-grok-cli-wrapper-grok-main"); + assert.equal(grokPlan.env.CCR_PROFILE_SURFACE, "cli"); + 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 index 46283389..97377390 100644 --- a/tests/main/profile-service.test.mjs +++ b/tests/main/profile-service.test.mjs @@ -1,11 +1,35 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; import { CONFIGDIR } from "../../packages/core/src/config/constants.ts"; -import { applyProfileConfig, cleanupGeneratedBinBackups, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts"; +import { applyProfileConfig, cleanupGeneratedBinBackups, resolveGrokSourceHome, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts"; + +test("Grok profile source home follows profile and process environment overrides", () => { + const previous = { + GROK_CONFIG_DIR: process.env.GROK_CONFIG_DIR, + GROK_HOME: process.env.GROK_HOME, + GROK_STORAGE_DIR: process.env.GROK_STORAGE_DIR + }; + try { + process.env.GROK_HOME = "/tmp/process-grok-home"; + process.env.GROK_STORAGE_DIR = "/tmp/process-grok-storage"; + process.env.GROK_CONFIG_DIR = "/tmp/process-grok-config"; + assert.equal(resolveGrokSourceHome({ env: {} }), path.resolve("/tmp/process-grok-home")); + assert.equal(resolveGrokSourceHome({ env: { GROK_HOME: "/tmp/profile-grok-home" } }), path.resolve("/tmp/profile-grok-home")); + assert.equal(resolveGrokSourceHome({ env: { GROK_STORAGE_DIR: "/tmp/profile-grok-storage" } }), path.resolve("/tmp/profile-grok-storage")); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}); test("profile service cleans stale generated bin backups only", () => { const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-generated-bin-cleanup-")); @@ -209,6 +233,85 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e assert.match(content, /TOOLHUB_OPENAI_MODEL = "Provider\/model"/); }); +test("profile service writes a Grok CLI wrapper that points model discovery and inference to CCR", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { + const profileId = "grok-gateway-test"; + const sourceGrokHome = path.join(process.env.HOME, ".grok"); + mkdirSync(path.join(sourceGrokHome, "sessions"), { recursive: true }); + mkdirSync(path.join(sourceGrokHome, "skills"), { recursive: true }); + writeFileSync(path.join(sourceGrokHome, "auth.json"), "oauth credentials must not be shared"); + writeFileSync(path.join(sourceGrokHome, "config.toml"), "[ui]\ncompact_mode = false\n"); + const profileGrokHome = path.join(CONFIGDIR, "profiles", profileId, "grok"); + const profileGrokConfig = path.join(profileGrokHome, "config.toml"); + if (process.platform !== "win32") { + mkdirSync(profileGrokHome, { recursive: true }); + symlinkSync(path.join(sourceGrokHome, "config.toml"), profileGrokConfig); + } + const config = createDefaultAppConfig({ + generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") + }); + config.Providers = [ + { + api_base_url: "https://example.test/v1", + api_key: "provider-key", + models: ["model"], + name: "Provider" + } + ]; + config.preferredProvider = "Provider"; + config.APIKEY = "ccr-grok-profile-test"; + config.APIKEYS = [ + { + createdAt: "2026-01-01T00:00:00.000Z", + id: `profile:${profileId}`, + key: "ccr-grok-profile-test", + name: "Profile: Grok Gateway Test" + } + ]; + config.profile.profiles = [ + { + agent: "grok", + enabled: true, + env: { + CCR_GROK_BIN: "/custom/bin/grok", + GROK_HOME: "~/.grok", + GROK_MODELS_BASE_URL: "https://ignored.example/v1", + USER_VALUE: "kept" + }, + id: profileId, + model: "Provider/model", + name: "Grok Gateway Test", + scope: "ccr", + surface: "cli" + } + ]; + + const result = await applyProfileConfig(config); + assert.equal(result.clients.length, 1); + assert.equal(result.clients[0].client, "grok"); + assert.equal(result.clients[0].ok, true); + + const commandExtension = process.platform === "win32" ? ".cmd" : ""; + const wrapperFile = path.join(CONFIGDIR, "bin", `ccr-grok-cli-wrapper-${profileId}${commandExtension}`); + const content = readFileSync(wrapperFile, "utf8"); + assert.match(content, new RegExp(`GROK_MODELS_BASE_URL.*http://127\\.0\\.0\\.1:${config.gateway.port}/v1`)); + assert.match(content, new RegExp(`GROK_MODELS_LIST_URL.*http://127\\.0\\.0\\.1:${config.gateway.port}/v1/models`)); + assert.match(content, /XAI_API_KEY.*ccr-grok-profile-test/); + assert.match(content, /GROK_DEFAULT_MODEL.*Provider\/model/); + assert.match(content, new RegExp(`GROK_HOME.*profiles.*${profileId}.*grok`)); + assert.match(content, /USER_VALUE.*kept/); + assert.match(content, /NO_PROXY.*127\.0\.0\.1,localhost,::1/); + assert.match(content, /\/custom\/bin\/grok/); + assert.equal(content.includes("https://ignored.example/v1"), false); + + assert.equal(readFileSync(profileGrokConfig, "utf8"), "[ui]\ncompact_mode = false\n"); + assert.equal(lstatSync(profileGrokConfig).isSymbolicLink(), false); + writeFileSync(profileGrokConfig, "[ui]\ncompact_mode = true\n"); + assert.equal(readFileSync(path.join(sourceGrokHome, "config.toml"), "utf8"), "[ui]\ncompact_mode = false\n"); + assert.equal(existsSync(path.join(profileGrokHome, "sessions")), true); + assert.equal(existsSync(path.join(profileGrokHome, "skills")), true); + assert.equal(existsSync(path.join(profileGrokHome, "auth.json")), false); +}); + test("profile service clears stale Claude Code ToolHub artifacts when no gateway models are available", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "stale-toolhub-no-models"; const settingsFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "settings.json"); diff --git a/tests/main/request-log-store.test.mjs b/tests/main/request-log-store.test.mjs index d5b58959..a3e110dc 100644 --- a/tests/main/request-log-store.test.mjs +++ b/tests/main/request-log-store.test.mjs @@ -312,3 +312,70 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async rmSync(dir, { force: true, recursive: true }); } }); + +test("RequestLogStore identifies Grok CLI requests in agent analysis", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-grok-agent-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const startedAt = new Date().toISOString(); + await store.record({ + completedAt: startedAt, + durationMs: 25, + method: "POST", + path: "/v1/responses", + providerName: "test-provider", + providerProtocol: "openai_responses", + requestBody: Buffer.from(JSON.stringify({ input: "hello", model: "Provider/model" }), "utf8"), + requestHeaders: { + "content-type": "application/json", + "user-agent": "xai-grok-cli/0.2.93" + }, + requestId: "grok-agent-request", + responseBodyText: JSON.stringify({ model: "Provider/model", output: [] }), + responseHeaders: { "content-type": "application/json" }, + startedAt, + statusCode: 200, + url: "http://127.0.0.1:3456/v1/responses" + }); + + const analysis = await store.analyze({ agent: "grok", range: "30d" }); + assert.equal(analysis.scannedRequestCount, 1); + assert.equal(analysis.agents[0]?.agent, "grok"); + assert.equal(analysis.agents[0]?.label, "Grok CLI"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("RequestLogStore does not identify an unknown client as Grok CLI from its model name", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-grok-model-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const startedAt = new Date().toISOString(); + await store.record({ + completedAt: startedAt, + durationMs: 25, + method: "POST", + path: "/v1/responses", + providerName: "test-provider", + providerProtocol: "openai_responses", + requestBody: Buffer.from(JSON.stringify({ input: "hello", model: "xAI/grok-4.5" }), "utf8"), + requestHeaders: { + "content-type": "application/json", + "user-agent": "generic-openai-client/1.0" + }, + requestId: "grok-model-request", + responseBodyText: JSON.stringify({ model: "xAI/grok-4.5", output: [] }), + responseHeaders: { "content-type": "application/json" }, + startedAt, + statusCode: 200, + url: "http://127.0.0.1:3456/v1/responses" + }); + + const analysis = await store.analyze({ agent: "all", range: "30d" }); + assert.equal(analysis.scannedRequestCount, 1); + assert.equal(analysis.agents[0]?.agent, "unknown"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index 3602da67..43c6f3d3 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -359,6 +359,98 @@ test("built-in Codex route preserves the requested model when the authenticated assert.equal(result.decision.reason, "default"); }); +test("Grok internal title requests use the authenticated profile model", async () => { + const plugin = createRouterPlugin({ + agent: "grok", + profileModel: "Provider/claude-sonnet" + }); + const result = await plugin.routeRequest({ + body: { + model: "grok-build" + }, + headers: { + "user-agent": "grok/0.2.99" + }, + method: "POST", + url: "/v1/responses" + }); + + assert.equal(result.body.model, "Provider/claude-sonnet"); + assert.equal(result.decision.model, "Provider/claude-sonnet"); + assert.equal(result.decision.reason, "builtin:grok-internal"); +}); + +test("Grok internal title requests use the gateway default when the profile model is unset", async () => { + const plugin = createRouterPlugin({ + agent: "grok", + profileModel: "" + }); + const result = await plugin.routeRequest({ + body: { + model: "grok-build" + }, + headers: { + "user-agent": "grok/0.2.99" + }, + method: "POST", + url: "/v1/responses" + }); + + assert.equal(result.body.model, "Provider/claude-sonnet"); + assert.equal(result.decision.reason, "builtin:grok-internal"); +}); + +test("Grok explicit model selection routes chat requests through a Responses provider", async () => { + const config = { + CUSTOM_ROUTER_PATH: "", + Providers: [{ + api_base_url: "https://cli-chat-proxy.grok.com/v1", + api_key: "ccr-local-agent-login", + capabilities: [{ + baseUrl: "https://cli-chat-proxy.grok.com/v1", + type: "openai_responses" + }], + models: ["grok-4.5"], + name: "Grok CLI API", + type: "openai_responses" + }], + Router: { + builtInRules: { + "claude-code": { enabled: true }, + codex: { enabled: true } + }, + fallback: { mode: "off", models: [], retryCount: 1 }, + rules: [] + }, + profile: { + enabled: true, + profiles: [{ + agent: "grok", + enabled: true, + id: "grok-cli", + model: "Grok CLI API/grok-4.5", + name: "Grok CLI", + scope: "ccr" + }] + }, + virtualModelProfiles: [] + }; + const attempt = prepareGatewayUpstreamAttemptForTest({ + body: { + messages: [{ content: "OK", role: "user" }], + model: "Grok CLI API/grok-4.5" + }, + config, + headers: {}, + method: "POST", + path: "/v1/chat/completions", + routedModel: "Grok CLI API/grok-4.5" + }); + + assert.equal(attempt.body.model, "grok-4.5"); + assert.match(attempt.headers["x-target-provider"], /^provider-grok-cli-api-[a-f0-9]{10}::openai_responses$/); +}); + test("built-in Claude Code route does not inject Claude Code native tool search", async () => { const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet", diff --git a/tests/renderer/profiles.test.tsx b/tests/renderer/profiles.test.tsx index 96d66660..3fc5b534 100644 --- a/tests/renderer/profiles.test.tsx +++ b/tests/renderer/profiles.test.tsx @@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { ProfileConfig } from "../../packages/core/src/contracts/app.ts"; import { DeleteProfileDialog } from "../../packages/ui/src/pages/home/components/profiles.tsx"; import { AppI18nContext, appCopy } from "../../packages/ui/src/pages/home/shared/i18n.tsx"; -import { createProfileDraft, profileDraftWithDetectedAppPath } from "../../packages/ui/src/pages/home/shared/profiles.ts"; +import { createProfileDraft, normalizeUnknownProfileItem, profileDraftWithDetectedAppPath } from "../../packages/ui/src/pages/home/shared/profiles.ts"; const profile: ProfileConfig = { agent: "claude-code", @@ -49,3 +49,27 @@ test("detected CHATGPT_APP_PATH is used as the Codex profile default", () => { assert.equal(profileDraftWithDetectedAppPath({ ...draft, appPath: "/custom/chatgpt" }, detectedPath).appPath, "/custom/chatgpt"); assert.equal(profileDraftWithDetectedAppPath(createProfileDraft("claude-code"), detectedPath).appPath, ""); }); + +test("Grok CLI profile defaults to a CCR-scoped CLI entry", () => { + const draft = createProfileDraft("grok"); + + assert.equal(draft.name, "Grok CLI"); + assert.equal(draft.scope, "ccr"); + assert.equal(draft.surface, "cli"); +}); + +test("persisted Grok profiles are normalized to the supported launch scope", () => { + const profile = normalizeUnknownProfileItem({ + agent: "grok-cli", + enabled: true, + id: "grok-work", + model: "Provider/model", + name: "Grok Work", + scope: "global", + surface: "app" + }, 0); + + assert.equal(profile?.agent, "grok"); + assert.equal(profile?.scope, "ccr"); + assert.equal(profile?.surface, "cli"); +}); diff --git a/tests/renderer/routing.test.ts b/tests/renderer/routing.test.ts new file mode 100644 index 00000000..03dc2e31 --- /dev/null +++ b/tests/renderer/routing.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; +import { + buildBuiltInAgentRoutingRows, + routerBuiltInAgentProfile, + routerBuiltInAgentRouteTarget, + routerBuiltInAgentRuleDisabledReason, + routerBuiltInAgentRuleIsActive +} from "../../packages/ui/src/pages/home/shared/routing.ts"; + +test("Codex built-in route accepts a later enabled profile with a configured model", () => { + const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-generated.json" }); + config.profile.profiles = [ + { + agent: "codex", + enabled: true, + id: "codex", + model: "", + name: "Codex", + scope: "ccr" + }, + { + agent: "codex", + enabled: true, + id: "bs-2", + model: "uuroute/gpt-5.5", + name: "BS", + scope: "ccr" + } + ]; + + assert.equal(routerBuiltInAgentProfile(config, "codex")?.id, "bs-2"); + assert.equal(routerBuiltInAgentRouteTarget(config, "codex"), "uuroute/gpt-5.5"); + assert.equal(routerBuiltInAgentRuleDisabledReason(config, "codex"), undefined); + assert.equal(routerBuiltInAgentRuleIsActive(config, "codex"), true); + assert.equal( + buildBuiltInAgentRoutingRows(config).find((row) => row.builtInAgent === "codex")?.target, + "set request.body.model = uuroute/gpt-5.5" + ); +}); + +test("Codex built-in route asks for a model only when every enabled Codex profile is unset", () => { + const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-generated.json" }); + config.profile.profiles = [ + { + agent: "codex", + enabled: true, + id: "codex", + model: " ", + name: "Codex", + scope: "ccr" + }, + { + agent: "codex", + enabled: true, + id: "bs-2", + model: "", + name: "BS", + scope: "ccr" + } + ]; + + assert.equal( + routerBuiltInAgentRuleDisabledReason(config, "codex"), + "Set a model on the Codex profile before enabling this built-in route." + ); + assert.equal(routerBuiltInAgentRuleIsActive(config, "codex"), false); +}); From 8a49c51ebf01abc0e8e156e9205de5fe5b783fe4 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 20:08:19 +0800 Subject: [PATCH 19/38] Add overview provider and model filters --- packages/ui/src/pages/home/App.tsx | 39 +++++++- .../src/pages/home/components/dashboard.tsx | 90 ++++++++++++++++++- packages/ui/src/pages/home/shared/i18n.tsx | 1 + tests/renderer/overview-components.test.tsx | 8 +- 4 files changed, 132 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index bbd9b55c..4937856e 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -276,6 +276,8 @@ function App() { const [agentAnalysisLoading, setAgentAnalysisLoading] = useState(false); const [agentAnalysisRange, setAgentAnalysisRange] = useState("7d"); const [agentAnalysisSession, setAgentAnalysisSession] = useState(); + const [usageModelFilter, setUsageModelFilter] = useState(""); + const [usageProviderFilter, setUsageProviderFilter] = useState(""); const [usageRange, setUsageRange] = useState("7d"); const [usageStats, setUsageStats] = useState(fallbackUsageStats); const [providerAccountSnapshots, setProviderAccountSnapshots] = useState([]); @@ -454,7 +456,11 @@ function App() { let cancelled = false; const refreshUsageStats = () => { - const filter: UsageStatsFilter | undefined = usageRange === "today" ? { includeProxy: true } : undefined; + const filter: UsageStatsFilter = { + ...(usageRange === "today" ? { includeProxy: true } : {}), + ...(usageProviderFilter ? { provider: usageProviderFilter } : {}), + ...(usageModelFilter ? { model: usageModelFilter } : {}) + }; void window.ccr?.getUsageStats(usageRange, filter).then((snapshot) => { if (!cancelled) { setUsageStats(snapshot); @@ -466,7 +472,29 @@ function App() { cancelled = true; stopPolling(); }; - }, [usageRange]); + }, [usageModelFilter, usageProviderFilter, usageRange]); + + useEffect(() => { + if (!usageProviderFilter) { + return; + } + if (!draftConfig.Providers.some((provider) => provider.name === usageProviderFilter)) { + setUsageProviderFilter(""); + } + }, [draftConfig.Providers, usageProviderFilter]); + + useEffect(() => { + if (!usageModelFilter) { + return; + } + const modelAvailable = draftConfig.Providers.some((provider) => + (!usageProviderFilter || provider.name === usageProviderFilter) && + provider.models.some((model) => model.trim() === usageModelFilter) + ); + if (!modelAvailable) { + setUsageModelFilter(""); + } + }, [draftConfig.Providers, usageModelFilter, usageProviderFilter]); useEffect(() => { if (!window.ccr) { @@ -2900,6 +2928,13 @@ function App() { snapshot: agentAnalysis }, overview: { + usageFilters: { + modelFilter: usageModelFilter, + providerFilter: usageProviderFilter, + providers: draftConfig.Providers, + setModelFilter: setUsageModelFilter, + setProviderFilter: setUsageProviderFilter + }, onWidgetsChange: changeOverviewWidgets, overviewWidgets: normalizeOverviewWidgets(draftConfig.overviewWidgets), providerAccounts: providerAccountSnapshots, diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 310d1111..8279521b 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -18,13 +18,24 @@ import { ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select, SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass, systemStatusTooltipPositionClass, Tooltip, translateOptions, Trash2, UsageComparisonRow, usageRangeOptions, - UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText, + GatewayProviderConfig, UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, X, XAxis, YAxis } from "../shared/index"; import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity"; import { ShareCardWidget } from "./share-cards"; import { Cloud, Rocket } from "lucide-react"; + +type OverviewUsageFilters = { + modelFilter: string; + providerFilter: string; + providers: GatewayProviderConfig[]; + setModelFilter: (model: string) => void; + setProviderFilter: (provider: string) => void; +}; + +const emptyOverviewProviders: GatewayProviderConfig[] = []; + export function OverviewView({ onWidgetsChange, overviewWidgets, @@ -32,6 +43,7 @@ export function OverviewView({ providerAccountRefreshing = false, refreshProviderAccounts, setUsageRange, + usageFilters, usageRange, usageStats }: { @@ -41,6 +53,7 @@ export function OverviewView({ providerAccountRefreshing?: boolean; refreshProviderAccounts?: () => void | Promise; setUsageRange: (range: UsageStatsRange) => void; + usageFilters?: OverviewUsageFilters; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; }) { @@ -67,6 +80,11 @@ export function OverviewView({ const visibleWidgets = displayWidgets.filter((widget) => widget.enabled); const activeWidget = visibleWidgets.find((widget) => widget.id === activeWidgetId); const selectedWidget = widgets.find((widget) => widget.id === selectedWidgetId); + const filterProviders = usageFilters?.providers ?? emptyOverviewProviders; + const providerFilter = usageFilters?.providerFilter ?? ""; + const modelFilter = usageFilters?.modelFilter ?? ""; + const providerOptions = useMemo(() => overviewProviderFilterOptions(filterProviders, t), [filterProviders, t]); + const modelOptions = useMemo(() => overviewModelFilterOptions(filterProviders, providerFilter, t), [filterProviders, providerFilter, t]); useEffect(() => { if (!editing) { @@ -153,6 +171,17 @@ export function OverviewView({ setSelectedWidgetId((current) => current === id ? undefined : current); } + function changeProviderFilter(provider: string) { + usageFilters?.setProviderFilter(provider); + if (modelFilter && provider && !overviewProviderHasModel(filterProviders, provider, modelFilter)) { + usageFilters?.setModelFilter(""); + } + } + + function changeModelFilter(model: string) { + usageFilters?.setModelFilter(model); + } + useEffect(() => { if (!editing || !selectedWidgetId || activeWidgetId) { return; @@ -308,8 +337,21 @@ export function OverviewView({ >
-

{t("Overview")}

+
{editing ? ( @@ -403,6 +445,50 @@ function OverviewUsageRangeSelector({ ); } +function overviewProviderFilterOptions(providers: GatewayProviderConfig[], translate: (value: string) => string): Array<{ label: string; value: string }> { + const providerNames = new Set(); + for (const provider of providers) { + const name = provider.name.trim(); + if (name) { + providerNames.add(name); + } + } + return [ + { label: translate("All providers"), value: "" }, + ...Array.from(providerNames).map((provider) => ({ label: provider, value: provider })) + ]; +} + +function overviewModelFilterOptions( + providers: GatewayProviderConfig[], + providerFilter: string, + translate: (value: string) => string +): Array<{ label: string; value: string }> { + const models = new Set(); + for (const provider of providers) { + if (providerFilter && provider.name !== providerFilter) { + continue; + } + for (const rawModel of provider.models) { + const model = rawModel.trim(); + if (model) { + models.add(model); + } + } + } + return [ + { label: translate("All models"), value: "" }, + ...Array.from(models).map((model) => ({ label: model, value: model })) + ]; +} + +function overviewProviderHasModel(providers: GatewayProviderConfig[], providerName: string, modelName: string): boolean { + return providers.some((provider) => + provider.name === providerName && + provider.models.some((model) => model.trim() === modelName) + ); +} + function isEditableKeyboardTarget(target: Element | undefined): boolean { return Boolean(target?.closest("input, textarea, select, [contenteditable='true'], [contenteditable='plaintext-only'], [role='textbox']")); } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index ac9c6569..3abe25be 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -596,6 +596,7 @@ export const appCopy: Record = { "Agent Mix": "Agent 分布", "Agent profiles": "Agent 配置档案", "All agents": "全部 Agent", + "All models": "全部模型", "All providers": "全部供应商", "API key": "API 密钥", "API key created": "API 密钥已创建", diff --git a/tests/renderer/overview-components.test.tsx b/tests/renderer/overview-components.test.tsx index 61050d18..0c3e4043 100644 --- a/tests/renderer/overview-components.test.tsx +++ b/tests/renderer/overview-components.test.tsx @@ -42,7 +42,9 @@ test("OverviewView renders every overview widget type", () => { /> ); - assert.match(html, /

Overview<\/h2>/); + assert.doesNotMatch(html, /

Overview<\/h2>/); + assert.match(html, /All providers/); + assert.match(html, /All models/); assert.match(html, /aria-label="Edit widgets"/); assert.match(html, /System status/); assert.match(html, /API Service/); @@ -79,7 +81,9 @@ test("OverviewView renders the empty widget layout state", () => { /> ); - assert.match(html, /Overview/); + assert.doesNotMatch(html, /

Overview<\/h2>/); + assert.match(html, /All providers/); + assert.match(html, /All models/); assert.match(html, /No widgets configured/); assert.match(html, /aria-label="Edit widgets"/); }); From f9525f469dc7589d6f33af94a2479a07816c476e Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 13 Jul 2026 20:27:52 +0800 Subject: [PATCH 20/38] Add configured web search providers and upstream proxy preload --- .../gateway/application/gateway-service.ts | 6 +- .../gateway/core-runtime/config-compiler.ts | 18 +- .../src/gateway/core-runtime/config-writer.ts | 6 +- .../src/gateway/core-runtime/supervisor.ts | 4 +- .../features/hosted-web-search/discovery.ts | 360 +++++++++++++++++- .../features/hosted-web-search/index.ts | 2 +- .../hosted-web-search/response-transform.ts | 20 +- packages/core/src/gateway/request/pipeline.ts | 30 +- packages/core/src/gateway/service.ts | 2 +- packages/core/src/mcp/fusion-config.ts | 27 +- tests/main/gateway-virtual-models.test.mjs | 133 +++++++ 11 files changed, 553 insertions(+), 55 deletions(-) diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts index 29061591..515d0141 100644 --- a/packages/core/src/gateway/application/gateway-service.ts +++ b/packages/core/src/gateway/application/gateway-service.ts @@ -117,14 +117,14 @@ class GatewayService { } if (shouldRunGateway) { - await writeCoreGatewayConfig(config, this.rawTraceSynchronizer.token, coreAuthToken, this.browserWebSearchMcpIntegration); + await proxyService.refreshUpstreamProxyFromCurrentSystem(); + const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config); + await writeCoreGatewayConfig(config, this.rawTraceSynchronizer.token, coreAuthToken, this.browserWebSearchMcpIntegration, upstreamProxyUrl); 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}`); } - await proxyService.refreshUpstreamProxyFromCurrentSystem(); const runtimeId = randomUUID(); - const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config); this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, coreAuthToken); this.coreAuthToken = coreAuthToken; const managedChild = this.child; diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index 1fa0bb17..3f5b697b 100644 --- a/packages/core/src/gateway/core-runtime/config-compiler.ts +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -11,7 +11,7 @@ import { fusionBuiltinToolArtifacts, fusionToolFallbackMcpServer, normalizeFusio import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery"; import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology"; import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync"; -import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor"; +import { endpoint, resolveUndiciProxyAgentModule, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor"; import { claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared"; import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared"; import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; @@ -23,7 +23,8 @@ export async function compileCoreGatewayConfig( config: AppConfig, rawTraceSyncToken: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + upstreamProxyUrl?: string ): Promise> { const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); const configuredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins([ @@ -37,7 +38,18 @@ export async function compileCoreGatewayConfig( ...pluginService.getVirtualModelProfiles() ])), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile(config, upstreamProxyUrl) : undefined; + const proxyEnv = upstreamProxyUrl + ? { CCR_UPSTREAM_PROXY_URL: upstreamProxyUrl, CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule() } + : undefined; + const builtinToolArtifacts = await fusionBuiltinToolArtifacts( + virtualModelProfiles, + coreEndpoint, + coreAuthToken, + browserWebSearchMcpIntegration, + proxyPreloadFile, + proxyEnv + ); const providers = [ ...config.Providers .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) diff --git a/packages/core/src/gateway/core-runtime/config-writer.ts b/packages/core/src/gateway/core-runtime/config-writer.ts index cfc7310f..7d243d55 100644 --- a/packages/core/src/gateway/core-runtime/config-writer.ts +++ b/packages/core/src/gateway/core-runtime/config-writer.ts @@ -13,7 +13,8 @@ export async function writeCoreGatewayConfig( config: AppConfig, rawTraceSyncToken: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + upstreamProxyUrl?: string ): Promise { assertLoopbackCoreHost(config.gateway.coreHost); mkdirSync(dirname(config.gateway.generatedConfigFile), { @@ -25,7 +26,8 @@ export async function writeCoreGatewayConfig( config, rawTraceSyncToken, coreAuthToken, - browserWebSearchMcpIntegration + browserWebSearchMcpIntegration, + upstreamProxyUrl ); writePrivateTextFile( config.gateway.generatedConfigFile, diff --git a/packages/core/src/gateway/core-runtime/supervisor.ts b/packages/core/src/gateway/core-runtime/supervisor.ts index f2677606..54011127 100644 --- a/packages/core/src/gateway/core-runtime/supervisor.ts +++ b/packages/core/src/gateway/core-runtime/supervisor.ts @@ -69,7 +69,7 @@ function resolveBundledGatewayEntry(): string | undefined { } -function resolveUndiciProxyAgentModule(): string { +export function resolveUndiciProxyAgentModule(): string { const bundled = resolveBundledUndiciProxyAgentModule(); if (bundled) { return bundled; @@ -140,7 +140,7 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u } -function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: string): string { +export function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: string): string { const file = pathJoin(dirname(config.gateway.generatedConfigFile), "gateway-proxy-preload.cjs"); writeFileSync( file, 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 bbe05a1d..0c10b515 100644 --- a/packages/core/src/gateway/features/hosted-web-search/discovery.ts +++ b/packages/core/src/gateway/features/hosted-web-search/discovery.ts @@ -1,10 +1,33 @@ -import type { AppConfig, GatewayProviderProtocol } from "@ccr/core/contracts/app"; +import type { AppConfig, GatewayProviderProtocol, VirtualModelFusionWebSearchProvider } from "@ccr/core/contracts/app"; import { isRecord, numberValue, stringListValue, stringValue } from "@ccr/core/gateway/internal/value"; import { normalizeCoreGatewayVirtualModelProfiles } from "@ccr/core/gateway/core-runtime/config-compiler"; -import { fusionModelNameFromSelector, readFusionWebSearchConfig, withCodexCompatibleVirtualModelProfiles, withFusionVirtualModelAliases } from "@ccr/core/mcp/fusion-config"; +import { browserWebSearchUnavailableMessage, fusionModelNameFromSelector, readFusionWebSearchConfig, withCodexCompatibleVirtualModelProfiles, withFusionVirtualModelAliases } from "@ccr/core/mcp/fusion-config"; import type { AnthropicWebSearchProtocolContext, BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, ClaudeCodeWebSearchContinuationContext, HostedWebSearchProtocolContext } from "@ccr/core/gateway/internal/shared"; import { clampNumber, uniqueStrings } from "@ccr/core/gateway/internal/collections"; +import { defaultFusionWebSearchProvider } from "@ccr/core/gateway/internal/shared"; import { queryMatchScore } from "@ccr/core/gateway/features/hosted-web-search/evidence"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; +import { formatError } from "@ccr/core/gateway/http/io"; + +type FusionWebSearchToolCandidate = { + aliases: string[]; + env?: Record; + provider: VirtualModelFusionWebSearchProvider; + resultCount?: number; + timeoutMs?: number; + toolName: string; +}; + +type WebSearchProviderInput = { + count: number; + env?: Record; + provider: Exclude; + query: string; + timeoutMs: number; + toolName: string; +}; + +type WebSearchProviderResult = BrowserWebSearchProtocolRecord["results"][number]; @@ -421,13 +444,38 @@ export function fusionWebSearchToolNameForRequest(config: AppConfig, model: stri -function fusionWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string }> { +export function fusionWebSearchProviderForToolName(config: AppConfig, toolName: string): VirtualModelFusionWebSearchProvider | undefined { + return fusionWebSearchToolCandidateForToolName(config, toolName)?.provider; +} + + + +export function hostedWebSearchUnavailableMessage(config: AppConfig, toolName: string): string { + const provider = fusionWebSearchProviderForToolName(config, toolName); + if (provider && provider !== "browser") { + return [ + `Fusion web search provider "${provider}" did not return results for tool "${toolName}".`, + "Check the provider API key, endpoint, and network/proxy settings." + ].join(" "); + } + return browserWebSearchUnavailableMessage(toolName); +} + + + +function fusionWebSearchToolCandidateForToolName(config: AppConfig, toolName: string): FusionWebSearchToolCandidate | undefined { + return fusionWebSearchToolCandidates(config).find((candidate) => candidate.toolName === toolName); +} + + + +function fusionWebSearchToolCandidates(config: AppConfig): FusionWebSearchToolCandidate[] { const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; const profiles = normalizeCoreGatewayVirtualModelProfiles( withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), config ); - const candidates: Array<{ aliases: string[]; toolName: string }> = []; + const candidates: FusionWebSearchToolCandidate[] = []; for (const profile of profiles) { if (!isRecord(profile) || profile.enabled === false) { continue; @@ -438,6 +486,7 @@ function fusionWebSearchToolCandidates(config: AppConfig): Array<{ aliases: stri if (!webSearchConfig?.toolName) { continue; } + const provider = webSearchConfig.provider ?? defaultFusionWebSearchProvider; const match = isRecord(profile.match) ? profile.match : undefined; const aliases = uniqueStrings([ stringValue(profile.id), @@ -445,7 +494,14 @@ function fusionWebSearchToolCandidates(config: AppConfig): Array<{ aliases: stri stringValue(profile.displayName), ...stringListValue(match?.exactAliases) ].filter((item): item is string => Boolean(item))); - candidates.push({ aliases, toolName: webSearchConfig.toolName }); + candidates.push({ + aliases, + env: webSearchConfig.env, + provider, + resultCount: webSearchConfig.resultCount, + timeoutMs: webSearchConfig.timeoutMs, + toolName: webSearchConfig.toolName + }); } return candidates; } @@ -454,11 +510,12 @@ function fusionWebSearchToolCandidates(config: AppConfig): Array<{ aliases: stri export async function selectHostedWebSearchProtocolRecords( context: HostedWebSearchProtocolContext, - integration: BrowserWebSearchMcpIntegration + integration?: BrowserWebSearchMcpIntegration, + config?: AppConfig ): Promise { const records = [ ...(context.records ?? []), - ...(integration.recentBrowserWebSearchResults?.({ sinceMs: context.sinceMs, toolName: context.toolName }) ?? []) + ...(integration?.recentBrowserWebSearchResults?.({ sinceMs: context.sinceMs, toolName: context.toolName }) ?? []) ] .filter((record) => record.results.length > 0) .filter(uniqueSearchRecordFilter()) @@ -469,16 +526,292 @@ export async function selectHostedWebSearchProtocolRecords( if (records.length > 0) { return records.slice(0, 8); } - if (!context.queryHint || !integration.runBrowserWebSearch) { + if (!context.queryHint) { return []; } - const record = await integration.runBrowserWebSearch({ - count: Math.trunc(clampNumber(context.maxUses ?? 5, 1, 10)), - prompt: context.queryHint, - timeoutMs: 30_000, + if (integration?.runBrowserWebSearch) { + const record = await integration.runBrowserWebSearch({ + count: Math.trunc(clampNumber(context.maxUses ?? 5, 1, 10)), + prompt: context.queryHint, + timeoutMs: 30_000, + toolName: context.toolName + }); + if (record?.results.length) { + return [record]; + } + } + return config ? selectConfiguredWebSearchProtocolRecords(context, config) : []; +} + + + +async function selectConfiguredWebSearchProtocolRecords( + context: HostedWebSearchProtocolContext, + config: AppConfig +): Promise { + if (!context.queryHint) { + return []; + } + const candidate = fusionWebSearchToolCandidateForToolName(config, context.toolName); + if (!candidate || candidate.provider === "browser") { + return []; + } + const input: WebSearchProviderInput = { + count: Math.trunc(clampNumber(context.maxUses ?? candidate.resultCount ?? 5, 1, 20)), + env: candidate.env, + provider: candidate.provider, + query: context.queryHint, + timeoutMs: Math.trunc(clampNumber(candidate.timeoutMs ?? 15_000, 1_000, 600_000)), toolName: context.toolName + }; + + try { + const results = await runConfiguredWebSearch(input); + if (results.length === 0) { + return []; + } + return [{ + completedAtMs: Date.now(), + engine: candidate.provider, + query: context.queryHint, + results, + searchUrl: searchProviderUrl(candidate.provider, context.queryHint), + toolName: context.toolName + }]; + } catch (error) { + console.warn(`[gateway] Fusion web search provider ${candidate.provider} failed: ${formatError(error)}`); + return []; + } +} + + + +async function runConfiguredWebSearch(input: WebSearchProviderInput): Promise { + switch (input.provider) { + case "brave": + return searchBrave(input); + case "bing": + return searchBing(input); + case "google_cse": + return searchGoogleCse(input); + case "serper": + return searchSerper(input); + case "serpapi": + return searchSerpApi(input); + case "tavily": + return searchTavily(input); + case "exa": + return searchExa(input); + } +} + + + +async function searchBrave(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "BRAVE_SEARCH_API_KEY"); + if (!apiKey) { + console.warn("[gateway] Brave web search API key is not configured."); + return []; + } + const url = new URL(searchEnv(input, "BRAVE_SEARCH_ENDPOINT") || "https://api.search.brave.com/res/v1/web/search"); + url.searchParams.set("q", input.query); + url.searchParams.set("count", String(input.count)); + const raw = await fetchJson(url.toString(), { + headers: { "x-subscription-token": apiKey }, + signal: AbortSignal.timeout(input.timeoutMs) }); - return record?.results.length ? [record] : []; + const items = isRecord(raw) && isRecord(raw.web) && Array.isArray(raw.web.results) ? raw.web.results : []; + return items.map((item) => webSearchResult(item, "title", "url", "description")).filter(isWebSearchProviderResult); +} + + + +async function searchBing(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "BING_SEARCH_API_KEY"); + if (!apiKey) { + console.warn("[gateway] Bing web search API key is not configured."); + return []; + } + const url = new URL(searchEnv(input, "BING_SEARCH_ENDPOINT") || "https://api.bing.microsoft.com/v7.0/search"); + url.searchParams.set("q", input.query); + url.searchParams.set("count", String(input.count)); + url.searchParams.set("mkt", "en-US"); + const raw = await fetchJson(url.toString(), { + headers: { "ocp-apim-subscription-key": apiKey }, + signal: AbortSignal.timeout(input.timeoutMs) + }); + const items = isRecord(raw) && isRecord(raw.webPages) && Array.isArray(raw.webPages.value) ? raw.webPages.value : []; + return items.map((item) => webSearchResult(item, "name", "url", "snippet")).filter(isWebSearchProviderResult); +} + + + +async function searchGoogleCse(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "GOOGLE_SEARCH_API_KEY"); + const cx = searchEnv(input, "GOOGLE_SEARCH_CX"); + if (!apiKey || !cx) { + console.warn("[gateway] Google CSE web search API key or engine ID is not configured."); + return []; + } + const url = new URL(searchEnv(input, "GOOGLE_SEARCH_ENDPOINT") || "https://www.googleapis.com/customsearch/v1"); + url.searchParams.set("key", apiKey); + url.searchParams.set("cx", cx); + url.searchParams.set("q", input.query); + url.searchParams.set("num", String(Math.min(input.count, 10))); + const raw = await fetchJson(url.toString(), { signal: AbortSignal.timeout(input.timeoutMs) }); + const items = isRecord(raw) && Array.isArray(raw.items) ? raw.items : []; + return items.map((item) => webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult); +} + + + +async function searchSerper(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "SERPER_API_KEY"); + if (!apiKey) { + console.warn("[gateway] Serper web search API key is not configured."); + return []; + } + const raw = await fetchJson(searchEnv(input, "SERPER_SEARCH_ENDPOINT") || "https://google.serper.dev/search", { + body: JSON.stringify({ num: input.count, q: input.query }), + headers: { + "content-type": "application/json", + "x-api-key": apiKey + }, + method: "POST", + signal: AbortSignal.timeout(input.timeoutMs) + }); + const items = isRecord(raw) && Array.isArray(raw.organic) ? raw.organic : []; + return items.map((item) => webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult); +} + + + +async function searchSerpApi(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "SERPAPI_API_KEY"); + if (!apiKey) { + console.warn("[gateway] SerpAPI web search API key is not configured."); + return []; + } + const url = new URL(searchEnv(input, "SERPAPI_SEARCH_ENDPOINT") || "https://serpapi.com/search.json"); + url.searchParams.set("api_key", apiKey); + url.searchParams.set("engine", "google"); + url.searchParams.set("q", input.query); + url.searchParams.set("num", String(input.count)); + const raw = await fetchJson(url.toString(), { signal: AbortSignal.timeout(input.timeoutMs) }); + const items = isRecord(raw) && Array.isArray(raw.organic_results) ? raw.organic_results : []; + return items.map((item) => webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult); +} + + + +async function searchTavily(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "TAVILY_API_KEY"); + if (!apiKey) { + console.warn("[gateway] Tavily web search API key is not configured."); + return []; + } + const raw = await fetchJson(searchEnv(input, "TAVILY_SEARCH_ENDPOINT") || "https://api.tavily.com/search", { + body: JSON.stringify({ + api_key: apiKey, + max_results: input.count, + query: input.query, + search_depth: "basic" + }), + headers: { "content-type": "application/json" }, + method: "POST", + signal: AbortSignal.timeout(input.timeoutMs) + }); + const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results : []; + return items.map((item) => webSearchResult(item, "title", "url", "content")).filter(isWebSearchProviderResult); +} + + + +async function searchExa(input: WebSearchProviderInput): Promise { + const apiKey = searchEnv(input, "EXA_API_KEY"); + if (!apiKey) { + console.warn("[gateway] Exa web search API key is not configured."); + return []; + } + const raw = await fetchJson(searchEnv(input, "EXA_SEARCH_ENDPOINT") || "https://api.exa.ai/search", { + body: JSON.stringify({ + numResults: input.count, + query: input.query + }), + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json" + }, + method: "POST", + signal: AbortSignal.timeout(input.timeoutMs) + }); + const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results : []; + return items.map((item) => webSearchResult(item, "title", "url", "text")).filter(isWebSearchProviderResult); +} + + + +async function fetchJson(input: string, init: RequestInit): Promise { + const response = await fetchWithSystemProxy(input, init); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json() as Promise; +} + + + +function searchEnv(input: WebSearchProviderInput, key: string): string | undefined { + return input.env?.[key]?.trim() || process.env[key]?.trim() || undefined; +} + + + +function webSearchResult(item: unknown, titleKey: string, urlKey: string, snippetKey: string): WebSearchProviderResult | undefined { + if (!isRecord(item)) { + return undefined; + } + const title = stringValue(item[titleKey]) || ""; + const url = stringValue(item[urlKey]) || ""; + if (!title && !url) { + return undefined; + } + const snippet = stringValue(item[snippetKey]); + return { + ...(snippet ? { snippet } : {}), + title, + url + }; +} + + + +function isWebSearchProviderResult(value: WebSearchProviderResult | undefined): value is WebSearchProviderResult { + return Boolean(value); +} + + + +function searchProviderUrl(provider: VirtualModelFusionWebSearchProvider, query: string): string { + const encoded = encodeURIComponent(query); + switch (provider) { + case "brave": + return `https://search.brave.com/search?q=${encoded}`; + case "bing": + return `https://www.bing.com/search?q=${encoded}`; + case "google_cse": + return `https://www.google.com/search?q=${encoded}`; + case "serper": + return "https://serper.dev"; + case "serpapi": + return "https://serpapi.com"; + case "tavily": + return "https://tavily.com"; + case "exa": + return "https://exa.ai"; + case "browser": + return `https://www.google.com/search?q=${encoded}`; + } } @@ -523,4 +856,3 @@ function uniqueSearchRecordFilter(): (record: BrowserWebSearchProtocolRecord) => return true; }; } - diff --git a/packages/core/src/gateway/features/hosted-web-search/index.ts b/packages/core/src/gateway/features/hosted-web-search/index.ts index 47ac1420..fff6f3e0 100644 --- a/packages/core/src/gateway/features/hosted-web-search/index.ts +++ b/packages/core/src/gateway/features/hosted-web-search/index.ts @@ -1,4 +1,4 @@ /** Public facade for the hosted web-search protocol bridge. */ export { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, prepareAnthropicWebSearchProtocolRequestBody, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody } from "@ccr/core/gateway/features/hosted-web-search/request-transform"; export { hostedWebSearchProtocolResponseStream, transformAnthropicWebSearchProtocolResponseValue, transformAnthropicWebSearchProtocolSseText, transformGeminiHostedWebSearchResponseValue, transformGeminiHostedWebSearchSseText, transformOpenAiChatHostedWebSearchResponseValue, transformOpenAiChatHostedWebSearchSseText, transformOpenAiResponsesHostedWebSearchResponseValue, transformOpenAiResponsesHostedWebSearchSseText } from "@ccr/core/gateway/features/hosted-web-search/response-transform"; -export { extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/discovery"; +export { extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hostedWebSearchUnavailableMessage, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/discovery"; diff --git a/packages/core/src/gateway/features/hosted-web-search/response-transform.ts b/packages/core/src/gateway/features/hosted-web-search/response-transform.ts index 6c184ee6..f9924906 100644 --- a/packages/core/src/gateway/features/hosted-web-search/response-transform.ts +++ b/packages/core/src/gateway/features/hosted-web-search/response-transform.ts @@ -16,7 +16,8 @@ export function hostedWebSearchProtocolResponseStream( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration | undefined ): Readable { - if (!integration?.recentBrowserWebSearchResults && !integration?.runBrowserWebSearch) { + const hasIntegration = integration?.recentBrowserWebSearchResults !== undefined || integration?.runBrowserWebSearch !== undefined; + if (!hasIntegration && !context.records?.length) { return input; } const contentType = headers.get("content-type")?.toLowerCase() ?? ""; @@ -39,7 +40,9 @@ export function hostedWebSearchProtocolResponseStream( flush(callback) { const body = Buffer.concat(chunks).toString("utf8"); void (async () => { - const records = await selectHostedWebSearchProtocolRecords(context, integration); + const records = context.records?.length + ? context.records + : await selectHostedWebSearchProtocolRecords(context, integration); if (records.length === 0) { this.push(body); return; @@ -61,9 +64,11 @@ export function hostedWebSearchProtocolResponseStream( function hostedWebSearchProtocolSseStream( input: Readable, context: HostedWebSearchProtocolContext, - integration: BrowserWebSearchMcpIntegration + integration: BrowserWebSearchMcpIntegration | undefined ): Readable { - const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + const recordsPromise = context.records?.length + ? Promise.resolve(context.records) + : selectHostedWebSearchProtocolRecords(context, integration); let records: BrowserWebSearchProtocolRecord[] | undefined; let pending = ""; let passThrough = false; @@ -271,9 +276,11 @@ function hostedWebSearchSseFallbackEvents( function anthropicHostedWebSearchProtocolSseStream( input: Readable, context: HostedWebSearchProtocolContext, - integration: BrowserWebSearchMcpIntegration + integration: BrowserWebSearchMcpIntegration | undefined ): Readable { - const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + const recordsPromise = context.records?.length + ? Promise.resolve(context.records) + : selectHostedWebSearchProtocolRecords(context, integration); let records: BrowserWebSearchProtocolRecord[] | undefined; let pending = ""; let passThrough = false; @@ -1243,4 +1250,3 @@ function doneSseEventIndex(events: ParsedSseEvent[]): number { function sseEventIsDone(event: ParsedSseEvent): boolean { return Boolean(event.raw?.includes("[DONE]")); } - diff --git a/packages/core/src/gateway/request/pipeline.ts b/packages/core/src/gateway/request/pipeline.ts index b24e2972..476959ac 100644 --- a/packages/core/src/gateway/request/pipeline.ts +++ b/packages/core/src/gateway/request/pipeline.ts @@ -10,7 +10,6 @@ import { reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; import { recordProviderCredentialOutcome } from "@ccr/core/providers/credential-pool"; import { codexApplyPatchBridgeResponseStream, prepareCodexApplyPatchBridgeRequest } from "@ccr/core/gateway/features/codex-patch-bridge"; import { prepareCursorOpenAICompatChatBody } from "@ccr/core/gateway/features/cursor-compat"; -import { browserWebSearchUnavailableMessage } from "@ccr/core/mcp/fusion-config"; import { filteredResponseHeaders, formatError, forwardHeaders, inferGatewayClient, parseJsonObject, readRequestBody, sendJson, shouldCaptureGatewayUsage, shouldSendBody, stripLocalGatewayAuthHeaders } from "@ccr/core/gateway/http/io"; import { createGatewayModelsResponse, prepareClaudeAppFallbackModelRequest, prepareClaudeCodeDiscoveredModelRequest, shouldServeGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery"; import { resolveProviderLogName, resolveResponseProviderProtocol, sanitizeHeaderValue } from "@ccr/core/providers/runtime-topology"; @@ -20,7 +19,7 @@ import { clientClosedRequestStatusCode, clientDisconnectMessage, UpstreamRequest import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, UpstreamFetchResult } from "@ccr/core/gateway/internal/shared"; import { applyProviderCapabilityRouting, cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor"; import { shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints"; -import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, hostedWebSearchProtocolResponseStream, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/index"; +import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, hostedWebSearchProtocolResponseStream, hostedWebSearchUnavailableMessage, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/index"; export type GatewayRequestPipelineDependencies = { getBrowserWebSearchMcpIntegration: () => BrowserWebSearchMcpIntegration | undefined; @@ -226,19 +225,11 @@ export class GatewayRequestPipeline { sinceMs: startedAt - 1_000 }); - if (hostedWebSearchProtocolContext && !this.browserWebSearchMcpIntegration) { - const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); - const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); - const responseBody = JSON.stringify({ error: { message } }); - writeRequestLog(503, responseHeaders, responseBody, false, message); - sendJson(response, 503, { error: { message } }); - return; - } - - if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) { + if (hostedWebSearchProtocolContext) { const records = await selectHostedWebSearchProtocolRecords( hostedWebSearchProtocolContext, - this.browserWebSearchMcpIntegration + this.browserWebSearchMcpIntegration, + this.config ).catch((error) => { console.warn(`[gateway] Failed to prefetch hosted web search results: ${formatError(error)}`); return [] as BrowserWebSearchProtocolRecord[]; @@ -256,6 +247,14 @@ export class GatewayRequestPipeline { headers["x-ccr-hosted-web-search-context"] = hostedWebSearchProtocolContext.protocol; } } + if (records.length === 0 && !this.browserWebSearchMcpIntegration) { + const message = hostedWebSearchUnavailableMessage(this.config, hostedWebSearchProtocolContext.toolName); + const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); + const responseBody = JSON.stringify({ error: { message } }); + writeRequestLog(503, responseHeaders, responseBody, false, message); + sendJson(response, 503, { error: { message } }); + return; + } } const claudeCodeWebSearchContinuationContext = !hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration @@ -357,7 +356,9 @@ export class GatewayRequestPipeline { hostedWebSearchProtocolContext && (hostedWebSearchResponseContentType.includes("application/json") || hostedWebSearchResponseContentType.includes("text/event-stream")) && - (this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || this.browserWebSearchMcpIntegration?.runBrowserWebSearch) + (hostedWebSearchProtocolContext.records?.length || + this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || + this.browserWebSearchMcpIntegration?.runBrowserWebSearch) ) { responseHeaders.delete("content-length"); } @@ -472,4 +473,3 @@ export class GatewayRequestPipeline { responseBody.pipe(response); } } - diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index f80d7366..cfc29b66 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -12,4 +12,4 @@ export type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, B export { prepareGatewayUpstreamAttemptForTest } from "@ccr/core/gateway/upstream/executor"; export { fallbackRetryDelayAfterNetworkErrorForTest, fallbackRetryDelayAfterStatusForTest } from "@ccr/core/gateway/upstream/retry-policy"; export { shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints"; -export { extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hostedWebSearchProtocolResponseStream, prepareAnthropicWebSearchProtocolRequestBody, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, transformAnthropicWebSearchProtocolResponseValue, transformAnthropicWebSearchProtocolSseText, transformGeminiHostedWebSearchResponseValue, transformGeminiHostedWebSearchSseText, transformOpenAiChatHostedWebSearchResponseValue, transformOpenAiChatHostedWebSearchSseText, transformOpenAiResponsesHostedWebSearchResponseValue, transformOpenAiResponsesHostedWebSearchSseText } from "@ccr/core/gateway/features/hosted-web-search/index"; +export { extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hostedWebSearchProtocolResponseStream, prepareAnthropicWebSearchProtocolRequestBody, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectHostedWebSearchProtocolRecords, transformAnthropicWebSearchProtocolResponseValue, transformAnthropicWebSearchProtocolSseText, transformGeminiHostedWebSearchResponseValue, transformGeminiHostedWebSearchSseText, transformOpenAiChatHostedWebSearchResponseValue, transformOpenAiChatHostedWebSearchSseText, transformOpenAiResponsesHostedWebSearchResponseValue, transformOpenAiResponsesHostedWebSearchSseText } from "@ccr/core/gateway/features/hosted-web-search/index"; diff --git a/packages/core/src/mcp/fusion-config.ts b/packages/core/src/mcp/fusion-config.ts index 0bd850bb..b9c0dd1e 100644 --- a/packages/core/src/mcp/fusion-config.ts +++ b/packages/core/src/mcp/fusion-config.ts @@ -15,7 +15,9 @@ export async function fusionBuiltinToolArtifacts( profiles: unknown[], coreEndpoint: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + proxyPreloadFile?: string, + proxyEnv?: Record ): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { const providers: CoreGatewayProvider[] = []; const mcpServers: GatewayMcpServerConfig[] = []; @@ -49,7 +51,9 @@ export async function fusionBuiltinToolArtifacts( ...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}), ...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {}) }, - name: `fusion-vision-${sanitizedProfileId}` + name: `fusion-vision-${sanitizedProfileId}`, + proxyEnv, + proxyPreloadFile })); } } @@ -82,7 +86,9 @@ export async function fusionBuiltinToolArtifacts( ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), ...(webSearchConfig.env ?? {}) }, - name: `fusion-web-search-${sanitizedProfileId}` + name: `fusion-web-search-${sanitizedProfileId}`, + proxyEnv, + proxyPreloadFile })); } } @@ -97,26 +103,33 @@ export async function fusionBuiltinToolArtifactsForTest( profiles: unknown[], coreEndpoint: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + proxyPreloadFile?: string, + proxyEnv?: Record ): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> { - return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration, proxyPreloadFile, proxyEnv); } function fusionBuiltinMcpServer({ entry, env, - name + name, + proxyEnv, + proxyPreloadFile }: { entry: string; env: Record; name: string; + proxyEnv?: Record; + proxyPreloadFile?: string; }): GatewayMcpServerConfig { return { - args: [entry], + args: proxyPreloadFile ? ["--require", proxyPreloadFile, entry] : [entry], command: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1", + ...(proxyEnv ?? {}), ...env }, name, diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index fa60136e..f109c0e6 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -9,6 +9,7 @@ import { extractHostedWebSearchQueryHint, hostedWebSearchProtocolResponseStream, prepareGatewayUpstreamAttemptForTest, + selectHostedWebSearchProtocolRecords, prepareAnthropicWebSearchProtocolRequestBody, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, @@ -239,6 +240,45 @@ test("gateway config does not inject core auth token into external Fusion vision assert.equal(server.env.VISION_GATEWAY_API_KEY, undefined); }); +test("gateway config passes proxy preload to Fusion built-in MCP runtimes", async () => { + const profiles = [ + { + enabled: true, + id: "fusion-tavily", + key: "fusion-tavily", + metadata: { + fusionWebSearch: { + env: { TAVILY_API_KEY: "tavily-key" }, + provider: "tavily", + toolName: "tavily_web_search" + } + } + } + ]; + const proxyPreloadFile = "/tmp/gateway-proxy-preload.cjs"; + const proxyEnv = { + CCR_UNDICI_MODULE: "/tmp/undici.js", + CCR_UPSTREAM_PROXY_URL: "http://127.0.0.1:8888" + }; + + const artifacts = await fusionBuiltinToolArtifactsForTest( + profiles, + "http://127.0.0.1:3457", + "core-token", + undefined, + proxyPreloadFile, + proxyEnv + ); + const server = artifacts.mcpServers.find((item) => item.name === "fusion-web-search-fusion-tavily"); + + assert.ok(server); + assert.deepEqual(server.args.slice(0, 2), ["--require", proxyPreloadFile]); + assert.equal(server.args[2].endsWith("fusion-vision-mcp.js"), true); + assert.equal(server.env.CCR_UPSTREAM_PROXY_URL, proxyEnv.CCR_UPSTREAM_PROXY_URL); + assert.equal(server.env.CCR_UNDICI_MODULE, proxyEnv.CCR_UNDICI_MODULE); + assert.equal(server.env.TAVILY_API_KEY, "tavily-key"); +}); + test("gateway ignores non-Gemini capabilities on Gemini preset providers", () => { const providerName = "Google Gemini"; const config = { @@ -542,6 +582,65 @@ test("gateway resolves non-browser Fusion web search tools for hosted protocol b assert.equal(fusionWebSearchToolNameForRequest(config, "gpt-5"), undefined); }); +test("gateway prefetches non-browser Fusion web search records without browser integration", async () => { + const requests = []; + const endpoint = "http://127.0.0.1/tavily-search"; + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + assert.equal(String(input), endpoint); + requests.push(JSON.parse(String(init.body))); + return new Response(JSON.stringify({ + results: [ + { content: "The result body", title: "Result title", url: "https://example.test/result" } + ] + }), { 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: { TAVILY_API_KEY: "tavily-key", TAVILY_SEARCH_ENDPOINT: endpoint }, + provider: "tavily", + resultCount: 3, + 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, "tavily"); + assert.equal(records[0].toolName, "research_web_search"); + assert.deepEqual(records[0].results, [ + { snippet: "The result body", title: "Result title", url: "https://example.test/result" } + ]); + assert.equal(requests[0].api_key, "tavily-key"); + assert.equal(requests[0].max_results, 3); + assert.equal(requests[0].query, "search query"); + } finally { + globalThis.fetch = previousFetch; + } +}); + test("gateway config does not create fallback tools for MCP-backed Fusion tools", () => { const profiles = [ { @@ -780,6 +879,40 @@ test("gateway synthesizes final Anthropic text when web search response has no v assert.equal(transformed.value.stop_reason, "end_turn"); }); +test("gateway hosted web search response stream uses prefetched records without browser integration", async () => { + 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 stream = hostedWebSearchProtocolResponseStream( + Readable.from([Buffer.from(JSON.stringify(response), "utf8")]), + new Headers({ "content-type": "application/json; 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" + }, + undefined + ); + + const transformed = JSON.parse(await readStreamText(stream)); + + assert.deepEqual( + transformed.content.map((block) => block.type), + ["thinking", "server_tool_use", "web_search_tool_result", "text"] + ); + assert.match(transformed.content[3].text, /Spot gold traded near \$3,340 per ounce/); +}); + test("gateway synthesizes useful component changelog answers from extracted pages", () => { const response = { content: [ From 21ab38dd47225cd7941e3d8cf35c34576cdb17c2 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 13 Jul 2026 21:57:18 +0800 Subject: [PATCH 21/38] Add Unity2.Ai provider preset and docs --- README.md | 7 +++++++ README_zh.md | 7 +++++++ docs/public/provider-icons/unity2.jpg | Bin 0 -> 29605 bytes .../docs/en/configuration/provider-deeplink.md | 4 ++++ .../docs/zh/configuration/provider-deeplink.md | 4 ++++ docs/src/styles/global.css | 6 ++++++ packages/core/src/providers/presets/index.ts | 2 ++ .../core/src/providers/presets/unity2/index.ts | 15 +++++++++++++++ .../ui/src/assets/provider-icons/unity2.jpg | Bin 0 -> 29605 bytes packages/ui/src/pages/home/shared/options.ts | 2 ++ tests/main/provider-preset-utils.test.mjs | 10 ++++++++++ 11 files changed, 57 insertions(+) create mode 100644 docs/public/provider-icons/unity2.jpg create mode 100644 packages/core/src/providers/presets/unity2/index.ts create mode 100644 packages/ui/src/assets/provider-icons/unity2.jpg diff --git a/README.md b/README.md index 966da243..7b193144 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,13 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co Fenno.ai + + + Unity2.Ai icon +
+ Unity2.Ai +
+ diff --git a/README_zh.md b/README_zh.md index 2f2013b1..1aeaafb8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -256,6 +256,13 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 Fenno.ai + +
+ Unity2.Ai 图标 +
+ Unity2.Ai +
+ diff --git a/docs/public/provider-icons/unity2.jpg b/docs/public/provider-icons/unity2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e83c9e02e2b28cf93818afa12039d87d303db385 GIT binary patch literal 29605 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 TeamoRouterAnthropic / Chat / Responses + + + Unity2.AiOpenAI compatible gateway + code0.aiAnthropic / Chat / Responses diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index a2814711..6bcecfd5 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -78,6 +78,10 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少 TeamoRouterAnthropic / Chat / Responses + + + Unity2.AiOpenAI 兼容网关 + code0.aiAnthropic / Chat / Responses diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 094438bf..25640e25 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -1318,6 +1318,12 @@ h1 { --provider-brand-3: #f4f4f5; } +.doc-markdown a.provider-import-button.provider-unity2 { + --provider-brand: #050505; + --provider-brand-2: #7a7f85; + --provider-brand-3: #f4f5f6; +} + .doc-markdown a.provider-import-button.provider-code0 { --provider-brand: #101214; --provider-brand-2: #267dff; diff --git a/packages/core/src/providers/presets/index.ts b/packages/core/src/providers/presets/index.ts index 2a56d91a..e607f39e 100644 --- a/packages/core/src/providers/presets/index.ts +++ b/packages/core/src/providers/presets/index.ts @@ -15,6 +15,7 @@ import { qiniuAiProviderPreset } from "@ccr/core/providers/presets/qiniu-ai/inde import { runApiProviderPreset } from "@ccr/core/providers/presets/runapi/index"; import { siliconFlowProviderPreset } from "@ccr/core/providers/presets/siliconflow/index"; import { teamoRouterProviderPreset } from "@ccr/core/providers/presets/teamorouter/index"; +import { unity2ProviderPreset } from "@ccr/core/providers/presets/unity2/index"; import { zaiGlobalCodingProviderPreset } from "@ccr/core/providers/presets/zai-global-coding/index"; import { zaiGlobalGeneralProviderPreset } from "@ccr/core/providers/presets/zai-global-general/index"; import { zhipuCnCodingProviderPreset } from "@ccr/core/providers/presets/zhipu-cn-coding/index"; @@ -52,6 +53,7 @@ export const providerPresets: ProviderPreset[] = [ fennoProviderPreset, runApiProviderPreset, teamoRouterProviderPreset, + unity2ProviderPreset, code0ProviderPreset, claudeApiProviderPreset ]; diff --git a/packages/core/src/providers/presets/unity2/index.ts b/packages/core/src/providers/presets/unity2/index.ts new file mode 100644 index 00000000..528b9b49 --- /dev/null +++ b/packages/core/src/providers/presets/unity2/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const unity2ProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["unity2", "unity2.ai", "unity2 ai", "unity 2"], + endpoints: [ + { + baseUrl: "https://unity2.ai/v1", + protocols: ["openai_chat_completions"] + } + ], + id: "unity2", + name: "Unity2.Ai", + websiteUrl: "https://unity2.ai/register?source=claudecoderouter" +}; diff --git a/packages/ui/src/assets/provider-icons/unity2.jpg b/packages/ui/src/assets/provider-icons/unity2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e83c9e02e2b28cf93818afa12039d87d303db385 GIT binary patch literal 29605 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 = { runapi: runapiProviderIconUrl, siliconflow: siliconflowProviderIconUrl, teamorouter: teamorouterProviderIconUrl, + unity2: unity2ProviderIconUrl, "zai-global-coding": zaiGlobalCodingProviderIconUrl, "zai-global-general": zaiGlobalGeneralProviderIconUrl, "zhipu-cn-coding": zhipuCnCodingProviderIconUrl, diff --git a/tests/main/provider-preset-utils.test.mjs b/tests/main/provider-preset-utils.test.mjs index 062f72e5..f8166e72 100644 --- a/tests/main/provider-preset-utils.test.mjs +++ b/tests/main/provider-preset-utils.test.mjs @@ -18,6 +18,9 @@ import { import { qiniuAiProviderPreset } from "../../packages/core/src/providers/presets/qiniu-ai/index.ts"; +import { + unity2ProviderPreset +} from "../../packages/core/src/providers/presets/unity2/index.ts"; const openAiPreset = { aliases: ["OpenAI", "ChatGPT"], @@ -85,6 +88,13 @@ test("sponsor provider presets expose requested endpoints and protocols", () => assert.deepEqual(qiniuAiProviderPreset.endpoints[0]?.protocols, [ "openai_chat_completions" ]); + + assert.equal(unity2ProviderPreset.websiteUrl, "https://unity2.ai/register?source=claudecoderouter"); + assert.equal(providerPresetMatchesBaseUrl(unity2ProviderPreset, "https://unity2.ai/v1/chat/completions"), true); + assert.equal(providerPresetMatchesBaseUrl(unity2ProviderPreset, "https://api.unity2.ai/v1"), false); + assert.deepEqual(unity2ProviderPreset.endpoints[0]?.protocols, [ + "openai_chat_completions" + ]); }); test("provider identity safety does not block branded third-party endpoints", () => { From 99cc7066fb5e5cbe6304467ca292223e1c60b4d6 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 13 Jul 2026 21:58:17 +0800 Subject: [PATCH 22/38] update ai-gateway version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4bbb5fd2..baa425d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "packages/*" ], "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.7", + "@the-next-ai/ai-gateway": "^1.0.8", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", @@ -2310,9 +2310,9 @@ } }, "node_modules/@the-next-ai/ai-gateway": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.7.tgz", - "integrity": "sha512-HJtfjpBgjFEPQ6ub7nFTtEyxzzbrz6V5vQZfJOoHi3Jhpjob3W4h0UenTW6loHanJYucFIEh01SvB//Qj3ulcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.8.tgz", + "integrity": "sha512-IQKlD39YtK3Uhz0lJSKkXtEWf+fAk8J2YW1H1ARpiXa3Bv6VhrVwV++8J4P9JCqJ46ATbKQQKauhminIsrzEGg==", "license": "MIT", "dependencies": { "diff": "^8.0.3", diff --git a/package.json b/package.json index 9b218e18..af4f2328 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.7", + "@the-next-ai/ai-gateway": "^1.0.8", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", From 58998becfac682d9388e53b1991bbf0ff90fe858 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 13 Jul 2026 22:01:48 +0800 Subject: [PATCH 23/38] chore: release 3.0.12 --- package-lock.json | 8 ++++---- package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index baa425d0..e3ad0792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-router-monorepo", - "version": "3.0.11", + "version": "3.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-router-monorepo", - "version": "3.0.11", + "version": "3.0.12", "license": "MIT", "workspaces": [ "packages/*" @@ -9578,7 +9578,7 @@ }, "packages/electron": { "name": "@claude-code-router/electron", - "version": "3.0.11", + "version": "3.0.12", "dependencies": { "better-sqlite3": "^12.11.1" }, @@ -9588,7 +9588,7 @@ }, "packages/ui": { "name": "@claude-code-router/ui", - "version": "3.0.11", + "version": "3.0.12", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index af4f2328..180b084f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-router-monorepo", - "version": "3.0.11", + "version": "3.0.12", "private": true, "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", diff --git a/packages/electron/package.json b/packages/electron/package.json index 1e55555f..7a955292 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/electron", - "version": "3.0.11", + "version": "3.0.12", "private": true, "description": "Claude Code Router Electron desktop shell.", "main": "dist/main/main.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 609fab2c..b2946434 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/ui", - "version": "3.0.11", + "version": "3.0.12", "private": true, "description": "Claude Code Router web management UI.", "dependencies": { From e9f59c54252a3071ca10a55bc845c7dab8d73d59 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 13 Jul 2026 22:35:22 +0800 Subject: [PATCH 24/38] chore: release cli and docker 3.0.4 --- package-lock.json | 4 ++-- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3ad0792..fb55ead0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9542,7 +9542,7 @@ }, "packages/cli": { "name": "@musistudio/claude-code-router", - "version": "3.0.3", + "version": "3.0.4", "license": "MIT", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.4", @@ -9560,7 +9560,7 @@ }, "packages/core": { "name": "@claude-code-router/core", - "version": "3.0.3", + "version": "3.0.4", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.4", "@the-next-ai/bot-gateway-sdk": "^0.1.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index ef8d9215..55ed7ec1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@musistudio/claude-code-router", - "version": "3.0.3", + "version": "3.0.4", "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", "repository": { diff --git a/packages/core/package.json b/packages/core/package.json index 91f6862c..97c793f8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/core", - "version": "3.0.3", + "version": "3.0.4", "private": true, "description": "Claude Code Router core gateway, routing, provider, and storage services.", "main": "dist/main/server.js", From f1930b249c2f4017201faa0ea6305137a5d75c16 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 14 Jul 2026 06:23:02 +0800 Subject: [PATCH 25/38] Refactor router pipeline and improve provider handling --- README.md | 23 +- README_zh.md | 23 +- docker/README.md | 362 +++++++++++--- .../en/configuration/configuration-file.md | 4 +- .../content/docs/en/configuration/server.md | 28 ++ docs/src/content/docs/en/guides.md | 16 +- docs/src/content/docs/en/guides/cli.md | 161 ++++++ docs/src/content/docs/en/guides/docker.md | 198 ++++++++ docs/src/content/docs/en/guides/install.md | 65 ++- docs/src/content/docs/en/index.md | 11 +- .../docs/zh/configuration/config-file.md | 4 +- .../content/docs/zh/configuration/server.md | 28 ++ docs/src/content/docs/zh/guides.md | 16 +- docs/src/content/docs/zh/guides/cli.md | 220 ++++++++ docs/src/content/docs/zh/guides/docker.md | 297 +++++++++++ docs/src/content/docs/zh/guides/install.md | 63 ++- docs/src/content/docs/zh/index.md | 11 +- docs/src/i18n/content.ts | 8 + docs/src/pages/en/guides/[slug].astro | 2 + docs/src/pages/guides/[slug].astro | 2 + packages/cli/README.md | 473 ++++++------------ packages/cli/README_zh.md | 472 ++++++----------- packages/cli/src/cli.ts | 42 +- 23 files changed, 1749 insertions(+), 780 deletions(-) create mode 100644 docs/src/content/docs/en/guides/cli.md create mode 100644 docs/src/content/docs/en/guides/docker.md create mode 100644 docs/src/content/docs/zh/guides/cli.md create mode 100644 docs/src/content/docs/zh/guides/docker.md diff --git a/README.md b/README.md index 7b193144..007d0360 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Instead of wiring every agent to every model service by hand, CCR centralizes th ## Documentation -Read the full documentation at [ccrdesk.top](https://ccrdesk.top/). +Read the full documentation at [ccrdesk.top](https://ccrdesk.top/), including the [CLI reference](https://ccrdesk.top/en/guides/cli/) and [Docker deployment guide](https://ccrdesk.top/en/guides/docker/). ## Download And Install @@ -98,12 +98,31 @@ Read the full documentation at [ccrdesk.top](https://ccrdesk.top/). 3. Install and launch **Claude Code Router**. 4. On first launch, CCR creates its local configuration database: - macOS/Linux: `~/.claude-code-router/config.sqlite` - - Windows: `%APPDATA%\Claude Code Router\config.sqlite` + - Windows: `%APPDATA%\claude-code-router\config.sqlite` CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists. After the service is started from the **Server** page, CCR listens on `http://127.0.0.1:3456` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status. +## CLI And Docker + +The npm CLI requires Node.js 22 or newer and provides the browser management UI, gateway, and Agent Config launch commands without Electron: + +```sh +npm install -g @musistudio/claude-code-router +ccr ui +``` + +The CLI management UI defaults to `http://127.0.0.1:3458`, while its model gateway defaults to `http://127.0.0.1:3456`. See the [complete CLI reference](https://ccrdesk.top/en/guides/cli/) for background/foreground service commands, options, profile launching, authentication, and data locations. + +To run the browser UI and gateway behind one Nginx port with persistent Docker storage: + +```sh +docker compose up -d --build +``` + +Docker exposes both management and gateway routes at `http://127.0.0.1:3458` by default. Read the [Docker deployment guide](https://ccrdesk.top/en/guides/docker/) before remote exposure; it covers the internal port topology, management and gateway authentication, `CCR_PUBLIC_BASE_URL`, volumes, backup/restore, upgrades, and health checks. + ## Quick Start CCR can be configured entirely from the desktop UI. Use this setup order for a clean first run. diff --git a/README_zh.md b/README_zh.md index 1aeaafb8..8dfa475d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -85,7 +85,7 @@ Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 ## 文档 -完整文档见 [ccrdesk.top](https://ccrdesk.top/)。 +完整文档见 [ccrdesk.top](https://ccrdesk.top/),其中包括 [CLI 命令参考](https://ccrdesk.top/guides/cli/) 和 [Docker 部署指南](https://ccrdesk.top/guides/docker/)。 ## 下载和安装 @@ -98,12 +98,31 @@ Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 3. 安装并启动 **Claude Code Router**。 4. 首次启动后,CCR 会创建本地配置数据库: - macOS/Linux:`~/.claude-code-router/config.sqlite` - - Windows:`%APPDATA%\Claude Code Router\config.sqlite` + - Windows:`%APPDATA%\claude-code-router\config.sqlite` CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。 从 **服务** 页面启动后,CCR 默认监听 `http://127.0.0.1:3456`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。 +## CLI 与 Docker + +npm CLI 要求 Node.js 22 或更高版本,不依赖 Electron,也能提供浏览器管理界面、模型网关和 Agent 配置启动命令: + +```sh +npm install -g @musistudio/claude-code-router +ccr ui +``` + +CLI 管理界面默认是 `http://127.0.0.1:3458`,模型网关默认是 `http://127.0.0.1:3456`。后台 / 前台服务、全部选项、Profile 启动、鉴权和数据位置见[完整 CLI 参考](https://ccrdesk.top/guides/cli/)。 + +如果要使用单一 Nginx 端口和持久化 Docker 数据卷运行管理 UI 与网关: + +```sh +docker compose up -d --build +``` + +Docker 默认把管理和网关路径都发布在 `http://127.0.0.1:3458`。远程暴露前请先阅读 [Docker 部署指南](https://ccrdesk.top/guides/docker/),其中包含内部端口拓扑、管理与网关鉴权、`CCR_PUBLIC_BASE_URL`、数据卷、备份恢复、升级和健康检查。 + ## 快速开始 CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序操作。 diff --git a/docker/README.md b/docker/README.md index 2b51a1f3..7a929ffe 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,68 +1,214 @@ -# Docker deployment +# Docker Deployment -This image runs the core server package with PM2 and serves the built UI package -through Nginx. Nginx is the only published entrypoint: it serves the UI, proxies -management API calls to the internal core server, and proxies gateway API calls -to the internal gateway listener. +[中文说明](#中文说明) · [Project documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router) -## Build and run +The Docker image runs the CCR core server under PM2 and serves the built management UI through Nginx. Nginx is the only public container entrypoint: the browser UI, management RPC, gateway API, and health route all share one published port. + +The image is intended for a persistent gateway and browser-based administration. It does not include Electron, the npm `ccr` command, system tray features, desktop Agent/App launching, automatic desktop updates, or desktop-only browser integrations. + +## Architecture And Ports + +```text +host:3458 -> container Nginx:8080 + |-> static management UI + |-> management RPC: 127.0.0.1:3459 + |-> gateway: 127.0.0.1:3456 + `-> gateway core: 127.0.0.1:3457 +``` + +Only Nginx port `8080` should be published. The three internal ports are container implementation details and should not be exposed individually. + +Nginx routes: + +| Public route | Purpose | +| --- | --- | +| `/` and `/pages/home/index.html` | Browser management UI. `/` redirects to a URL containing the management token. | +| `/api/ccr/rpc` | Authenticated management RPC. | +| `/health` | Gateway health, not container/UI health. It can return `502` until a provider and model are configured and the gateway starts. | +| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Supported model and MCP gateway requests. | + +## Quick Start With Compose + +From the repository root: ```sh -docker compose up --build +docker compose up -d --build +docker compose logs -f ccr ``` -Then open: +Open . On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**. -- Web UI: -- Gateway endpoint: - -`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image -runs separate container-private listeners for management RPC, API gateway -routing, and the core gateway runtime. They are implementation details and are -not published or configured by the default Compose file. - -To use a different host port, change the Compose port mapping and keep the -public router endpoint in sync: +The repository Compose file publishes `3458:8080`, stores data in the `ccr-data` named volume, and restarts the service unless explicitly stopped. A mapping without a host IP binds on every host interface. For local-only access, change it to: ```yaml -services: - ccr: - ports: - - "8088:8080" - environment: - CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 +ports: + - "127.0.0.1:3458:8080" ``` -The container stores config and SQLite databases under `/data`, backed by the -`ccr-data` volume in `docker-compose.yml`. +Stop or remove the container without deleting its named volume: -On a fresh data volume, the Web UI starts immediately. The gateway endpoint is -available through the same Nginx entrypoint, but the gateway only starts after at -least one provider and model are configured. +```sh +docker compose stop +docker compose down +``` -## Image scripts +Do not add `--volumes` to `docker compose down` unless you intentionally want to delete all persisted CCR data. + +## `docker run` + +Build and run without Compose: + +```sh +docker build -t claude-code-router:local . +docker run -d \ + --name claude-code-router \ + --restart unless-stopped \ + -p 127.0.0.1:3458:8080 \ + -e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \ + -v ccr-data:/data \ + claude-code-router:local +``` + +Equivalent repository scripts are available: ```sh npm run docker:build npm run docker:run ``` -## Smoke test +`npm run docker:run` uses port `3458` and the `ccr-data` volume, but runs an ephemeral container without a fixed name or restart policy. -```sh -npm run test:docker +## Authentication And Network Security + +There are two independent authentication layers: + +1. `CCR_WEB_AUTH_TOKEN` protects management RPC. Nginx puts it into the management-page URL, and the browser sends it to RPC as `x-ccr-web-auth`. +2. CCR client API keys created in the **API Keys** page protect model gateway requests. These are separate from upstream provider credentials. + +If `CCR_WEB_AUTH_TOKEN` is unset, the entrypoint generates a new random token on each container start. Opening `/` still works because Nginx redirects to a tokenized URL, but a stable token is recommended for persistent or remote deployments. + +Avoid putting the token directly in shell history. Create a protected environment file instead: + +```dotenv +CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value +CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 ``` -The smoke test builds the image, starts an isolated temporary container with a -special-character `CCR_WEB_AUTH_TOKEN`, verifies that only the Nginx port is -published, checks UI and RPC authentication, confirms legacy Docker config is -migrated to the public Nginx router endpoint, and removes its temporary -container and volume. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an already -built image. +Then use it with `docker run --env-file` or map the same variables under the Compose service's `environment` section. Keep this file out of version control. -The Dockerfile uses `node:22-bookworm` for build and native SQLite dependency -installation, then copies the production dependencies into a smaller -`node:22-bookworm-slim` runtime image. To use different base images: +Security guidance: + +- Bind the published port to `127.0.0.1` unless LAN or remote access is intentional. +- Never expose the management UI over untrusted networks without TLS, a firewall/private network, and a fixed strong management token. +- Treat tokenized management URLs as secrets; URLs may be recorded in browser history, proxy logs, screenshots, and support tickets. +- Create scoped CCR client API keys before exposing gateway routes. Do not reuse upstream provider credentials as client keys. +- Protect `/data` and its backups because they contain configuration, provider credentials, CCR client keys, request data, and generated certificates. + +## Changing The Public Address + +The host-facing URL is separate from the container's internal ports. Whenever the host port, hostname, or scheme changes, set `CCR_PUBLIC_BASE_URL` to the exact URL clients should use: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:8088:8080" + environment: + CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +`CCR_PUBLIC_BASE_URL` is written to CCR's public router endpoint. It does not publish a Docker port by itself. + +For a reverse proxy or ingress that terminates HTTPS: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:3458:8080" + environment: + CCR_PUBLIC_BASE_URL: https://ccr.example.com + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +Proxy all paths to Nginx and preserve streaming. The external proxy should allow long-lived responses and should not buffer SSE/model streams. Keep the host port private when the reverse proxy is the public entrypoint. + +## Persistent Data + +The entrypoint sets `HOME=/data`, so CCR stores files under: + +```text +/data/.claude-code-router/ +├── config.sqlite +├── gateway.config.json +├── app-data/ +│ ├── api-keys.sqlite +│ ├── request-logs.sqlite +│ ├── usage.sqlite +│ └── certs/ +├── profiles/ +└── bin/ +``` + +Use a named volume unless a bind mount is operationally required. Bind mounts must be writable by the container and should not be shared by two running CCR containers. + +The first-run bootstrap writes a minimal legacy `config.json` only when neither `config.json` nor `config.sqlite` exists. When the UI saves current settings, SQLite becomes authoritative. By default, every container start also synchronizes the stored gateway listener and `routerEndpoint` to the Docker public endpoint. + +## Backup And Restore + +The safest application-level backup is **Settings → Export data**. For a full volume backup, stop writes before copying the data directory: + +```sh +docker compose stop ccr +docker compose cp ccr:/data/. ./ccr-data-backup/ +docker compose start ccr +``` + +Keep the backup private. It contains secrets and may include request/response data. + +For a full restore, use a new empty volume or empty `/data` directory, copy the backup contents into it while the CCR container is stopped, then start the container. Do not overlay an old backup onto a populated live volume: stale SQLite WAL/SHM files and newer runtime files can produce an inconsistent result. Make a second backup before replacing existing data. + +## Upgrade And Rollback + +Back up `/data`, update the source revision, rebuild with fresh base layers, and recreate the service: + +```sh +git pull +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=200 ccr +``` + +Configuration migrations run against the persistent data. To roll back, use the previous image/source revision together with a backup created before the upgrade; do not assume a newer database can always be read by an older build. + +## Environment Variables + +Most deployments should set only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping. Internal listener values normally should remain unchanged. + +| Variable | Default | Description | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | Random per container start | Management UI/RPC token. Set a stable strong value for persistent or remote use. | +| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public gateway/UI base URL written into CCR configuration. Overrides `CCR_PUBLIC_HOST` and `CCR_PUBLIC_PORT`. | +| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset; it does not change Docker port publishing. | +| `CCR_PUBLIC_PORT` | `3458` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset. | +| `CCR_DATA_DIR` | `/data` | Container data root and process `HOME`. Mount persistent storage here. | +| `CCR_NGINX_PORT` | `8080` | Container-private Nginx listen port. Match the container side of the published mapping if changed. | +| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management server host. | +| `CCR_WEB_PORT` | `3459` | Container-private management server port. | +| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private gateway listener host. | +| `CCR_GATEWAY_PORT` | `3456` | Container-private gateway listener port used by Nginx. | +| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core gateway runtime port. | +| `CCR_NO_GATEWAY` | `0` | Set to `1`, `true`, or `yes` to run the management UI without starting the gateway at boot. | +| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable minimal first-run `config.json` bootstrap. | +| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Set to `0` to stop startup from syncing existing JSON/SQLite listener and public endpoint fields to Docker values. | + +Changing internal ports requires corresponding Nginx/PM2 variables and offers no benefit in normal deployments. Publish only `CCR_NGINX_PORT`. + +## Build Options And Smoke Test + +The Dockerfile builds native dependencies with `node:22-bookworm`, then copies production dependencies and built assets into `node:22-bookworm-slim`. Override the base images when required: ```sh docker build \ @@ -71,21 +217,123 @@ docker build \ -t claude-code-router:local . ``` -## Environment +Run the isolated Docker smoke test: -Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`, -and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not -`http://127.0.0.1:3458`. +```sh +npm run test:docker +``` -| Variable | Default | Description | -| --- | --- | --- | -| `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. | -| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. | -| `CCR_DATA_DIR` | `/data` | Container data root. | -| `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. | -| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. | -| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Sync existing Docker config to the Nginx public router endpoint on startup. | +The test builds the image, starts a temporary container and volume, verifies that only Nginx is published, checks UI/RPC authentication, tests public-endpoint migration, starts a configured gateway, checks `/health`, and removes its resources. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an existing image or `CCR_DOCKER_TEST_IMAGE` to test a different local tag. -The first-run bootstrap writes a minimal legacy `config.json` only when neither -`config.json` nor `config.sqlite` exists in the mounted data directory. Once the -UI saves settings into SQLite, existing persisted configuration takes priority. +## Operations And Troubleshooting + +Useful commands: + +```sh +docker compose ps +docker compose logs -f ccr +docker compose restart ccr +docker compose config +``` + +### `/` returns `302` + +This is expected. Nginx redirects the root URL to the management page and URL-encodes the management token. + +### `/health` returns `502` + +`/health` checks the model gateway, not Nginx or the management UI. On a fresh volume it returns `502` until a provider/model exists and the gateway has started. Use `docker compose ps` for container health and open the UI to configure/start the gateway. + +### The UI returns `401` after a token change + +Open the bare root URL again so Nginx creates a URL with the current token. Close stale tabs and avoid bookmarks that contain an old `ccr_web_token`. + +### Clients still use the old port or hostname + +Update `CCR_PUBLIC_BASE_URL` and recreate the container. Leave `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1` so existing SQLite configuration is synchronized at startup. + +### Configuration disappears after recreation + +Confirm that `/data` is mounted and that the same named volume or bind-mount path is being reused. `docker compose down` keeps named volumes; `docker compose down --volumes` deletes them. + +### A bind mount fails with permission errors + +Verify that the host directory exists, is writable by the container, and is not mounted read-only. Named volumes avoid most host ownership and labeling issues. + +### The container is healthy but model requests fail + +Container health only verifies Nginx/UI reachability. Check **Server** status, provider connectivity, CCR client-key authentication, routing, and request logs. Then inspect `docker compose logs --tail=200 ccr` for startup or runtime errors. + +--- + +## 中文说明 + +Docker 镜像通过 PM2 运行 CCR Core,并由 Nginx 同时提供管理 UI、管理 RPC、模型网关和健康检查。对外只应发布 Nginx 的容器端口 `8080`;`3459`、`3456`、`3457` 都是容器内部实现端口,不应单独暴露。 + +这个镜像面向常驻网关和浏览器管理,不包含 Electron、npm 的 `ccr` 命令、系统托盘、桌面 Agent/App 启动、桌面自动更新和桌面专属浏览器集成。 + +### 快速启动 + +```sh +docker compose up -d --build +docker compose logs -f ccr +``` + +打开 。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。 + +仓库默认映射是 `3458:8080`,会监听宿主机所有网卡。如果只允许本机访问,请改为: + +```yaml +ports: + - "127.0.0.1:3458:8080" +``` + +### 鉴权与远程访问 + +- `CCR_WEB_AUTH_TOKEN` 用于管理 UI / RPC;不设置时,每次容器启动都会生成新的随机 Token。 +- **API 密钥** 页面创建的 CCR 客户端 Key 用于模型网关请求。 +- 上游供应商凭据是第三类凭据,不应拿来代替 CCR 客户端 Key。 + +根路径会重定向到包含 `ccr_web_token` 的管理 URL。请把该 URL 当作密码。远程部署至少应使用固定强 Token、TLS、主机防火墙或私网,并让反向代理把全部路径转发到 Nginx。流式响应和 SSE 不应被代理缓冲。 + +外部端口、域名或协议变化时,必须同步设置公开地址: + +```yaml +environment: + CCR_PUBLIC_BASE_URL: https://ccr.example.com + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +`CCR_PUBLIC_BASE_URL` 只负责写入客户端应使用的公开地址,不会自动发布 Docker 端口。 + +### 数据、备份与升级 + +数据实际位于 `/data/.claude-code-router/`,其中包括 `config.sqlite`、`app-data/`、Agent 配置和生成文件。优先使用命名卷,不要让两个运行中的 CCR 容器共享同一个数据目录。 + +完整文件备份前先停止写入: + +```sh +docker compose stop ccr +docker compose cp ccr:/data/. ./ccr-data-backup/ +docker compose start ccr +``` + +备份包含密钥和请求数据,必须按敏感数据保存。恢复时应复制到新的空卷或空 `/data`,不要把旧备份覆盖到仍有新数据的目录。升级前先备份,然后执行: + +```sh +git pull +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=200 ccr +``` + +### 常见排查 + +- `/` 返回 `302`:正常,Nginx 正在跳转到带管理 Token 的页面。 +- `/health` 返回 `502`:它检查的是模型网关;首次启动尚未配置模型时属于预期行为。 +- 修改 Token 后 UI 返回 `401`:重新打开不带参数的根地址,关闭仍使用旧 Token 的标签页。 +- 重建后配置消失:检查是否仍挂载同一个 `/data` 卷;`docker compose down --volumes` 会删除数据卷。 +- 容器健康但模型请求失败:继续检查服务状态、供应商连通性、CCR 客户端 Key、路由和请求日志;容器健康只表示 Nginx / UI 可访问。 + +完整的环境变量、端口拓扑、远程部署、构建参数和烟雾测试说明见本页英文主体,对应变量名和命令在中英文环境中完全相同。 diff --git a/docs/src/content/docs/en/configuration/configuration-file.md b/docs/src/content/docs/en/configuration/configuration-file.md index 5543fc14..7d84dd61 100644 --- a/docs/src/content/docs/en/configuration/configuration-file.md +++ b/docs/src/content/docs/en/configuration/configuration-file.md @@ -8,7 +8,9 @@ lead: Locate the SQLite configuration database maintained by the CCR desktop app ## Default Locations - **macOS/Linux**: `~/.claude-code-router/config.sqlite` -- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite` +- **Windows**: `%APPDATA%\claude-code-router\config.sqlite` + +Docker sets `HOME=/data`, so its configuration database is `/data/.claude-code-router/config.sqlite`. Persist the complete `/data` directory rather than mounting only one database file. ## Applying Changes diff --git a/docs/src/content/docs/en/configuration/server.md b/docs/src/content/docs/en/configuration/server.md index c671b31a..54a2b2ec 100644 --- a/docs/src/content/docs/en/configuration/server.md +++ b/docs/src/content/docs/en/configuration/server.md @@ -5,6 +5,18 @@ eyebrow: Detailed Configuration lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception and proxying into CCR. --- +## Management And Gateway Addresses Are Separate + +The Host/Port fields under **Server** configure the model gateway, not the browser management page: + +| Distribution | Management entry | Model gateway | +| --- | --- | --- | +| Desktop | App window | `http://127.0.0.1:3456` by default | +| npm CLI | `http://127.0.0.1:3458` by default | `http://127.0.0.1:3456` by default | +| Docker | Public `http://127.0.0.1:3458` by default | Combined into the same public Nginx endpoint | + +CLI `--host`/`--port` options configure management; this page configures the gateway. Docker internal listeners should not be published separately. See [Docker Deployment](../../guides/docker/). + ## Main Fields | Field | Capability | @@ -12,6 +24,20 @@ lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception | Host | Host address the CCR gateway listens on. Common values are `127.0.0.1` and `0.0.0.0`. | | Port | Gateway listening port. Clients should point their API base URL to this port. | +`127.0.0.1` allows local access only; `0.0.0.0` listens on every IPv4 interface. Use a wildcard only for intentional LAN/remote access, together with CCR client API keys, firewall/private-network controls, and TLS at a reverse proxy. + +Management tokens, CCR client API keys, and upstream credentials are separate. Gateway clients use keys created under **API Keys** and should never receive upstream provider credentials. + +## Start And Verify + +1. Add at least one provider and model. +2. Create a client key under **API Keys**. +3. Click **Start** or **Restart**. +4. Confirm Running status and request the gateway `/health` route. +5. Send a minimal model request and inspect the resolved provider/model under Logs. + +A reachable management UI does not prove the gateway is running. Docker returns `502` from `/health` until the gateway starts, and desktop/CLI can keep management available without usable models. + ## Proxy Mode Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HTTPS traffic to CCR. CCR uses MITM interception to identify and decrypt HTTPS requests, then proxies supported model requests into the CCR gateway path. @@ -26,3 +52,5 @@ Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HT | Check Trust | Checks again whether the proxy CA is trusted by the system. | | Proxy status | Shows whether the proxy service is running. | | Restart Proxy | Restarts the proxy service when proxy mode is enabled. | + +Proxy mode changes local networking and certificate trust and is primarily a desktop feature. Container deployments should normally point clients directly at the public CCR Nginx gateway instead of trying to change the host system proxy or install a host CA from inside the container. diff --git a/docs/src/content/docs/en/guides.md b/docs/src/content/docs/en/guides.md index 4bdbf094..5a1ebcf7 100644 --- a/docs/src/content/docs/en/guides.md +++ b/docs/src/content/docs/en/guides.md @@ -7,17 +7,15 @@ lead: Start from installation, connect a provider, let agents send requests thro ## Install And Start CCR -### Download And Install +CCR is available as a desktop app, a Node.js 22+ npm CLI, and a single-entrypoint Docker deployment. -1. Open the [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) page. -2. Download the package for your system: `.dmg` or `.zip` for macOS, `.exe` for Windows, and `.AppImage` for Linux. -3. Install and open **Claude Code Router** like a normal desktop app. +| Distribution | Start entry | Default management | Default model gateway | +| --- | --- | --- | --- | +| Desktop | App UI / `ccr-app` | In-app window | `http://127.0.0.1:3456` | +| npm CLI | `ccr ui` / `ccr serve` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` | +| Docker | `docker compose up -d --build` | Shared `http://127.0.0.1:3458` | Shared Nginx endpoint | -### Start The Service - -Open the **Server** page and click **Start**. After the page shows Running, CCR listens on the default local address `http://localhost:8080`. - -If you want the service to start when the app opens, enable **Auto start** on the Server page. +Use the [installation page](install/) to choose a distribution. See the [CLI reference](cli/) for terminal commands and [Docker Deployment](docker/) for container ports, authentication, persistence, and upgrades. ## Add A Provider diff --git a/docs/src/content/docs/en/guides/cli.md b/docs/src/content/docs/en/guides/cli.md new file mode 100644 index 00000000..02a17046 --- /dev/null +++ b/docs/src/content/docs/en/guides/cli.md @@ -0,0 +1,161 @@ +--- +title: CLI Installation And Reference +pageTitle: CLI Installation And Reference +eyebrow: Quick Start +lead: Run the browser management UI and model gateway from npm, and launch locally installed agents through CCR profiles without Electron. +--- + +## `ccr` And `ccr-app` + +CCR has two related commands: + +| Command | Source | Primary use | +| --- | --- | --- | +| `ccr` | npm package `@musistudio/claude-code-router` | Electron-free management UI, gateway service, and profile launches. | +| `ccr-app` | CCR desktop application | Desktop-managed profile launcher used by commands copied from Agent Config cards. | + +Both distributions use the same local configuration directory, but their command names are not interchangeable. Use the desktop app for tray features, notifications, automatic app updates, and desktop-only browser integrations. Use the npm CLI for headless hosts or external process supervision. + +## Install, Upgrade, Or Remove + +Node.js 22 or newer is required: + +```sh +node --version +npm install -g @musistudio/claude-code-router +ccr --help +``` + +Upgrade or uninstall with npm: + +```sh +npm install -g @musistudio/claude-code-router@latest +npm uninstall -g @musistudio/claude-code-router +``` + +Uninstalling the package does not delete local configuration or databases. If `ccr` is not found, run `npm prefix -g`, add npm's global binary directory to `PATH`, and open a new shell. + +## First Start + +Start the background service and open the UI: + +```sh +ccr ui +``` + +For SSH or headless sessions: + +```sh +ccr ui --no-open +``` + +Then add a provider/model, create a CCR client key under **API Keys**, configure routing if needed, and confirm the gateway is running under **Server**. The management UI defaults to `http://127.0.0.1:3458`; the model gateway defaults to `http://127.0.0.1:3456`. + +The management token and CCR client keys are separate credentials. The first protects UI/RPC access; the second authenticates model gateway requests. + +## Service Command Summary + +| Command | Mode | Purpose | +| --- | --- | --- | +| `ccr start` | Background | Starts management and the gateway, then prints the authenticated management URL. | +| `ccr ui` | Background | Reuses or starts the background service and opens a browser. | +| `ccr stop` | One-shot | Stops the service created by `start` or `ui`. | +| `ccr serve` | Foreground | Runs in the current terminal for logs or process supervision. | +| `ccr web` | Foreground | Alias of `serve`. | +| `ccr ` | Foreground | Launches an enabled Agent Config profile. | + +## Service Options + +```text +ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +ccr stop +``` + +| Option | Description | +| --- | --- | +| `--host ` | Management listener, default `127.0.0.1`. `--host=value` is also accepted. | +| `--port ` | Preferred management port, default `3458`. `--port=value` is also accepted. | +| `--open` / `--no-open` | Enables or disables browser opening. `ui` opens by default. | +| `--gateway` | Explicitly requests model gateway startup; this is the default. | +| `--no-gateway` | Starts management without starting the model gateway during service startup. | + +When the preferred port is occupied, CCR tries following ports and prints the actual URL. `serve` handles `SIGINT` and `SIGTERM`; `ccr stop` manages only detached services. + +## Background Service Reuse + +`start` and `ui` store the process ID, URL, and a private service token in `service.json`. A later invocation verifies both the process and RPC identity before reuse. + +- A valid service is reused rather than duplicated. +- New host, port, or `--no-gateway` options do not reconfigure an already running service. +- A command that requires the gateway can ask the existing management process to start it. +- Stale state is removed before a replacement service starts. + +Stop first when changing listener settings: + +```sh +ccr stop +ccr start --host 127.0.0.1 --port 3458 +``` + +## Launch Agent Config Profiles + +Create and enable a profile under **Agent Config**, then use: + +```text +ccr [cli|app] [-- ] +``` + +Examples: + +```sh +ccr "Codex - Work" +ccr "Codex - Work" app +ccr "Claude - Review" cli -- --model sonnet +ccr profile-id -- --help +``` + +- `--cli` and `--app` are alternatives to the positional surface. +- Put agent arguments after `--` to avoid ambiguity. +- Claude Code, Codex, and Grok default to CLI; ZCode defaults to App. +- Grok supports CLI only; ZCode supports App only. +- Claude App and ZCode App reject trailing agent arguments. +- App launches require a locally installed application and graphical session. +- Only enabled profiles are launchable. Use the profile ID when names are ambiguous. + +Most profiles require the CCR gateway to be running. Grok CLI can create a managed temporary shared service and stops it after the final managed Grok session exits. + +## Configuration And Data + +| Platform | Configuration directory | +| --- | --- | +| macOS / Linux | `~/.claude-code-router` | +| Windows | `%APPDATA%\claude-code-router` | + +Important paths include `config.sqlite`, `app-data/`, `service.json`, `gateway.config.json`, `profiles/`, and generated launch wrappers under `bin/`. Do not edit or copy live SQLite files. Use **Settings → Export data**, or stop CCR before taking a filesystem backup. + +## Authentication And Remote Access + +`CCR_WEB_HOST` and `CCR_WEB_PORT` provide defaults when command-line listener options are omitted. Set `CCR_WEB_AUTH_TOKEN` to keep a stable management UI/RPC token; otherwise a random token is generated for the process. The authenticated management URL contains `ccr_web_token`; treat the full URL as a password. + +Keep the listener on `127.0.0.1` unless remote access is intentional. A remote deployment should use a strong fixed token, firewall/private network controls, and TLS at a trusted reverse proxy. Create separate CCR client API keys for gateway access and protect the data directory because it contains upstream credentials. + +## Process Supervisors + +Use `ccr serve --no-open` with an external supervisor. Fix the service user, `HOME`, listener, and `CCR_WEB_AUTH_TOKEN`. Do not also run a detached `ccr start` service, which can create a second management listener or make both processes compete for the same configuration. + +## Troubleshooting + +- **UI works but gateway requests fail:** add a provider/model and CCR client key, start the gateway under **Server**, and use `ccr serve` to inspect startup errors. +- **The UI is not on port 3458:** the preferred port was occupied; use the printed URL or stop the conflict. +- **Profile not found:** confirm it is enabled, use its ID when names are ambiguous, and re-save it if generated launchers are missing. +- **Old background options remain active:** run `ccr stop`, then start again with the new options. +- **A foreground service does not stop through `ccr stop`:** stop `ccr serve` from its terminal or supervisor. + +## Related Pages + +- [Install And Start CCR](../install/) +- [Agent Config](../../configuration/profiles/) +- [Server](../../configuration/server/) +- [Docker Deployment](../docker/) diff --git a/docs/src/content/docs/en/guides/docker.md b/docs/src/content/docs/en/guides/docker.md new file mode 100644 index 00000000..f3e84138 --- /dev/null +++ b/docs/src/content/docs/en/guides/docker.md @@ -0,0 +1,198 @@ +--- +title: Docker Deployment +pageTitle: Docker Deployment +eyebrow: Quick Start +lead: Run CCR Core and the browser UI behind a single Nginx entrypoint with documented ports, authentication, persistence, upgrades, and troubleshooting. +--- + +## Scope And Limitations + +The image contains CCR Core, the built management UI, PM2, and Nginx. It is intended for a persistent model gateway and browser administration. It does not include Electron, the npm `ccr` command, tray features, host desktop Agent/App launching, desktop automatic updates, or desktop-only browser integrations. + +Use the desktop distribution for local app profiles and tray workflows, or the [CLI](../cli/) for an Electron-free host command. + +## Process And Port Topology + +```text +host 3458 -> container Nginx 8080 + |-> static management UI + |-> management RPC: 127.0.0.1:3459 + |-> model gateway: 127.0.0.1:3456 + `-> core runtime: 127.0.0.1:3457 +``` + +Publish only container port `8080`. The other listeners are implementation details and should remain private. + +| Public route | Purpose | +| --- | --- | +| `/`, `/pages/home/index.html` | Management UI. The root redirects to a tokenized page URL. | +| `/api/ccr/rpc` | Authenticated management RPC. | +| `/health` | Model gateway health, not UI/container health. | +| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Model and MCP gateway routes. | + +## Start With Compose + +From the repository root: + +```sh +docker compose up -d --build +docker compose logs -f ccr +``` + +Open . Add a provider/model, create a CCR client key under **API Keys**, and start the gateway under **Server**. A fresh UI is available immediately, but `/health` can return `502` until the gateway has usable models. + +Stop or remove the container without deleting its volume: + +```sh +docker compose stop +docker compose down +``` + +Do not add `--volumes` unless all persisted CCR data should be deleted. + +The repository mapping `3458:8080` binds every host interface. For local-only access, use: + +```yaml +ports: + - "127.0.0.1:3458:8080" +``` + +## Authentication + +CCR uses three distinct credential types: + +| Credential | Purpose | Location | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | Management UI/RPC | Container environment | +| CCR client API key | Model gateway requests | **API Keys** page | +| Upstream credential | Requests from CCR to a provider | **Providers** page | + +Without `CCR_WEB_AUTH_TOKEN`, the entrypoint generates a new random token for every container start. Opening `/` still works because Nginx redirects to a URL containing the current token. Use a fixed strong token for persistent or remote deployments. + +Keep secrets out of shell history by using an ignored environment file: + +```dotenv +CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value +CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 +``` + +Pass it through `docker run --env-file` or the Compose service `environment`. Treat the complete management URL as a secret because `ccr_web_token` can be captured in browser history, proxy logs, screenshots, and tickets. + +## Change The Public Address + +Changing the host-facing port, hostname, or scheme also requires the exact client URL in `CCR_PUBLIC_BASE_URL`: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:8088:8080" + environment: + CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +`CCR_PUBLIC_BASE_URL` updates CCR's public router endpoint. It does not publish a Docker port. + +For TLS at a reverse proxy or ingress, set the HTTPS URL and keep the host port private: + +```yaml +environment: + CCR_PUBLIC_BASE_URL: https://ccr.example.com + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +Proxy every path, allow long-lived requests and adequate body sizes, and disable buffering for SSE/model streams. Add firewall, private-network, or equivalent access controls before exposing management to an untrusted network. + +## Persistent Data + +The entrypoint sets `HOME=/data`; CCR data is under `/data/.claude-code-router/`, including `config.sqlite`, `gateway.config.json`, `app-data/`, `profiles/`, and generated files under `bin/`. + +Prefer a named volume. A bind mount must be writable by the container, and two running CCR containers must not share the same data directory. + +On a completely empty volume, the entrypoint writes minimal bootstrap `config.json`. Once the UI saves configuration, SQLite is authoritative. Startup also synchronizes persisted listener/router endpoint fields to the Docker public address unless disabled. + +## Backup, Restore, And Upgrade + +Use **Settings → Export data** for an application-level backup. For a complete copy, stop writes first: + +```sh +docker compose stop ccr +docker compose cp ccr:/data/. ./ccr-data-backup/ +docker compose start ccr +``` + +The backup contains secrets and may contain request/response data. Restore into a new empty volume or empty `/data` while the container is stopped. Do not overlay an old copy onto populated live data because SQLite WAL/SHM and newer runtime files can be mixed. + +Upgrade after backing up: + +```sh +git pull +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=200 ccr +``` + +Rollback should pair the previous image/source revision with a pre-upgrade backup; an older build may not understand a newer database. + +## Environment Reference + +Most installations should change only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping. + +| Variable | Default | Description | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | Random per start | Management UI/RPC token. | +| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public URL written to CCR configuration. | +| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive the public URL when the full URL is unset. | +| `CCR_PUBLIC_PORT` | `3458` | Used only to derive the public URL when the full URL is unset. | +| `CCR_DATA_DIR` | `/data` | Data root and process `HOME`. | +| `CCR_NGINX_PORT` | `8080` | Container-private Nginx port. | +| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management host. | +| `CCR_WEB_PORT` | `3459` | Container-private management port. | +| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private model gateway host. | +| `CCR_GATEWAY_PORT` | `3456` | Container-private model gateway port used by Nginx. | +| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core runtime port. | +| `CCR_NO_GATEWAY` | `0` | `1`, `true`, or `yes` starts management without the gateway at boot. | +| `CCR_DOCKER_INIT_CONFIG` | `1` | `0` disables empty-volume bootstrap `config.json`. | +| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | `0` disables startup synchronization of persisted listener/public endpoint fields. | + +Internal ports normally should not change. Publish only `CCR_NGINX_PORT`. + +## Build And Smoke Test + +```sh +docker build \ + --build-arg NODE_IMAGE=node:22-bookworm \ + --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \ + -t claude-code-router:local . + +npm run test:docker +``` + +The smoke test creates temporary resources and verifies the single Nginx entrypoint, UI/RPC auth, public endpoint migration, gateway startup, and `/health`. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an image or `CCR_DOCKER_TEST_IMAGE` to select another local tag. + +## Operations And Troubleshooting + +```sh +docker compose ps +docker compose logs -f ccr +docker compose restart ccr +docker compose config +``` + +- **`/` returns `302`:** expected tokenized management-page redirect. +- **`/health` returns `502`:** the model gateway is not yet configured/running; this is separate from container health. +- **UI returns `401` after a token change:** reopen the bare root URL and close tabs/bookmarks containing the old token. +- **Clients use an old host/port:** update `CCR_PUBLIC_BASE_URL` and recreate the container with endpoint synchronization enabled. +- **Data disappears after recreation:** verify the same `/data` volume is mounted; `docker compose down --volumes` deletes it. +- **Bind mount permission errors:** ensure the host directory exists and is writable, or use a named volume. +- **Container is healthy but requests fail:** inspect Server status, provider connectivity, CCR client-key auth, routing, request logs, and `docker compose logs --tail=200 ccr`. + +## Related Pages + +- [Install And Start CCR](../install/) +- [CLI Installation And Reference](../cli/) +- [Server](../../configuration/server/) +- [API Keys](../../configuration/api-keys/) + diff --git a/docs/src/content/docs/en/guides/install.md b/docs/src/content/docs/en/guides/install.md index cfafdb73..ba9fee9e 100644 --- a/docs/src/content/docs/en/guides/install.md +++ b/docs/src/content/docs/en/guides/install.md @@ -2,17 +2,66 @@ title: Install And Start CCR pageTitle: Install And Start CCR eyebrow: Quick Start -lead: Download the desktop app, install it, and start the local CCR service. +lead: Choose the desktop app, npm CLI, or Docker for the deployment, and distinguish the management address from the model gateway address. --- -## Download And Install +## Choose A Distribution -1. Open the [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) page. -2. Download the package for your system: `.dmg` or `.zip` for macOS, `.exe` for Windows, and `.AppImage` for Linux. -3. Install and open **Claude Code Router** like a normal desktop app. +| Distribution | Best for | Entry | Default management address | Default gateway address | +| --- | --- | --- | --- | --- | +| Desktop app | Daily local use, tray, multi-instance Agent Apps, desktop integrations | App UI, `ccr-app` | In-app window | `http://127.0.0.1:3456` | +| npm CLI | Terminal, SSH, no Electron, external process supervisors | `ccr` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` | +| Docker | Persistent servers and container operations | Nginx | Shared public endpoint | `http://127.0.0.1:3458` with the default mapping | -## Start The Service +In desktop/CLI deployments, management and the model gateway do not use the same port. Do not use CLI management port `3458` as the default model gateway. Docker intentionally combines both through one Nginx endpoint. -Open the **Server** page and click **Start**. After the page shows Running, CCR listens on the default local address `http://localhost:8080`. +## Install The Desktop App -If you want the service to start when the app opens, enable **Auto start** on the Server page. +1. Open [GitHub Releases](https://github.com/musistudio/claude-code-router/releases). +2. Download `.dmg`/`.zip` for macOS, `.exe` for Windows, or `.AppImage` for Linux. +3. Install and open **Claude Code Router**. +4. Add a provider/model, create a client key under **API Keys**, then click **Start** under **Server**. + +When Server shows Running, the model gateway defaults to `http://127.0.0.1:3456`. Enable automatic startup under Server if the gateway should start whenever the app opens. + +## Install The npm CLI + +Node.js 22 or newer is required: + +```sh +npm install -g @musistudio/claude-code-router +ccr ui +``` + +`ccr ui` starts a background service and opens the browser. Use `ccr ui --no-open` on a headless host or `ccr serve --no-open` under a process supervisor. See [CLI Installation And Reference](../cli/) for all commands and profile launches. + +## Use Docker + +From a source checkout: + +```sh +docker compose up -d --build +``` + +Open . Docker publishes one Nginx endpoint shared by management and the gateway. Add a provider/model, create a CCR client key, and start the gateway under Server. See [Docker Deployment](../docker/) for ports, authentication, persistence, backups, and remote access. + +## Verify The Installation + +After configuring a provider, model, and CCR client key: + +1. Confirm Server shows Running. +2. Request `/health` on the deployment's gateway address and expect a `200` running response. +3. Send one minimal model request to a compatible endpoint using the CCR client key. +4. Confirm requested/resolved model, provider, status, and latency under Logs. + +A reachable management UI does not prove that the model gateway is usable. Docker `/health` returning `502` is expected before a provider/model has been configured. + +## Data Locations + +| Distribution | Configuration location | +| --- | --- | +| Desktop / CLI on macOS or Linux | `~/.claude-code-router` | +| Desktop / CLI on Windows | `%APPDATA%\claude-code-router` | +| Docker | `/data/.claude-code-router`; persist `/data` | + +Current configuration is stored in `config.sqlite`. Legacy `config.json` is only a migration source when SQLite does not exist, or an initial Docker bootstrap. Do not edit live SQLite files. diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md index a9a08ba6..2e84f8c7 100644 --- a/docs/src/content/docs/en/index.md +++ b/docs/src/content/docs/en/index.md @@ -12,7 +12,7 @@ The top navigation is split into four standalone pages: | Page | Contents | | --- | --- | | [Documentation](./) | Product positioning, architecture overview, and reading path | -| [Quick Start](guides/) | From installation and provider setup to connecting an agent | +| [Quick Start](guides/) | Desktop, CLI, and Docker installation plus provider and Agent setup | | [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 | @@ -22,7 +22,8 @@ Bot platform guides are child pages under Detailed Configuration. Each platform 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/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. +1. Choose desktop, npm CLI, or Docker on the [installation page](guides/install/), then use the dedicated [CLI](guides/cli/) or [Docker](guides/docker/) guide. +2. Continue through [Quick Start](guides/) to connect a provider and Agent Config. +3. Use request logs to confirm whether requests are passing through CCR. +4. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay. +5. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions. diff --git a/docs/src/content/docs/zh/configuration/config-file.md b/docs/src/content/docs/zh/configuration/config-file.md index fe346779..d97ed814 100644 --- a/docs/src/content/docs/zh/configuration/config-file.md +++ b/docs/src/content/docs/zh/configuration/config-file.md @@ -8,7 +8,9 @@ lead: 找到 CCR 桌面 App 默认维护的 SQLite 配置数据库。 ## 默认位置 - macOS/Linux:`~/.claude-code-router/config.sqlite` -- Windows:`%APPDATA%\Claude Code Router\config.sqlite` +- Windows:`%APPDATA%\claude-code-router\config.sqlite` + +Docker 设置 `HOME=/data`,因此配置数据库位于 `/data/.claude-code-router/config.sqlite`;需要持久化挂载整个 `/data`,而不是只挂载单个数据库文件。 ## 生效方式 diff --git a/docs/src/content/docs/zh/configuration/server.md b/docs/src/content/docs/zh/configuration/server.md index 39422399..e216d404 100644 --- a/docs/src/content/docs/zh/configuration/server.md +++ b/docs/src/content/docs/zh/configuration/server.md @@ -5,6 +5,18 @@ eyebrow: 详细配置 lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MITM 劫持并代理到 CCR 的能力。 --- +## 先区分管理地址和网关地址 + +**服务配置** 中的 Host / Port 指模型网关,不是浏览器管理页面: + +| 运行方式 | 管理入口 | 模型网关 | +| --- | --- | --- | +| 桌面应用 | 应用窗口 | 默认 `http://127.0.0.1:3456` | +| npm CLI | 默认 `http://127.0.0.1:3458` | 默认 `http://127.0.0.1:3456` | +| Docker | 默认公开入口 `http://127.0.0.1:3458` | 由 Nginx 合并到同一公开入口 | + +CLI 的 `--host` / `--port` 配置管理服务;本页字段配置模型网关。Docker 的内部管理和网关端口不应单独发布,详见 [Docker 部署](../../guides/docker/)。 + ## 主字段 | 字段 | 代表的能力 | @@ -12,6 +24,20 @@ lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MI | Host | CCR 网关监听的主机地址。常见值是 `127.0.0.1` 或 `0.0.0.0`。 | | Port | CCR 网关监听端口。客户端需要把 API Base URL 指向这个端口。 | +Host 使用 `127.0.0.1` 时仅本机可访问;`0.0.0.0` 会监听所有 IPv4 网卡。只有在确实需要局域网或远程访问时才使用通配地址,并同时配置 CCR 客户端 API Key、防火墙 / 私网和 TLS 反向代理。 + +管理 Token、CCR 客户端 API Key 和上游供应商凭据彼此独立。客户端访问网关时使用 **API 密钥** 页面创建的 CCR Key,不要直接暴露上游凭据。 + +## 启动和验证 + +1. 至少添加一个供应商和模型。 +2. 在 **API 密钥** 页面创建客户端 Key。 +3. 点击 **启动** 或 **重启**。 +4. 确认状态显示运行中,并请求网关的 `/health`。 +5. 发出最小模型请求,再到请求日志核对最终供应商 / 模型。 + +管理 UI 可访问不代表模型网关已运行。Docker 在网关未启动时会让 `/health` 返回 `502`;桌面版 / CLI 也可能在没有可用模型时只保留管理服务。 + ## 代理模式 代理模式是本地代理能力。开启后,客户端可以把 HTTP/HTTPS 流量交给 CCR;CCR 会通过 MITM 劫持识别和解密 HTTPS 请求,并把可处理的模型请求代理到 CCR 网关链路。 @@ -26,3 +52,5 @@ lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MI | 检查信任 | 重新检测代理 CA 是否已被系统信任。 | | 代理状态 | 显示代理服务当前是否运行。 | | 重启代理 | 代理模式开启时,重新启动代理服务。 | + +代理模式需要操作本机网络和证书信任,主要面向桌面环境。容器部署通常应把客户端直接指向 CCR 的 Nginx 网关入口,不建议依赖容器修改宿主机系统代理或安装宿主机 CA。 diff --git a/docs/src/content/docs/zh/guides.md b/docs/src/content/docs/zh/guides.md index dfb20a1f..dbd0aad2 100644 --- a/docs/src/content/docs/zh/guides.md +++ b/docs/src/content/docs/zh/guides.md @@ -7,17 +7,15 @@ lead: 从安装开始,逐步接入供应商、让 Agent 通过 CCR 发请求 ## 安装并启动 CCR -### 下载安装 +CCR 提供三种发行方式:桌面应用、Node.js 22+ 的 npm CLI,以及 Docker 单入口部署。 -1. 打开 [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) 页面。 -2. 按你的系统下载安装包:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。 -3. 像普通桌面软件一样安装并打开 **Claude Code Router**。 +| 方式 | 启动入口 | 默认管理地址 | 默认模型网关 | +| --- | --- | --- | --- | +| 桌面应用 | 应用界面 / `ccr-app` | 应用内窗口 | `http://127.0.0.1:3456` | +| npm CLI | `ccr ui` / `ccr serve` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` | +| Docker | `docker compose up -d --build` | 与网关共用 `http://127.0.0.1:3458` | 与管理界面共用 Nginx 入口 | -### 启动服务 - -进入 **服务** 页面,点击 **启动**。页面显示运行中后,CCR 会在本机监听默认地址 `http://localhost:8080`。 - -如果希望打开 App 后自动启动服务,可以在服务页面开启自动启动。 +先阅读[安装页](install/)选择发行方式;完整终端命令见 [CLI 参考](cli/),容器端口、鉴权、持久化和升级见 [Docker 部署](docker/)。 ## 接入供应商 diff --git a/docs/src/content/docs/zh/guides/cli.md b/docs/src/content/docs/zh/guides/cli.md new file mode 100644 index 00000000..df411675 --- /dev/null +++ b/docs/src/content/docs/zh/guides/cli.md @@ -0,0 +1,220 @@ +--- +title: CLI 安装与命令参考 +pageTitle: CLI 安装与命令参考 +eyebrow: 快速开始 +lead: 使用 npm 版 CCR 在开发机或无桌面服务器上运行管理界面、模型网关,并按 Agent 配置启动本机工具。 +--- + +## CLI 与桌面版命令的区别 + +CCR 有两个相关命令: + +| 命令 | 来源 | 主要用途 | +| --- | --- | --- | +| `ccr` | npm 包 `@musistudio/claude-code-router` | 不依赖 Electron,启动浏览器管理界面、模型网关和 Agent 配置。 | +| `ccr-app` | CCR 桌面应用 | 桌面版生成的配置启动器;Agent配置卡片复制的命令使用这个名称。 | + +两个发行版会读取同一套本机配置目录,但不要把命令名混用。需要托盘、桌面通知、自动更新和桌面专属浏览器集成时,使用桌面版;需要无桌面部署或由进程管理器托管时,使用 npm CLI。 + +## 安装、升级与卸载 + +CLI 要求 Node.js 22 或更高版本: + +```sh +node --version +npm install -g @musistudio/claude-code-router +ccr --help +``` + +升级和卸载: + +```sh +npm install -g @musistudio/claude-code-router@latest +npm uninstall -g @musistudio/claude-code-router +``` + +卸载 npm 包不会删除 CCR 的本地配置和数据库。 + +如果安装成功但找不到命令,执行 `npm prefix -g`,确认 npm 全局可执行目录已经加入 `PATH`,然后打开一个新终端。 + +## 第一次启动 + +在后台启动 CCR 并打开管理界面: + +```sh +ccr ui +``` + +SSH 或无桌面环境使用: + +```sh +ccr ui --no-open +``` + +随后按这个顺序完成配置: + +1. 添加供应商和至少一个模型。 +2. 在 **API 密钥** 页面创建用于访问网关的 CCR 客户端 Key。 +3. 按需要设置默认模型、路由规则和 Fallback。 +4. 在 **服务** 页面确认网关已经运行。 +5. 把客户端 Base URL 指向界面显示的网关地址。 + +管理界面默认使用 `http://127.0.0.1:3458`,模型网关默认使用 `http://127.0.0.1:3456`。管理 Token 与 CCR 客户端 Key 是两种独立凭据:前者保护 UI / RPC,后者验证模型请求。 + +## 服务命令总览 + +| 命令 | 运行方式 | 用途 | +| --- | --- | --- | +| `ccr start` | 后台 | 启动管理服务和模型网关,打印带认证信息的管理 URL。 | +| `ccr ui` | 后台 | 复用或启动后台服务,并打开浏览器。 | +| `ccr stop` | 一次性 | 停止由 `start` 或 `ui` 启动的后台服务。 | +| `ccr serve` | 前台 | 在当前终端运行,适合查看日志或交给进程管理器。 | +| `ccr web` | 前台 | `serve` 的别名。 | +| `ccr <配置名称或 ID>` | 前台 | 启动一个已启用的 Agent 配置。 | + +## `ccr start` + +```text +ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` + +| 选项 | 说明 | +| --- | --- | +| `--host ` | 管理服务监听地址,默认 `127.0.0.1`。也接受 `--host=value`。 | +| `--port ` | 管理服务首选端口,默认 `3458`。也接受 `--port=value`。 | +| `--open` | 启动后打开浏览器。 | +| `--no-open` | 不打开浏览器。 | +| `--gateway` | 明确要求启动模型网关;这是默认行为。 | +| `--no-gateway` | 只启动管理服务,不在启动阶段拉起模型网关。 | + +如果首选端口被占用,CCR 会继续尝试后续端口并打印实际 URL。端口必须是 `1` 到 `65535` 的整数。 + +## `ccr ui` + +```text +ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` + +`ui` 与 `start` 使用同一个后台服务,但默认会打开浏览器。管理 URL 包含 `ccr_web_token` 查询参数;请把完整 URL 当作密码,不要粘贴到日志、工单或公开截图。 + +## `ccr serve` + +```text +ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` + +`serve` 留在前台,收到 `SIGINT` 或 `SIGTERM` 后关闭管理服务和已配置服务。排查启动错误时优先使用它,因为错误会直接输出到当前终端。 + +`ccr stop` 只管理后台服务。前台 `serve` 应通过当前终端或外部进程管理器停止。 + +## 后台服务的复用规则 + +`start` 和 `ui` 会把进程 ID、URL 和私有服务 Token 写入 `service.json`。再次执行时,CCR 会先验证对应进程和 RPC 身份: + +- 服务有效时直接复用,不会再启动第二个后台进程。 +- 新传入的 Host、Port 和 `--no-gateway` 不会重配已经运行的进程。 +- 如果新命令要求网关运行,CCR 会尝试在现有管理进程中启动网关。 +- 状态文件失效或进程已经退出时,CCR 会清理旧状态并启动新服务。 + +需要修改监听参数时先执行: + +```sh +ccr stop +ccr start --host 127.0.0.1 --port 3458 +``` + +## 按 Agent 配置启动 + +先在 **Agent配置** 中创建并启用配置,然后使用: + +```text +ccr <配置名称或 ID> [cli|app] [-- ] +``` + +示例: + +```sh +ccr "Codex - Work" +ccr "Codex - Work" app +ccr "Claude - Review" cli -- --model sonnet +ccr profile-id -- --help +``` + +规则如下: + +- `--cli` 和 `--app` 可以替代位置形式的 `cli` / `app`。 +- Agent 自己的参数放到 `--` 后,避免与 CCR 选项或入口名冲突。 +- 省略入口时,Claude Code、Codex、Grok CLI 默认使用 CLI,ZCode 默认使用 App。 +- Grok 只支持 CLI,ZCode 只支持 App。 +- Claude App 和 ZCode App 不支持额外 Agent 参数。 +- 启动 App 需要本机安装对应桌面应用,并且当前环境有图形会话。 +- 只有已启用的配置可以启动。名称产生歧义时使用配置 ID。 + +大多数配置要求 CCR 网关已经运行。Grok CLI 是例外:如果服务不存在,它可以自动启动一个受管的临时共享服务,并在最后一个 Grok 会话退出后关闭。 + +## 配置和数据位置 + +| 平台 | 配置目录 | +| --- | --- | +| macOS / Linux | `~/.claude-code-router` | +| Windows | `%APPDATA%\claude-code-router` | + +常见文件和目录: + +| 路径 | 用途 | +| --- | --- | +| `config.sqlite` | 当前应用配置。 | +| `app-data/` | API Key、用量、请求日志、证书等运行数据。 | +| `service.json` | 后台 CLI 服务状态和私有 Token。 | +| `gateway.config.json` | 生成的网关运行配置。 | +| `profiles/` | 按 Agent 配置隔离的文件。 | +| `bin/` | CCR 生成的 Agent 启动包装器。 | + +不要在 CCR 运行时直接编辑或复制活跃 SQLite 文件。优先使用 **Settings → Export data**;文件级备份前先停止 CLI 和桌面应用。 + +## 环境变量与远程访问 + +公开的管理认证变量是: + +| 变量 | 说明 | +| --- | --- | +| `CCR_WEB_HOST` | 省略 `--host` 时使用的管理服务监听地址。 | +| `CCR_WEB_PORT` | 省略 `--port` 时使用的管理服务端口。 | +| `CCR_WEB_AUTH_TOKEN` | 固定管理 UI / RPC Token;不设置时进程会生成随机 Token。 | + +监听到 `0.0.0.0` 会让管理界面进入局域网或外部网络。只有在确实需要时才这样配置,并同时使用固定强 Token、主机防火墙或私网,以及可信反向代理提供的 TLS。 + +模型网关还需要单独创建 CCR 客户端 Key。上游供应商凭据保存在本地数据目录,因此目录和备份都应按敏感数据保护。 + +## 进程管理器示例 + +生产环境应使用 `ccr serve --no-open`,让外部管理器负责重启和日志。启动命令至少应固定工作用户、`HOME`、监听地址和 `CCR_WEB_AUTH_TOKEN`。不要同时运行由 `ccr start` 创建的后台服务,否则可能得到两个管理端口或竞争同一套配置。 + +## 常见问题 + +### UI 能打开,但 `/health` 或模型请求失败 + +管理服务可以在没有可用模型网关时运行。添加供应商和模型、创建 CCR 客户端 Key,然后从 **服务** 页面启动或重启网关。使用 `ccr serve` 查看启动错误。 + +### 实际管理端口不是 3458 + +3458 已被占用,CCR 使用了后续可用端口。以命令打印的 URL 为准;需要固定端口时,先停止冲突进程。 + +### 找不到 Agent 配置 + +确认配置已启用,并检查名称是否重复。CCR 会按 ID、名称、忽略大小写的名称和清理后的名称匹配;多个结果时必须使用 ID。 + +### 提示启动器不存在 + +先打开一次 CCR 或重新保存该 Agent 配置,让 CCR 重新生成 `bin/` 下的启动包装器。 + +### 后台服务无法停止 + +先运行 `ccr stop`。如果状态文件已经失效,命令会清理它并报告服务未运行。前台 `ccr serve` 不受 `ccr stop` 管理,应回到对应终端或进程管理器停止。 + +## 相关页面 + +- [安装并启动 CCR](../install/) +- [Agent配置](../../configuration/profile/) +- [服务配置](../../configuration/server/) +- [Docker 部署](../docker/) diff --git a/docs/src/content/docs/zh/guides/docker.md b/docs/src/content/docs/zh/guides/docker.md new file mode 100644 index 00000000..d27c8a23 --- /dev/null +++ b/docs/src/content/docs/zh/guides/docker.md @@ -0,0 +1,297 @@ +--- +title: Docker 部署 +pageTitle: Docker 部署 +eyebrow: 快速开始 +lead: 使用 Nginx 单入口运行 CCR Core 和浏览器管理界面,并正确处理端口、鉴权、持久化、远程访问、备份和升级。 +--- + +## 适用范围与限制 + +Docker 镜像适合常驻模型网关和浏览器管理。它包含 CCR Core、构建后的管理 UI、PM2 和 Nginx,但不包含: + +- Electron 桌面应用、系统托盘和桌面通知; +- npm 发行版的 `ccr` 命令; +- 从容器中启动宿主机 Claude App、ChatGPT、ZCode 等桌面 App; +- 桌面自动更新和桌面专属的内置浏览器集成。 + +如果主要需求是本机 Agent 多开、托盘或桌面 App 启动,请使用桌面版;如果需要终端命令但不需要容器,请使用 [CLI](../cli/)。 + +## 进程和端口拓扑 + +```text +宿主机 3458 -> 容器 Nginx 8080 + |-> 静态管理 UI + |-> 管理 RPC:127.0.0.1:3459 + |-> 模型网关:127.0.0.1:3456 + `-> Core Runtime:127.0.0.1:3457 +``` + +只应发布 Nginx 的容器端口 `8080`。`3459`、`3456`、`3457` 都是容器内部实现端口,不要分别映射到宿主机。 + +Nginx 对外提供: + +| 路径 | 用途 | +| --- | --- | +| `/`、`/pages/home/index.html` | 管理 UI。根路径会跳转到带管理 Token 的页面。 | +| `/api/ccr/rpc` | 需要管理 Token 的管理 RPC。 | +| `/health` | 模型网关健康状态,不是容器或 UI 健康状态。 | +| `/v1/*`、`/v1beta/*`、`/messages`、`/chat/completions`、`/responses`、`/interactions`、`/mcp/*` | 模型和 MCP 网关接口。 | + +## 使用 Compose 快速启动 + +在仓库根目录执行: + +```sh +docker compose up -d --build +docker compose logs -f ccr +``` + +打开 。新数据卷上管理 UI 会立即可用;模型网关要在添加供应商和模型后才能正常启动。 + +首次配置顺序: + +1. 添加供应商和至少一个模型。 +2. 在 **API 密钥** 页面创建 CCR 客户端 Key。 +3. 在 **服务** 页面启动网关。 +4. 请求 `/health`,确认返回 `200` 和运行状态。 +5. 把客户端 Base URL 指向 `http://127.0.0.1:3458`,并使用刚创建的 CCR 客户端 Key。 + +停止或移除容器不会自动删除命名卷: + +```sh +docker compose stop +docker compose down +``` + +不要给 `docker compose down` 添加 `--volumes`,除非你明确要删除全部 CCR 数据。 + +## 只允许本机访问 + +仓库默认映射 `3458:8080` 会监听宿主机所有网卡。如果只从当前机器访问,修改为: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:3458:8080" +``` + +端口映射左侧是宿主机地址和端口,右侧是 Nginx 容器端口。不要把右侧改为内部网关的 `3456`。 + +## 使用 `docker run` + +不使用 Compose 时: + +```sh +docker build -t claude-code-router:local . +docker run -d \ + --name claude-code-router \ + --restart unless-stopped \ + -p 127.0.0.1:3458:8080 \ + -e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \ + -v ccr-data:/data \ + claude-code-router:local +``` + +仓库也提供 `npm run docker:build` 和 `npm run docker:run`。后者使用 `3458` 和 `ccr-data`,但容器带 `--rm`,没有固定名称和自动重启策略,更适合临时验证。 + +## 三类凭据不要混用 + +| 凭据 | 用途 | 配置位置 | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | 管理 UI / RPC 鉴权 | 容器环境变量 | +| CCR 客户端 API Key | 模型网关请求鉴权 | UI 的 **API 密钥** 页面 | +| 上游供应商凭据 | CCR 调用模型供应商 | UI 的 **供应商** 页面 | + +不设置 `CCR_WEB_AUTH_TOKEN` 时,EntryPoint 每次启动容器都会生成新的随机 Token。打开根地址仍可工作,因为 Nginx 会跳转到包含当前 Token 的 URL;但持久部署和远程部署应固定一个足够长的强 Token。 + +不要把 Token 直接写进 Shell 历史。可以创建不进入版本控制的环境文件: + +```dotenv +CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value +CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 +``` + +通过 `docker run --env-file` 使用,或把同名变量映射到 Compose 服务的 `environment`。包含 `ccr_web_token` 的完整管理 URL 也应按密码保护,因为它可能出现在浏览器历史、反向代理日志、截图和工单中。 + +## 修改外部端口或地址 + +宿主机对外地址与容器内部端口是两层配置。修改宿主机端口时,还要把 `CCR_PUBLIC_BASE_URL` 设置为客户端真实使用的完整地址: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:8088:8080" + environment: + CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +`CCR_PUBLIC_BASE_URL` 会同步到 CCR 的公开 Router Endpoint。它本身不会发布 Docker 端口,也不会改变 Nginx 监听地址。 + +## 域名、HTTPS 与反向代理 + +由反向代理或 Ingress 终止 TLS 时: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:3458:8080" + environment: + CCR_PUBLIC_BASE_URL: https://ccr.example.com + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +反向代理应把全部路径交给 CCR Nginx,并满足: + +- 支持长时间模型请求; +- 不缓冲 SSE 和流式模型响应; +- 允许足够的请求体大小; +- 只有反向代理入口对外公开,宿主机 `3458` 保持仅本机监听; +- 配合防火墙、VPN / 私网或额外访问控制,避免管理界面直接暴露到不可信网络。 + +## 持久化目录 + +EntryPoint 会设置 `HOME=/data`,实际数据位于: + +```text +/data/.claude-code-router/ +├── config.sqlite +├── gateway.config.json +├── app-data/ +│ ├── api-keys.sqlite +│ ├── request-logs.sqlite +│ ├── usage.sqlite +│ └── certs/ +├── profiles/ +└── bin/ +``` + +优先使用命名卷。Bind Mount 目录必须允许容器写入,而且不能让两个运行中的 CCR 容器共享同一份数据。 + +全新数据目录中既没有 `config.json` 也没有 `config.sqlite` 时,EntryPoint 默认写入最小的旧格式 `config.json` 作为首次引导。UI 保存后 SQLite 成为权威配置。每次启动默认还会把 JSON / SQLite 中的网关监听字段和 `routerEndpoint` 同步到当前 Docker 公开地址。 + +## 备份与恢复 + +应用级备份优先使用 **Settings → Export data**。做完整文件备份时,先停止写入: + +```sh +docker compose stop ccr +docker compose cp ccr:/data/. ./ccr-data-backup/ +docker compose start ccr +``` + +备份包含供应商凭据、CCR 客户端 Key,并可能包含请求 / 响应数据,必须按敏感数据保存。 + +完整恢复时,应把备份复制到新的空卷或空 `/data` 目录,并确保容器已停止。不要把旧备份直接覆盖到仍有新数据的活动目录,否则旧 SQLite WAL / SHM 和新运行文件可能混合。替换现有数据前再做一份备份。 + +## 升级与回滚 + +先备份 `/data`,再更新源码、刷新基础镜像并重建: + +```sh +git pull +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=200 ccr +``` + +升级会对持久化数据执行当前版本需要的迁移。回滚时应同时使用旧镜像 / 旧源码和升级前备份,不要假设旧版本一定能读取新版本数据库。 + +## 环境变量完整参考 + +一般部署只需要设置 `CCR_WEB_AUTH_TOKEN`、`CCR_PUBLIC_BASE_URL` 和 Docker Port Mapping。内部监听变量通常不需要修改。 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | 每次启动随机生成 | 管理 UI / RPC Token。持久或远程部署应设置固定强值。 | +| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | 写入 CCR 配置的完整公开地址;设置后优先于 Public Host / Port。 | +| `CCR_PUBLIC_HOST` | `127.0.0.1` | 仅在没有完整公开 URL 时用于拼接公开地址,不会改变 Docker 端口绑定。 | +| `CCR_PUBLIC_PORT` | `3458` | 仅在没有完整公开 URL 时用于拼接公开地址。 | +| `CCR_DATA_DIR` | `/data` | 数据根目录,同时作为进程 `HOME`。 | +| `CCR_NGINX_PORT` | `8080` | Nginx 容器内监听端口,应与 Port Mapping 右侧一致。 | +| `CCR_WEB_HOST` | `127.0.0.1` | 管理服务容器内监听地址。 | +| `CCR_WEB_PORT` | `3459` | 管理服务容器内端口。 | +| `CCR_GATEWAY_HOST` | `127.0.0.1` | 模型网关容器内监听地址。 | +| `CCR_GATEWAY_PORT` | `3456` | Nginx 转发到的模型网关容器内端口。 | +| `CCR_GATEWAY_CORE_PORT` | `3457` | Core Gateway Runtime 容器内端口。 | +| `CCR_NO_GATEWAY` | `0` | 设为 `1`、`true` 或 `yes` 时,启动阶段只运行管理 UI。 | +| `CCR_DOCKER_INIT_CONFIG` | `1` | 设为 `0` 时禁用首次最小 `config.json` 引导。 | +| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | 设为 `0` 时不再在启动时同步已有 JSON / SQLite 的监听和公开地址字段。 | + +修改内部端口需要同时保证 PM2 和 Nginx 变量一致,正常部署没有收益。对外仍然只发布 `CCR_NGINX_PORT`。 + +## 构建和烟雾测试 + +默认使用 `node:22-bookworm` 构建原生依赖,再把生产依赖和构建产物复制到 `node:22-bookworm-slim`。需要替换基础镜像时: + +```sh +docker build \ + --build-arg NODE_IMAGE=node:22-bookworm \ + --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \ + -t claude-code-router:local . +``` + +运行 Docker 烟雾测试: + +```sh +npm run test:docker +``` + +测试会创建临时容器和数据卷,检查单一 Nginx 端口、UI / RPC 鉴权、公开地址迁移、网关启动和 `/health`,最后自动清理。使用 `CCR_DOCKER_TEST_SKIP_BUILD=1` 复用已有镜像,或通过 `CCR_DOCKER_TEST_IMAGE` 指定本地 Tag。 + +## 日常运维命令 + +```sh +docker compose ps +docker compose logs -f ccr +docker compose restart ccr +docker compose config +``` + +`docker compose ps` 显示的是容器健康;`/health` 显示的是模型网关健康。两者不能互相替代。 + +## 常见问题 + +### 根地址返回 `302` + +这是正常行为。Nginx 正在把根地址跳转到带 URL 编码管理 Token 的页面。 + +### `/health` 返回 `502` + +它检查模型网关,不检查 Nginx 或 UI。新数据卷尚未配置供应商 / 模型时会返回 `502`。先打开 UI 完成配置并启动网关。 + +### 修改 Token 后 UI 返回 `401` + +重新打开不带参数的根地址,让 Nginx 生成包含新 Token 的 URL;关闭仍使用旧 `ccr_web_token` 的标签页和书签。 + +### 客户端仍使用旧端口或域名 + +更新 `CCR_PUBLIC_BASE_URL` 并重新创建容器。保持 `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1`,让已有 SQLite 配置在启动时同步。 + +### 重建后配置消失 + +确认 `/data` 仍挂载同一个命名卷或 Bind Mount。`docker compose down` 保留卷,`docker compose down --volumes` 删除卷。 + +### Bind Mount 权限错误 + +确认宿主机目录存在、容器可写且没有只读挂载。命名卷通常可以避免宿主机 UID、所有权和安全标签问题。 + +### 容器健康,但模型请求失败 + +容器健康只代表 Nginx / UI 可访问。继续检查 **服务** 状态、供应商连通性、CCR 客户端 Key、路由和请求日志,并查看: + +```sh +docker compose logs --tail=200 ccr +``` + +## 相关页面 + +- [安装并启动 CCR](../install/) +- [CLI 安装与命令参考](../cli/) +- [服务配置](../../configuration/server/) +- [API 密钥](../../configuration/api-keys/) + diff --git a/docs/src/content/docs/zh/guides/install.md b/docs/src/content/docs/zh/guides/install.md index 02bf0dc7..8a78da9a 100644 --- a/docs/src/content/docs/zh/guides/install.md +++ b/docs/src/content/docs/zh/guides/install.md @@ -2,17 +2,66 @@ title: 安装并启动 CCR pageTitle: 安装并启动 CCR eyebrow: 快速开始 -lead: 下载桌面应用,安装后启动本地 CCR 服务。 +lead: 根据桌面版、npm CLI 或 Docker 的运行场景选择安装方式,并确认管理界面与模型网关的不同地址。 --- -## 下载安装 +## 选择发行方式 + +| 方式 | 适合场景 | 入口 | 默认管理地址 | 默认网关地址 | +| --- | --- | --- | --- | --- | +| 桌面应用 | 日常本机使用、托盘、多开 Agent App、桌面集成 | 应用界面、`ccr-app` | 应用内窗口 | `http://127.0.0.1:3456` | +| npm CLI | 终端、SSH、无 Electron 环境、进程管理器 | `ccr` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` | +| Docker | 常驻服务器、容器运维、统一浏览器入口 | Nginx | 与网关共用公开地址 | `http://127.0.0.1:3458`(默认端口映射) | + +管理 UI 地址和模型网关地址在桌面版 / CLI 中不是同一个端口。不要把 CLI 的管理端口 `3458` 当成默认模型网关端口;Docker 才通过 Nginx 把两者合并到同一公开入口。 + +## 安装桌面应用 1. 打开 [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) 页面。 -2. 按你的系统下载安装包:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。 -3. 像普通桌面软件一样安装并打开 **Claude Code Router**。 +2. 按系统下载:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。 +3. 安装并打开 **Claude Code Router**。 +4. 添加供应商和模型,在 **API 密钥** 中创建客户端 Key,然后从 **服务** 页面点击 **启动**。 -## 启动服务 +页面显示运行中后,模型网关默认监听 `http://127.0.0.1:3456`。需要打开应用时自动启动网关,可在 **服务** 页面开启自动启动。 -进入 **Server** 页面,点击 **Start**。页面显示 Running 后,CCR 会在本机监听默认地址 `http://localhost:8080`。 +## 安装 npm CLI -如果希望打开 App 后自动启动服务,可以在 Server 页面开启 **Auto start**。 +要求 Node.js 22 或更高版本: + +```sh +npm install -g @musistudio/claude-code-router +ccr ui +``` + +`ccr ui` 会启动后台服务并打开浏览器。无桌面环境使用 `ccr ui --no-open`,生产前台托管使用 `ccr serve --no-open`。完整命令和 Profile 启动说明见 [CLI 安装与命令参考](../cli/)。 + +## 使用 Docker + +在源码仓库根目录执行: + +```sh +docker compose up -d --build +``` + +打开 。Docker 只发布 Nginx 单入口,管理 UI 和模型网关共用该地址。首次启动后仍需添加供应商 / 模型、创建 CCR 客户端 Key,并从 **服务** 页面启动网关。端口、鉴权、持久化、备份和远程部署见 [Docker 部署](../docker/)。 + +## 验证安装 + +完成供应商、模型和 CCR 客户端 Key 配置后: + +1. 在 **服务** 页面确认状态为运行中。 +2. 请求当前部署的 `/health`;成功时应返回 `200` 和运行状态。 +3. 用 CCR 客户端 Key 向兼容路径发送一个最小模型请求。 +4. 在 **日志** 页面确认请求模型、最终供应商 / 模型、状态码和耗时。 + +管理界面能打开并不代表模型网关已经可用。没有供应商 / 模型时,Docker 的 `/health` 返回 `502` 属于预期行为。 + +## 数据位置 + +| 方式 | 配置位置 | +| --- | --- | +| 桌面 / CLI(macOS、Linux) | `~/.claude-code-router` | +| 桌面 / CLI(Windows) | `%APPDATA%\claude-code-router` | +| Docker | `/data/.claude-code-router`,应持久化挂载 `/data` | + +CCR 当前配置存储在 `config.sqlite` 中;`config.json` 只在没有 SQLite 配置时作为旧版迁移或 Docker 首次引导来源。不要在 CCR 运行时直接编辑 SQLite。 diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md index 45caad32..298da96c 100644 --- a/docs/src/content/docs/zh/index.md +++ b/docs/src/content/docs/zh/index.md @@ -12,7 +12,7 @@ lead: 了解 CCR 的定位、能力边界和文档结构。需要动手配置时 | 分类 | 内容 | | --- | --- | | [文档](./) | 产品定位、架构概览、阅读路径 | -| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 | +| [快速开始](guides/) | 桌面版、CLI、Docker 安装部署,以及供应商和 Agent 接入流程 | | [详细配置](configuration/overview/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 | | [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 | @@ -22,9 +22,10 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独 第一次使用时可以从这些页面了解 CCR 的主要流程: -1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。 -2. App 的请求日志页面展示请求是否经过 CCR。 -3. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。 -4. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。 +1. 从[安装页](guides/install/)选择桌面版、npm CLI 或 Docker;对应细节见 [CLI](guides/cli/) 和 [Docker](guides/docker/) 页面。 +2. [快速开始](guides/) 继续覆盖供应商接入和 Agent配置。 +3. App 的请求日志页面展示请求是否经过 CCR。 +4. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。 +5. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。 这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index 58a9dc1f..aea1e0e2 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -40,6 +40,8 @@ export const docsContent = { icon: "book", items: [ "安装并启动 CCR", + "CLI 安装与命令参考", + "Docker 部署", "接入供应商", "接入 Agent配置", "日志&观测", @@ -51,6 +53,8 @@ export const docsContent = { sidebarChildren: {}, sidebarLinks: { "安装并启动 CCR": "/guides/install/", + "CLI 安装与命令参考": "/guides/cli/", + "Docker 部署": "/guides/docker/", 接入供应商: "/guides/provider/", "接入 Agent配置": "/guides/agent-profile/", "日志&观测": "/guides/observability/", @@ -197,6 +201,8 @@ export const docsContent = { icon: "book", items: [ "Install And Start CCR", + "CLI Installation And Reference", + "Docker Deployment", "Add A Provider", "Connect Agent Config", "Logs & Observability", @@ -208,6 +214,8 @@ export const docsContent = { sidebarChildren: {}, sidebarLinks: { "Install And Start CCR": "/en/guides/install/", + "CLI Installation And Reference": "/en/guides/cli/", + "Docker Deployment": "/en/guides/docker/", "Add A Provider": "/en/guides/provider/", "Connect Agent Config": "/en/guides/agent-profile/", "Logs & Observability": "/en/guides/observability/", diff --git a/docs/src/pages/en/guides/[slug].astro b/docs/src/pages/en/guides/[slug].astro index 8ed6b4e9..6b75b687 100644 --- a/docs/src/pages/en/guides/[slug].astro +++ b/docs/src/pages/en/guides/[slug].astro @@ -5,6 +5,8 @@ import { enGuideDocs, sectionSlugFromPath } from "../../../section-docs"; export function getStaticPaths() { const activeLabels: Record = { install: "Install And Start CCR", + cli: "CLI Installation And Reference", + docker: "Docker Deployment", provider: "Add A Provider", "agent-profile": "Connect Agent Config", observability: "Logs & Observability", diff --git a/docs/src/pages/guides/[slug].astro b/docs/src/pages/guides/[slug].astro index c90b7810..0eeccfc3 100644 --- a/docs/src/pages/guides/[slug].astro +++ b/docs/src/pages/guides/[slug].astro @@ -5,6 +5,8 @@ import { sectionSlugFromPath, zhGuideDocs } from "../../section-docs"; export function getStaticPaths() { const activeLabels: Record = { install: "安装并启动 CCR", + cli: "CLI 安装与命令参考", + docker: "Docker 部署", provider: "接入供应商", "agent-profile": "接入 Agent配置", observability: "日志&观测", diff --git a/packages/cli/README.md b/packages/cli/README.md index 17dc66b5..f9c47bc6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,375 +1,184 @@ -

Claude Code Router Desktop

+# Claude Code Router CLI -
+[中文](README_zh.md) · [Documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router) -
+`@musistudio/claude-code-router` is the Node.js distribution of Claude Code Router. It provides the `ccr` command, the browser-based management UI, the local model gateway, and profile launch commands without requiring Electron. - - - - - - - -
- - Kimi K2.7 Code sponsor banner - -
- - Kimi Code Subscription -  ·  - API Global -  ·  - API China - -
-

- Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard. -

-

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

-
+Use the CLI on developer machines and headless hosts. If you want the tray, desktop notifications, automatic app updates, or desktop-only browser integrations, install the desktop application instead. -
+## Requirements And Installation -Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. +- Node.js 22 or newer +- A supported upstream model provider, or a locally logged-in agent account that CCR can import +- A locally installed agent executable when using profile launch commands -

- Claude Code Router Desktop screenshot -

+Install globally: -## Why Use CCR +```sh +npm install -g @musistudio/claude-code-router +ccr --help +``` -- Use one local endpoint for multiple agent tools instead of configuring every client separately. -- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand. -- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. -- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. +Upgrade or remove it with npm: -## Features +```sh +npm install -g @musistudio/claude-code-router@latest +npm uninstall -g @musistudio/claude-code-router +``` -- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards. -- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available. -- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites. -- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles. -- **Gateway compatibility**: translate supported client requests through the local CCR model gateway. -- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture. -- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model. - -## Documentation - -Read the full documentation at [ccrdesk.top](https://ccrdesk.top/). - -## Download And Install - -1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases). -2. Download the package for your platform: - - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip` - - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip` - - Windows: `Claude Code Router_.exe` - - Linux: `Claude Code Router_.AppImage` -3. Install and launch **Claude Code Router**. -4. On first launch, CCR creates its local configuration database: - - macOS/Linux: `~/.claude-code-router/config.sqlite` - - Windows: `%APPDATA%\Claude Code Router\config.sqlite` - -CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists. - -After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status. +Removing the package does not delete CCR's local configuration or databases. ## Quick Start -CCR can be configured entirely from the desktop UI. Use this setup order for a clean first run. +Start the background service and open the management UI: -### 1. Add a provider +```sh +ccr ui +``` -Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider. +Then: -### 2. Configure routing +1. Add an upstream provider and at least one model. +2. Create a CCR client key under **API Keys**. +3. Configure routing if the default provider/model is not sufficient. +4. Confirm that the gateway is running under **Server**. +5. Point your client at the gateway URL shown in the UI. The default gateway is `http://127.0.0.1:3456`; the management UI defaults to `http://127.0.0.1:3458`. -Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior. +The management token and CCR client API keys are different credentials. The management token protects the browser UI and RPC API. CCR client keys authenticate model requests sent to the gateway. -Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets. +## Service Commands -### 3. Start the gateway +| Command | Behavior | +| --- | --- | +| `ccr start` | Starts a detached background management service and gateway, then prints its authenticated management URL. | +| `ccr ui` | Reuses or starts the background service and opens the management UI. | +| `ccr stop` | Stops the detached service started by `ccr start` or `ccr ui`. | +| `ccr serve` | Runs the management service and gateway in the foreground. `ccr web` is an alias. | +| `ccr ` | Opens an enabled Agent Config profile by name or ID. | -Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens. +### `ccr start` -### 4. Connect your agent tool +```text +ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` -Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR. +- `--host `: management listener, default `127.0.0.1`. +- `--port `: preferred management port, default `3458`. +- `--open` / `--no-open`: enable or disable opening a browser. +- `--gateway`: explicitly request gateway startup; this is the default. +- `--no-gateway`: start only the management service. -### 5. Monitor and adjust +### `ccr ui` -Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status. +```text +ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` -## Acknowledgements +`ui` opens the browser by default. Use `--no-open` on SSH or other headless sessions. -Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl). +### `ccr serve` -## Support & Sponsoring +```text +ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` -
+`serve` stays attached to the current terminal and handles `SIGINT`/`SIGTERM`. It is the appropriate mode for a process supervisor. `ccr stop` only manages the detached service; stop a foreground server through its terminal or supervisor. -

If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.

+If the preferred management port is occupied, CCR tries the next available ports and prints the actual URL. When `start` or `ui` reuses an existing service, new host, port, and `--no-gateway` choices do not reconfigure that process. Run `ccr stop` first when those settings must change. - - - - - -
- - Support on Ko-fi - -
- One-time support via Ko-fi -
- - Sponsor with PayPal - -
- International sponsorship -
+## Agent Config Profiles - - - - - -
- Alipay -
- Alipay QR code -
- WeChat Pay -
- WeChat Pay QR code -
+Create and enable profiles in **Agent Config**, then launch one by name or ID: -
+```sh +ccr "Codex - Work" +ccr "Codex - Work" app +ccr "Claude - Review" cli -- --model sonnet +ccr profile-id -- --help +``` -### Our Sponsors +The syntax is: -
+```text +ccr [cli|app] [-- ] +``` -

A huge thank you to all our sponsors for their generous support.

+- `--cli` and `--app` are accepted alternatives to the positional surface. +- Put agent-specific arguments after `--` so they cannot be confused with CCR options. +- If the surface is omitted, CCR uses the first surface allowed by the profile: CLI for Claude Code, Codex, and Grok CLI; App for ZCode. +- Grok supports CLI only. ZCode supports App only. Claude App and ZCode App do not accept trailing agent arguments. +- Desktop App launches require that app to be installed and a graphical session to be available. +- Start the CCR service before opening most profiles. A Grok CLI profile can start a temporary shared service automatically and stops it after the last managed session exits. - - - - - - - - - - - - - -
- - Zhipu icon -
- Z智谱 -
-
- - AIHubmix icon -
- AIHubmix -
-
- - BurnCloud icon -
- BurnCloud -
-
- - 302.AI icon -
- 302.AI -
-
- - RunAPI icon -
- RunAPI -
-
- - TeamoRouter icon -
- TeamoRouter -
-
- - Qiniu Cloud AI icon -
- Qiniu Cloud AI -
-
- - Fenno.ai icon -
- Fenno.ai -
-
+The desktop application installs a related command named `ccr-app`. Commands copied from desktop Agent Config cards use `ccr-app`; the npm package documented here installs `ccr`. -

Community Sponsors

+## Configuration And Runtime Files - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
@*更@K*g@R*R@bobleer@*苗@*划
@Clarence-pan@carter003@S*r@*晖@*敏@Z*z
@*然@cluic@*苗@PromptExpert@*应@yusnake
@*飞@董*@*汀@*涯@*:-)@**磊
@*琢@*成@Z*o@*琨@congzhangzh@*_
@Z*m@*鑫@c*y@*昕@witsice@b*g
@*亿@*辉@JACK@*光@W*l@kesku
@biguncle@二吉吉@a*g@*林@*咸@*明
@S*y@f*o@*智@F*t@r*c@qierkang
@*军@snrise-z@*王@greatheart1000@*王@zcutlip
@Peng-YM@*更@*.@F*t@*政@*铭
@*叶@七*o@*青@**晨@*远@*霄
@**吉@**飞@**驰@x*g@**东@*落
@哆*k@*涛@苗大@*呢@d*u@crizcraig
s*s*火*勤**锟*涛**明
*知*语*瓜
+| Platform | Config directory | +| --- | --- | +| macOS / Linux | `~/.claude-code-router` | +| Windows | `%APPDATA%\claude-code-router` | -If your name is masked, please contact me via my homepage email to update it with your GitHub username. +Important files include: -
+- `config.sqlite`: current application configuration. +- `app-data/`: API key, usage, request-log, certificate, and other runtime databases/files. +- `service.json`: state and private token for a detached CLI service. +- `gateway.config.json`: generated gateway runtime configuration. +- `profiles/` and `bin/`: isolated profile configuration and launch wrappers. + +Do not edit or copy live SQLite files while CCR is writing to them. Use the UI export feature, or stop CCR before taking a filesystem backup. + +## Environment And Security + +| Variable | Description | +| --- | --- | +| `CCR_WEB_HOST` | Default management listener when `--host` is omitted. | +| `CCR_WEB_PORT` | Default management port when `--port` is omitted. | +| `CCR_WEB_AUTH_TOKEN` | Fixes the management UI/RPC token instead of generating a random token for the process. | + +The authenticated management URL contains `ccr_web_token` in its query string. Treat that URL like a password and avoid copying it into logs, tickets, or shell history. Bind to `127.0.0.1` unless remote access is intentional. For remote access, use a firewall or private network plus TLS at a trusted reverse proxy. + +Do not expose the gateway without creating CCR client API keys. Upstream provider credentials are stored in CCR's local data directory, so protect that directory and its backups. + +## Troubleshooting + +### `ccr` is not found + +Confirm Node.js is version 22 or later and that npm's global binary directory is on `PATH`: + +```sh +node --version +npm prefix -g +``` + +Open a new shell after installation if your shell caches command paths. + +### The management URL changed ports + +The requested port was already occupied. Use the URL printed by CCR, or stop the conflicting process and restart CCR. + +### The UI opens but the gateway is unavailable + +The management service can run without a usable gateway. Add a provider and model, create a client API key, then start or restart the gateway from **Server**. Check the foreground output from `ccr serve` when diagnosing startup errors. + +### A profile cannot be found + +Only enabled profiles are launchable. Names are matched without case and sanitized names are accepted, but ambiguous names require the profile ID. Re-save the profile if its generated launcher is missing. + +### A background service uses old options + +Stop and recreate it: + +```sh +ccr stop +ccr start --host 127.0.0.1 --port 3458 +``` + +## Docker + +The repository also includes a Docker image for gateway and browser-UI deployments. It does not install the npm `ccr` command into the runtime image. See the [Docker deployment guide](https://github.com/musistudio/claude-code-router/blob/main/docker/README.md). ## License -This project is licensed under the [MIT License](LICENSE). +[MIT](LICENSE) diff --git a/packages/cli/README_zh.md b/packages/cli/README_zh.md index 58c4fef9..6020d0ee 100644 --- a/packages/cli/README_zh.md +++ b/packages/cli/README_zh.md @@ -1,374 +1,184 @@ -

Claude Code Router Desktop

+# Claude Code Router CLI -

- English README - Discord - X - License - 文档 -

+[English](README.md) · [完整文档](https://ccrdesk.top/) · [GitHub](https://github.com/musistudio/claude-code-router) -
+`@musistudio/claude-code-router` 是 Claude Code Router 的 Node.js 发行版。它通过 `ccr` 命令提供浏览器管理界面、本地模型网关和 Agent 配置启动能力,不需要安装 Electron。 - - - - - - - -
- - Kimi K2.7 Code 赞助横幅 - -
- - Kimi Code 订阅 -  ·  - API 中文站 -  ·  - API Global - -
-

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

-

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

-
+CLI 适合开发机和无桌面的服务器。如果你需要系统托盘、桌面通知、应用自动更新或桌面端专属的浏览器集成,请安装桌面应用。 -
+## 环境要求与安装 -Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 +- Node.js 22 或更高版本 +- 一个可用的上游模型供应商,或 CCR 支持导入的本机 Agent 登录态 +- 使用配置启动命令时,本机需要已经安装对应 Agent -

- Claude Code Router Desktop 项目截图 -

+全局安装: -## 为什么使用 CCR +```sh +npm install -g @musistudio/claude-code-router +ccr --help +``` -- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 -- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。 -- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。 +升级或卸载: -## 功能和特性 +```sh +npm install -g @musistudio/claude-code-router@latest +npm uninstall -g @musistudio/claude-code-router +``` -- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。 -- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。 -- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。 -- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。 -- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。 -- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。 -- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。 - -## 文档 - -完整文档见 [ccrdesk.top](https://ccrdesk.top/)。 - -## 下载和安装 - -1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。 -2. 按系统下载对应安装包: - - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip` - - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip` - - Windows:`Claude Code Router_.exe` - - Linux:`Claude Code Router_.AppImage` -3. 安装并启动 **Claude Code Router**。 -4. 首次启动后,CCR 会创建本地配置数据库: - - macOS/Linux:`~/.claude-code-router/config.sqlite` - - Windows:`%APPDATA%\Claude Code Router\config.sqlite` - -CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。 - -从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。 +卸载 npm 包不会删除 CCR 的本地配置和数据库。 ## 快速开始 -CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序操作。 +启动后台服务并打开管理界面: -### 1. 添加 Provider +```sh +ccr ui +``` -打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。 +然后按以下顺序配置: -### 2. 设置路由 +1. 添加上游供应商和至少一个模型。 +2. 在 **API 密钥** 页面创建 CCR 客户端密钥。 +3. 如果默认供应商 / 模型不够用,再配置路由规则。 +4. 在 **服务** 页面确认网关已经运行。 +5. 把客户端指向界面显示的网关地址。网关默认是 `http://127.0.0.1:3456`,管理界面默认是 `http://127.0.0.1:3458`。 -打开 **路由**,添加条件规则,配置请求改写和失败降级。 +管理 Token 和 CCR 客户端 API Key 是两种不同凭据。管理 Token 保护浏览器 UI 和 RPC 接口,CCR 客户端 Key 用于验证发送到模型网关的请求。 -如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。 +## 服务命令 -### 3. 启动网关 +| 命令 | 行为 | +| --- | --- | +| `ccr start` | 在后台启动管理服务和网关,并打印带认证信息的管理 URL。 | +| `ccr ui` | 复用或启动后台服务,然后打开管理界面。 | +| `ccr stop` | 停止由 `ccr start` 或 `ccr ui` 启动的后台服务。 | +| `ccr serve` | 在前台运行管理服务和网关;`ccr web` 是别名。 | +| `ccr <配置>` | 按名称或 ID 打开一个已启用的 Agent 配置。 | -打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。 +### `ccr start` -### 4. 连接 Agent 工具 +```text +ccr start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` -打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。 +- `--host `:管理服务监听地址,默认 `127.0.0.1`。 +- `--port `:管理服务首选端口,默认 `3458`。 +- `--open` / `--no-open`:是否打开浏览器。 +- `--gateway`:明确要求启动模型网关;这是默认行为。 +- `--no-gateway`:只启动管理服务,不启动模型网关。 -### 5. 日常查看和调整 +### `ccr ui` -到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。 +```text +ccr ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` -## 致谢 +`ui` 默认会打开浏览器。在 SSH 或其他无桌面环境中使用 `--no-open`。 -对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。 +### `ccr serve` -## 支持与赞助 +```text +ccr serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway] +``` -
+`serve` 会留在当前终端并处理 `SIGINT` / `SIGTERM`,适合交给进程管理器托管。`ccr stop` 只管理后台服务;前台服务需要在终端或进程管理器中停止。 -

如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。

+如果首选管理端口已被占用,CCR 会继续尝试后续端口并打印实际 URL。`start` 或 `ui` 复用已运行服务时,新传入的 Host、Port 和 `--no-gateway` 不会重配该进程;要修改这些选项,请先运行 `ccr stop`。 - - - - - -
- - 通过 Ko-fi 赞助 - -
- 通过 Ko-fi 单次赞助 -
- - 通过 PayPal 赞助 - -
- 国际赞助通道 -
+## Agent 配置启动 - - - - - -
- 支付宝 -
- 支付宝收款码 -
- 微信支付 -
- 微信支付收款码 -
+先在 **Agent配置** 中创建并启用配置,然后按名称或 ID 启动: -
+```sh +ccr "Codex - Work" +ccr "Codex - Work" app +ccr "Claude - Review" cli -- --model sonnet +ccr profile-id -- --help +``` -### 我们的赞助商 +完整语法: -
+```text +ccr <配置名称或 ID> [cli|app] [-- ] +``` -

非常感谢所有赞助商的慷慨支持。

+- `--cli` 和 `--app` 也可以代替位置形式的入口类型。 +- Agent 自己的参数建议统一放到 `--` 后,避免被识别为 CCR 参数。 +- 省略入口类型时,Claude Code、Codex、Grok CLI 默认使用 CLI,ZCode 默认使用 App。 +- Grok 只支持 CLI,ZCode 只支持 App。Claude App 和 ZCode App 不接受额外 Agent 参数。 +- 启动桌面 App 时,本机必须已安装对应应用,并且当前环境必须有图形会话。 +- 大多数配置需要先启动 CCR 服务。Grok CLI 配置可以自动启动一个临时共享服务,并在最后一个受管会话退出后停止。 - - - - - - - - - - - - - -
- - 智谱图标 -
- Z智谱 -
-
- - AIHubmix 图标 -
- AIHubmix -
-
- - BurnCloud 图标 -
- BurnCloud -
-
- - 302.AI 图标 -
- 302.AI -
-
- - RunAPI 图标 -
- RunAPI -
-
- - TeamoRouter 图标 -
- TeamoRouter -
-
- - 七牛云 AI 图标 -
- 七牛云 AI -
-
- - Fenno.ai 图标 -
- Fenno.ai -
-
+桌面应用会安装一个相关命令 `ccr-app`。桌面 Agent配置卡片复制出来的命令使用 `ccr-app`;本文介绍的 npm 包安装的是 `ccr`。 -

社区赞助者

+## 配置与运行文件 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
@*更@K*g@R*R@bobleer@*苗@*划
@Clarence-pan@carter003@S*r@*晖@*敏@Z*z
@*然@cluic@*苗@PromptExpert@*应@yusnake
@*飞@董*@*汀@*涯@*:-)@**磊
@*琢@*成@Z*o@*琨@congzhangzh@*_
@Z*m@*鑫@c*y@*昕@witsice@b*g
@*亿@*辉@JACK@*光@W*l@kesku
@biguncle@二吉吉@a*g@*林@*咸@*明
@S*y@f*o@*智@F*t@r*c@qierkang
@*军@snrise-z@*王@greatheart1000@*王@zcutlip
@Peng-YM@*更@*.@F*t@*政@*铭
@*叶@七*o@*青@**晨@*远@*霄
@**吉@**飞@**驰@x*g@**东@*落
@哆*k@*涛@苗大@*呢@d*u@crizcraig
s*s*火*勤**锟*涛**明
*知*语*瓜
+| 平台 | 配置目录 | +| --- | --- | +| macOS / Linux | `~/.claude-code-router` | +| Windows | `%APPDATA%\claude-code-router` | -如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。 +重要文件包括: -
+- `config.sqlite`:当前应用配置。 +- `app-data/`:API Key、用量、请求日志、证书等运行数据库和文件。 +- `service.json`:后台 CLI 服务的状态和私有 Token。 +- `gateway.config.json`:生成的网关运行配置。 +- `profiles/` 和 `bin/`:隔离的 Agent 配置和启动包装器。 + +CCR 写入 SQLite 时不要直接编辑或复制活跃数据库。优先使用 UI 导出;要做文件级备份,请先停止 CCR。 + +## 环境变量与安全 + +| 变量 | 说明 | +| --- | --- | +| `CCR_WEB_HOST` | 省略 `--host` 时使用的管理服务监听地址。 | +| `CCR_WEB_PORT` | 省略 `--port` 时使用的管理服务端口。 | +| `CCR_WEB_AUTH_TOKEN` | 固定管理 UI / RPC 的认证 Token;不设置时每个进程会生成随机 Token。 | + +认证后的管理 URL 会在查询参数中包含 `ccr_web_token`。请把这个 URL 当作密码,不要复制到日志、工单或公开的 Shell 历史中。除非确实需要远程访问,否则监听地址应保持 `127.0.0.1`。远程访问时,应同时使用防火墙或私网,并在可信反向代理上启用 TLS。 + +不要在未创建 CCR 客户端 API Key 的情况下暴露网关。上游供应商凭据保存在 CCR 本地数据目录中,因此也要保护该目录及其备份。 + +## 常见问题 + +### 找不到 `ccr` 命令 + +确认 Node.js 不低于 22,并检查 npm 全局可执行目录是否在 `PATH`: + +```sh +node --version +npm prefix -g +``` + +如果 Shell 缓存了命令路径,安装后请打开一个新终端。 + +### 管理 URL 的端口发生变化 + +首选端口已被占用。请使用 CCR 打印的实际 URL,或停止占用端口的进程后重启 CCR。 + +### UI 能打开,但网关不可用 + +管理服务可以在没有可用网关时单独运行。请添加供应商和模型、创建客户端 API Key,然后从 **服务** 页面启动或重启网关。排查启动错误时,可以使用 `ccr serve` 查看前台输出。 + +### 找不到 Agent 配置 + +只有已启用的配置才能启动。名称匹配不区分大小写,也接受清理后的名称;如果多个名称产生歧义,必须使用配置 ID。生成的启动器缺失时,请重新保存配置。 + +### 后台服务仍使用旧参数 + +停止并重新创建服务: + +```sh +ccr stop +ccr start --host 127.0.0.1 --port 3458 +``` + +## Docker + +仓库还提供面向模型网关和浏览器 UI 的 Docker 镜像。运行时镜像不会安装 npm 的 `ccr` 命令。请参阅 [Docker 部署文档](https://github.com/musistudio/claude-code-router/blob/main/docker/README.md)。 ## 许可证 -本项目基于 [MIT License](LICENSE) 发布。 +[MIT](LICENSE) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index adf6d899..63b5be30 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -550,14 +550,21 @@ function printHelp(exitCode: number): void { const command = cliCommandName(); const output = [ "Usage:", - ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`, - ` ${command} ui [--host ] [--port ] [--no-gateway]`, + ` ${command} start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`, + ` ${command} ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`, + ` ${command} serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`, ` ${command} stop`, ` ${command} [cli|app] [-- ]`, "", + "Notes:", + ` ${command} web is an alias for ${command} serve.`, + " --cli and --app are alternatives to the positional profile surface.", + " Put agent-specific arguments after --.", + "", "Examples:", ` ${command} start`, ` ${command} ui`, + ` ${command} serve --no-open`, ` ${command} stop`, ` ${command} Codex`, ` ${command} default-codex -- --model gpt-5-codex`, @@ -572,16 +579,19 @@ function printStartHelp(exitCode: number): void { const command = cliCommandName(); const output = [ "Usage:", - ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`, + ` ${command} start [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`, "", "Options:", - " --host Management server host. Defaults to 127.0.0.1.", - " --port Management server port. Defaults to 3458.", + " --host Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.", + " --port Management server port. Defaults to CCR_WEB_PORT or 3458.", " --open Open the management page in the default browser.", " --no-open Do not open the management page.", + " --gateway Start the configured model gateway (default).", " --no-gateway Start only the web management server.", "", "Environment:", + " CCR_WEB_HOST Default management server host.", + " CCR_WEB_PORT Default management server port.", " CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication." ].join("\n"); const stream = exitCode === 0 ? process.stdout : process.stderr; @@ -593,17 +603,21 @@ function printUiHelp(exitCode: number): void { const command = cliCommandName(); const output = [ "Usage:", - ` ${command} ui [--host ] [--port ] [--no-gateway]`, + ` ${command} ui [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`, "", "Starts the background CCR service if needed and opens the management UI in the default browser.", "", "Options:", - " --host Management server host. Defaults to 127.0.0.1.", - " --port Management server port. Defaults to 3458.", + " --host Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.", + " --port Management server port. Defaults to CCR_WEB_PORT or 3458.", + " --open Open the management page (default).", " --no-open Start or find the service and print the management URL without opening a browser.", + " --gateway Start the configured model gateway (default).", " --no-gateway Start only the web management server when the service is not already running.", "", "Environment:", + " CCR_WEB_HOST Default management server host.", + " CCR_WEB_PORT Default management server port.", " CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication." ].join("\n"); const stream = exitCode === 0 ? process.stdout : process.stderr; @@ -628,15 +642,21 @@ function printWebHelp(exitCode: number): void { const command = cliCommandName(); const output = [ "Usage:", - ` ${command} serve [--host ] [--port ] [--open] [--no-gateway]`, + ` ${command} serve [--host ] [--port ] [--open|--no-open] [--gateway|--no-gateway]`, + "", + `Runs in the foreground. ${command} web is an alias.`, "", "Options:", - " --host Management server host. Defaults to 127.0.0.1.", - " --port Management server port. Defaults to 3458.", + " --host Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.", + " --port Management server port. Defaults to CCR_WEB_PORT or 3458.", " --open Open the management page in the default browser.", + " --no-open Do not open the management page (default).", + " --gateway Start the configured model gateway (default).", " --no-gateway Start only the web management server.", "", "Environment:", + " CCR_WEB_HOST Default management server host.", + " CCR_WEB_PORT Default management server port.", " CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication." ].join("\n"); const stream = exitCode === 0 ? process.stdout : process.stderr; From 245762710fbe728f8d0b6449b59b53a03e843d0d Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 14 Jul 2026 09:12:57 +0800 Subject: [PATCH 26/38] Refactor router config and request handling --- build/dev.mjs | 139 +++-- packages/electron/src/main/ipc.ts | 3 + packages/electron/src/main/main-app.ts | 4 +- packages/electron/src/main/native-theme.ts | 6 + packages/electron/src/main/tray-controller.ts | 42 +- .../src/pages/home/components/dashboard.tsx | 480 +++++++++------ .../ui/src/pages/home/shared/controls.tsx | 10 +- packages/ui/src/pages/tray/TrayApp.tsx | 25 +- packages/ui/src/pages/tray/TrayDetailApp.tsx | 12 +- .../pages/tray/components/account-panel.tsx | 8 +- .../src/pages/tray/components/source-grid.tsx | 44 +- .../pages/tray/components/status-strip.tsx | 8 +- .../pages/tray/components/usage-detail.tsx | 4 +- .../ui/src/pages/tray/components/widgets.tsx | 84 +-- packages/ui/src/pages/tray/shared.tsx | 113 +++- packages/ui/src/styles/globals.css | 556 +++++++++++++++++- tests/renderer/fixtures.ts | 9 +- tests/renderer/overview-components.test.tsx | 26 + tests/renderer/tray-components.test.tsx | 50 +- 19 files changed, 1244 insertions(+), 379 deletions(-) create mode 100644 packages/electron/src/main/native-theme.ts diff --git a/build/dev.mjs b/build/dev.mjs index fd87ff51..38901c2f 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -2,7 +2,7 @@ import electron from "electron"; import esbuild from "esbuild"; import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { buildStyles, @@ -97,13 +97,6 @@ function readyState() { .join(" "); } -function describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) { - const changedPath = filename - ? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename)) - : watchedPath; - return `${label} ${eventType} ${relativePath(changedPath)}`; -} - function contentSignature(targetPath) { try { return readContentSignature(targetPath); @@ -179,31 +172,14 @@ function listDirectoryFiles(targetPath, basePath = targetPath) { return files; } -function rememberWatchSignature(label, targetPath) { - const signature = contentSignature(targetPath); +function rememberWatchSignature(label, targetPath, options = {}) { + const signature = options.metadataOnly + ? metadataSignature(targetPath) + : contentSignature(targetPath); watchSignatures.set(label, signature.key); logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`); } -function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) { - const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory); - const signature = contentSignature(watchedPath); - const previousSignature = watchSignatures.get(label); - const changed = previousSignature !== signature.key; - watchSignatures.set(label, signature.key); - logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`); - - if (!changed) { - logDev(`restart skipped: ${reason} (content unchanged)`); - return; - } - - onChange(); - if (enabled.electron && options?.restart !== false) { - scheduleRestart(reason); - } -} - function scheduleStyleBuild(reason) { queuedStyleBuildReason = reason; if (styleBuildTimer) { @@ -258,6 +234,64 @@ function pollStyleWatchRoots() { } } +function pollWatchedInput(label, targetPath, onChange, options = {}) { + const signature = options.metadataOnly + ? metadataSignature(targetPath) + : contentSignature(targetPath); + const previousSignature = watchSignatures.get(label); + if (previousSignature === signature.key) { + return; + } + + watchSignatures.set(label, signature.key); + logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`); + try { + onChange(); + if (enabled.electron && options.restart !== false) { + scheduleRestart(label); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logDev(`watch action failed: ${label}; ${message}`); + } +} + +function metadataSignature(targetPath) { + if (!existsSync(targetPath)) { + return { + key: "missing", + summary: "missing" + }; + } + + const stats = statSync(targetPath); + return { + key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`, + summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}` + }; +} + +function pollSourceWatchTargets() { + pollWatchedInput("home html", rendererHtmlInput, () => { + copyRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + pollWatchedInput("browser html", browserRendererHtmlInput, () => { + copyBrowserRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + pollWatchedInput("tray html", trayRendererHtmlInput, () => { + copyTrayRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + if (enabled.electron) { + pollWatchedInput("app assets", appAssetsInput, copyAppAssets); + } + if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { + pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true }); + } +} + function markReady(name, reason = `${name} esbuild completed`) { if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") { ready[name] = true; @@ -385,43 +419,13 @@ if (enabled.electron) { rememberWatchSignature("app assets", appAssetsInput); } if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { - rememberWatchSignature("model catalog", modelCatalogInput); + rememberWatchSignature("model catalog", modelCatalogInput, { metadataOnly: true }); } -const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => { - copyRendererHtml(); - syncUiRendererToRuntimeDists(); - }); -}); - -const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => { - copyBrowserRendererHtml(); - syncUiRendererToRuntimeDists(); - }); -}); - -const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => { - copyTrayRendererHtml(); - syncUiRendererToRuntimeDists(); - }); -}); - -const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs); - -const appAssetsWatcher = enabled.electron - ? watch(appAssetsInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); - }) - : { close: () => undefined }; - -const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput) - ? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog); - }) - : { close: () => undefined }; +const sourcePoller = setInterval(() => { + pollStyleWatchRoots(); + pollSourceWatchTargets(); +}, stylePollIntervalMs); const contexts = []; @@ -521,12 +525,7 @@ async function shutdown() { if (styleBuildTimer) { clearTimeout(styleBuildTimer); } - htmlWatcher.close(); - browserHtmlWatcher.close(); - trayHtmlWatcher.close(); - clearInterval(stylePoller); - appAssetsWatcher.close(); - modelCatalogWatcher.close(); + clearInterval(sourcePoller); await Promise.all(contexts.map((context) => context.dispose())); process.exit(0); } diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index ec9386ef..edf2ee74 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -32,6 +32,7 @@ import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequest import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "@ccr/core/usage/store"; +import { applyNativeThemePreference } from "./native-theme"; import windowsManager from "./windows"; import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountResetRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "@ccr/core/contracts/app"; @@ -181,6 +182,7 @@ ipcMain.handle(IPC_CHANNELS.appOpenProfile, async (_event, request: ProfileOpenR ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: AppConfig) => { const previousConfig = await loadAppConfig(); const baseConfig = config ? await saveAppConfig(config) : previousConfig; + applyNativeThemePreference(baseConfig.theme); const synced = await syncClaudeAppGatewayConfig(baseConfig); const savedConfig = synced.config; let runtimeStatus = gatewayService.getStatus(); @@ -266,6 +268,7 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt } const launchAtLoginChanged = Boolean(config.launchAtLogin) !== Boolean(previousConfig.launchAtLogin); let savedConfig = await saveAppConfig(config); + applyNativeThemePreference(savedConfig.theme); if (launchAtLoginChanged) { try { syncLaunchAtLogin(savedConfig); diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 4cc37d15..10eab767 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -13,6 +13,7 @@ import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { browserAutomationMcpService } from "./browser-automation-mcp"; import { browserWebSearchMcpService } from "./electron-web-search-mcp"; +import { applyNativeThemePreference } from "./native-theme"; import windowsManager from "./windows"; const gotTheLock = app.requestSingleInstanceLock(); @@ -41,7 +42,8 @@ function startPrimaryInstance(): void { queueEnsureConfiguredProxyModeActive("second-instance"); }); - void app.whenReady().then(() => { + void app.whenReady().then(async () => { + applyNativeThemePreference((await loadAppConfig()).theme); configureProxyDesktopIntegration(); let ccrLauncherPreparation: CcrCliLauncherPreparation | undefined; try { diff --git a/packages/electron/src/main/native-theme.ts b/packages/electron/src/main/native-theme.ts new file mode 100644 index 00000000..840ec04b --- /dev/null +++ b/packages/electron/src/main/native-theme.ts @@ -0,0 +1,6 @@ +import { nativeTheme } from "electron"; +import type { AppConfig } from "@ccr/core/contracts/app"; + +export function applyNativeThemePreference(theme: AppConfig["theme"] | undefined): void { + nativeTheme.themeSource = theme === "light" || theme === "dark" ? theme : "system"; +} diff --git a/packages/electron/src/main/tray-controller.ts b/packages/electron/src/main/tray-controller.ts index b3d287cd..67c2969f 100644 --- a/packages/electron/src/main/tray-controller.ts +++ b/packages/electron/src/main/tray-controller.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, Menu, Tray, app, nativeImage, screen } from "electron"; +import { BrowserWindow, Menu, Tray, app, nativeImage, screen, type BrowserWindowConstructorOptions } from "electron"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { deflateSync } from "node:zlib"; @@ -17,7 +17,21 @@ const popoverDetailWidth = 420; const popoverMargin = 8; const trayActivationSuppressMs = 750; const trayMenuBarIconSize = 20; -const trayWindowBackgroundColor = "#020617"; +const trayWindowBackgroundColor = "#1c1c1e"; +const trayWindowMaterialOptions: Pick< + BrowserWindowConstructorOptions, + "backgroundColor" | "transparent" | "vibrancy" | "visualEffectState" +> = process.platform === "darwin" + ? { + backgroundColor: "#00000000", + transparent: true, + vibrancy: "under-window", + visualEffectState: "active" + } + : { + backgroundColor: trayWindowBackgroundColor, + transparent: false + }; const trayTokenFallbackTitle = "0 tokens"; const trayIconFallbackPath = path.join(__dirname, "../assets/tray.png"); const trayMascotIconIds = ["violet", "orange", "cyan"] as const; @@ -190,7 +204,6 @@ class TrayController { this.popover = new BrowserWindow({ acceptFirstMouse: true, alwaysOnTop: true, - backgroundColor: trayWindowBackgroundColor, frame: false, fullscreenable: false, hasShadow: true, @@ -203,7 +216,7 @@ class TrayController { show: false, skipTaskbar: true, title: `${APP_NAME} Usage`, - transparent: false, + ...trayWindowMaterialOptions, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -215,6 +228,7 @@ class TrayController { width: popoverMenuWidth }); + reinforceMacOSTrayMaterial(this.popover); prepareTrayWindowForSharpRendering(this.popover); this.popover.setAlwaysOnTop(true, "pop-up-menu"); this.popover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); @@ -235,7 +249,6 @@ class TrayController { this.detailPopover = new BrowserWindow({ acceptFirstMouse: true, alwaysOnTop: true, - backgroundColor: trayWindowBackgroundColor, frame: false, fullscreenable: false, hasShadow: true, @@ -248,7 +261,7 @@ class TrayController { show: false, skipTaskbar: true, title: `${APP_NAME} Usage Detail`, - transparent: false, + ...trayWindowMaterialOptions, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -260,6 +273,7 @@ class TrayController { width: popoverDetailWidth }); + reinforceMacOSTrayMaterial(this.detailPopover); prepareTrayWindowForSharpRendering(this.detailPopover); this.detailPopover.setAlwaysOnTop(true, "pop-up-menu"); this.detailPopover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); @@ -563,6 +577,22 @@ function normalizeDetailProvider(provider?: string): string | undefined { return trimmed ? trimmed : undefined; } +function reinforceMacOSTrayMaterial(window: BrowserWindow): void { + if (process.platform !== "darwin") { + return; + } + + const applyMaterial = () => { + if (!window.isDestroyed()) { + window.setBackgroundColor("#00000000"); + window.setVibrancy("under-window"); + } + }; + + applyMaterial(); + window.webContents.on("did-finish-load", applyMaterial); +} + function prepareTrayWindowForSharpRendering(window: BrowserWindow): void { const resetZoom = () => { if (!window.isDestroyed() && !window.webContents.isDestroyed()) { diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 8279521b..c61c4396 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3,20 +3,20 @@ import { AnimatePresence, AnimatedDisclosure, AnimatedIconSwap, Area, arrayMove, Badge, Bar, BarChart, Button, Card, CardContent, CardHeader, CardTitle, CartesianGrid, Cell, constrainOverviewWidgetSize, - Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, cn, compactId, + Check, ChevronDown, ChevronRight, CircleAlert, cn, compactId, compactUserAgent, compareProviderAccountSnapshots, ComposedChart, CSS, DEFAULT_OVERVIEW_WIDGETS, DndContext, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes, formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountDetailDate, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor, - LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricCard, MetricTone, - metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, + LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricTone, + motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions, OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus, PointerSensor, primaryProviderAccountMeter, providerAccountMeterDetailValidityProgress, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, isProviderAccountManualResetMeter, providerAccountSnapshotKey, providerAccountSnapshotLabel, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select, - SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass, + SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusPointTooltip, systemStatusTooltipPositionClass, Tooltip, translateOptions, Trash2, UsageComparisonRow, usageRangeOptions, GatewayProviderConfig, UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, @@ -24,7 +24,10 @@ import { } from "../shared/index"; import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity"; import { ShareCardWidget } from "./share-cards"; -import { Cloud, Rocket } from "lucide-react"; +import { + CalendarDays, ChartNoAxesCombined, ChartPie, Cloud, GripHorizontal, Inbox, Layers3, + Rocket, Server, SlidersHorizontal, UsersRound, WalletCards +} from "lucide-react"; type OverviewUsageFilters = { modelFilter: string; @@ -305,9 +308,7 @@ export function OverviewView({ ))} {visibleWidgets.length === 0 ? ( -
- {t("No widgets configured")} -
+ ) : null} @@ -330,24 +331,28 @@ export function OverviewView({ return ( -
+
+ -

- Chinese README - Discord - X - License - Documentation -