mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Compare commits
82 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c27b7f912 | ||
|
|
0663c0463e | ||
|
|
78f4859a9b | ||
|
|
9c98e353e6 | ||
|
|
81fbae63a6 | ||
|
|
c0da81d6f3 | ||
|
|
5a3e1cb71f | ||
|
|
4c5af02aae | ||
|
|
4cf0c2468c | ||
|
|
15f67ab8d8 | ||
|
|
9163e306c0 | ||
|
|
088ec97e7b | ||
|
|
768baa8575 | ||
|
|
2cc208d857 | ||
|
|
ab6a2c1c50 | ||
|
|
113c9059b4 | ||
|
|
990bc0d958 | ||
|
|
fcd386c80c | ||
|
|
1aaee2ef60 | ||
|
|
92a16b0d4b | ||
|
|
2020e8f53c | ||
|
|
70f85864a6 | ||
|
|
8361805bdf | ||
|
|
f568968673 | ||
|
|
421cbd05b2 | ||
|
|
a65b8bb6f9 | ||
|
|
d8154e5357 | ||
|
|
cce7292877 | ||
|
|
980a9bae63 | ||
|
|
a8ccdf0403 | ||
|
|
499d25d200 | ||
|
|
04185dba99 | ||
|
|
d29f2176e8 | ||
|
|
bc872a29b3 | ||
|
|
c8cbc09b96 | ||
|
|
52a6371600 | ||
|
|
027826fd13 | ||
|
|
7216b9dcfa | ||
|
|
300096e39e | ||
|
|
07fd47ad65 | ||
|
|
4d7c1990ba | ||
|
|
8cf350e041 | ||
|
|
7faf59276a | ||
|
|
a4f5cc9172 | ||
|
|
ad0d9db795 | ||
|
|
1da1723530 | ||
|
|
d4ff6706f4 | ||
|
|
fce80a0c3a | ||
|
|
9af987d224 | ||
|
|
61108ed6e3 | ||
|
|
c409797bc5 | ||
|
|
964d1dbf23 | ||
|
|
5f92120114 | ||
|
|
528a8e0efa | ||
|
|
daf745ac3f | ||
|
|
ee44357a34 | ||
|
|
87a666df21 | ||
|
|
d41dae4e4a | ||
|
|
3983322519 | ||
|
|
2929903bdc | ||
|
|
7130e1e632 | ||
|
|
6bfb2e1901 | ||
|
|
46fa7e2181 | ||
|
|
2410530450 | ||
|
|
6118d52df5 | ||
|
|
43948b4c2a | ||
|
|
b6f8bb3c52 | ||
|
|
15fcaaecf5 | ||
|
|
0d51897d18 | ||
|
|
89d2d2394c | ||
|
|
d45e88387a | ||
|
|
f13e3b4598 | ||
|
|
7e44650008 | ||
|
|
5f7345fdc8 | ||
|
|
0f54b7d7a3 | ||
|
|
ec99720037 | ||
|
|
01b3c3bc3b | ||
|
|
ccd2694d43 | ||
|
|
109b83bd80 | ||
|
|
32db838b0b | ||
|
|
1adfbbf006 | ||
|
|
ffc6318dee |
318 changed files with 30045 additions and 2222 deletions
17
.dockerignore
Normal file
17
.dockerignore
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
node_modules
|
||||
dist
|
||||
packages/*/dist
|
||||
release
|
||||
release-local
|
||||
logs
|
||||
tmp
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
docs/node_modules
|
||||
docs/dist
|
||||
docs/.astro
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -13,4 +13,8 @@ release
|
|||
tmp
|
||||
release-local
|
||||
logs
|
||||
.opencat
|
||||
.opencat
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
.tmp
|
||||
75
Dockerfile
Normal file
75
Dockerfile
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
ARG NODE_IMAGE=node:22-bookworm
|
||||
ARG RUNTIME_NODE_IMAGE=node:22-bookworm-slim
|
||||
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/cli/package.json packages/cli/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/electron/package.json packages/electron/package.json
|
||||
COPY packages/ui/package.json packages/ui/package.json
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM ${NODE_IMAGE} AS production-deps
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
RUN npm ci --omit=dev --workspace=@claude-code-router/core --include-workspace-root=false \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM ${RUNTIME_NODE_IMAGE} AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
CCR_DATA_DIR=/data \
|
||||
CCR_WEB_HOST=127.0.0.1 \
|
||||
CCR_WEB_PORT=3459 \
|
||||
CCR_NGINX_PORT=8080 \
|
||||
CCR_GATEWAY_HOST=127.0.0.1 \
|
||||
CCR_GATEWAY_PORT=3456 \
|
||||
CCR_GATEWAY_CORE_PORT=3457 \
|
||||
CCR_PUBLIC_HOST=127.0.0.1 \
|
||||
CCR_PUBLIC_PORT=3458
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 nginx \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \
|
||||
&& rm -rf \
|
||||
/opt/yarn-* \
|
||||
/usr/local/bin/corepack \
|
||||
/usr/local/bin/npm \
|
||||
/usr/local/bin/npx \
|
||||
/usr/local/bin/yarn \
|
||||
/usr/local/bin/yarnpkg \
|
||||
/usr/local/include/node \
|
||||
/usr/local/lib/node_modules/corepack \
|
||||
/usr/local/lib/node_modules/npm \
|
||||
/usr/local/share/doc \
|
||||
/usr/local/share/man
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY --from=production-deps /app/node_modules node_modules
|
||||
|
||||
COPY --from=build /app/packages/core/dist packages/core/dist
|
||||
COPY --from=build /app/packages/ui/dist/renderer /usr/share/nginx/html
|
||||
COPY docker/entrypoint.sh /usr/local/bin/ccr-docker-entrypoint
|
||||
COPY docker/pm2.config.cjs docker/pm2.config.cjs
|
||||
|
||||
RUN chmod +x /usr/local/bin/ccr-docker-entrypoint \
|
||||
&& mkdir -p /data /run/nginx /var/lib/nginx /var/log/nginx
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.CCR_NGINX_PORT || '8080') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["ccr-docker-entrypoint"]
|
||||
60
README.md
60
README.md
|
|
@ -1,19 +1,52 @@
|
|||
<h1 align="center">Claude Code Router Desktop</h1>
|
||||
<h1 align="center">Claude Code Router</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README_zh.md"><img alt="Chinese README" src="https://img.shields.io/badge/%F0%9F%87%A8%F0%9F%87%B3-%E4%B8%AD%E6%96%87%E7%89%88-ff0000?style=flat" /></a>
|
||||
<a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
|
||||
<a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
|
||||
<a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases"><img alt="Desktop downloads" src="https://img.shields.io/github/downloads/musistudio/claude-code-router/total?label=Desktop%20downloads&logo=github" /></a>
|
||||
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr">API China</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>Thanks to Kimi for sponsoring this project!</strong> 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.
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR already supports Kimi. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">cost-effective Coding Plan</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop screenshot" />
|
||||
</p>
|
||||
|
||||
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.
|
||||
|
||||
## Why Use CCR
|
||||
|
||||
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
|
||||
|
|
@ -169,6 +202,27 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co
|
|||
<strong>RunAPI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://teamorouter.com/">
|
||||
<img src="/docs/public/provider-icons/teamorouter.png" width="42" height="42" alt="TeamoRouter icon" />
|
||||
<br />
|
||||
<strong>TeamoRouter</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://code0.ai?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai icon" />
|
||||
<br />
|
||||
<strong>code0.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.claudeapi.com?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi icon" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
|
|||
60
README_zh.md
60
README_zh.md
|
|
@ -1,19 +1,52 @@
|
|||
<h1 align="center">Claude Code Router Desktop</h1>
|
||||
<h1 align="center">Claude Code Router</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><img alt="English README" src="https://img.shields.io/badge/%F0%9F%87%AC%F0%9F%87%A7-English-000aff?style=flat" /></a>
|
||||
<a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
|
||||
<a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
|
||||
<a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases"><img alt="桌面端下载次数" src="https://img.shields.io/github/downloads/musistudio/claude-code-router/total?label=%E6%A1%8C%E9%9D%A2%E7%AB%AF%E4%B8%8B%E8%BD%BD&logo=github" /></a>
|
||||
<a href="https://ccrdesk.top/"><img alt="文档" src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API,或了解高性价比 <a href="https://www.kimi.com/code?aff=ccr">Coding Plan</a> 套餐。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop 项目截图" />
|
||||
</p>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
## 为什么使用 CCR
|
||||
|
||||
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
|
||||
|
|
@ -168,6 +201,27 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
|
|||
<strong>RunAPI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://teamorouter.com/">
|
||||
<img src="/docs/public/provider-icons/teamorouter.png" width="42" height="42" alt="TeamoRouter 图标" />
|
||||
<br />
|
||||
<strong>TeamoRouter</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://code0.ai?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai 图标" />
|
||||
<br />
|
||||
<strong>code0.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.claudeapi.com?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi 图标" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs";
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
|
|
@ -19,4 +19,6 @@ await Promise.all([
|
|||
buildStyles({ minify: mode === "production" })
|
||||
]);
|
||||
|
||||
console.log(`Built Electron app assets in ${mode} mode.`);
|
||||
syncUiRendererToRuntimeDists();
|
||||
|
||||
console.log(`Built monorepo package assets in ${mode} mode.`);
|
||||
|
|
|
|||
294
build/dev.mjs
294
build/dev.mjs
|
|
@ -5,12 +5,14 @@ import { spawn } from "node:child_process";
|
|||
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
binPath,
|
||||
buildStyles,
|
||||
cleanDist,
|
||||
browserRendererHtmlInput,
|
||||
cliSourceRoot,
|
||||
coreSourceRoot,
|
||||
copyAppAssets,
|
||||
copyBrowserRendererHtml,
|
||||
copyCliRuntimeToElectronDist,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
|
|
@ -21,12 +23,12 @@ import {
|
|||
createRendererBuildOptions,
|
||||
createTrayRendererBuildOptions,
|
||||
createWebClientBridgeBuildOptions,
|
||||
cssInput,
|
||||
cssOutput,
|
||||
appAssetsInput,
|
||||
modelCatalogInput,
|
||||
projectRoot,
|
||||
rendererRoot,
|
||||
rendererHtmlInput,
|
||||
syncUiRendererToRuntimeDists,
|
||||
trayRendererHtmlInput,
|
||||
watchPlugin
|
||||
} from "./esbuild.config.mjs";
|
||||
|
|
@ -37,7 +39,12 @@ let pendingRestartReasons = [];
|
|||
const watchSignatures = new Map();
|
||||
let shuttingDown = false;
|
||||
const restartDelayMs = 160;
|
||||
const styleBuildDelayMs = 160;
|
||||
const stylePollIntervalMs = 1000;
|
||||
const ignoredSignatureEntries = new Set([".DS_Store"]);
|
||||
let styleBuildTimer = null;
|
||||
let styleBuildInFlight = false;
|
||||
let queuedStyleBuildReason = null;
|
||||
const ready = {
|
||||
browser: false,
|
||||
cli: false,
|
||||
|
|
@ -46,6 +53,32 @@ const ready = {
|
|||
tray: false,
|
||||
webBridge: false
|
||||
};
|
||||
const devTarget = parseDevTarget(process.argv.slice(2));
|
||||
const enabled = {
|
||||
cli: devTarget === "cli" || devTarget === "electron",
|
||||
electron: devTarget === "electron",
|
||||
ui: true
|
||||
};
|
||||
const coreSharedSourceRoot = path.join(coreSourceRoot, "shared");
|
||||
const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot));
|
||||
const activeReadyNames = new Set([
|
||||
...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []),
|
||||
...(enabled.cli ? ["cli"] : []),
|
||||
...(enabled.electron ? ["main"] : [])
|
||||
]);
|
||||
|
||||
function parseDevTarget(args) {
|
||||
const target = args[0] ?? "electron";
|
||||
if (target === "--help" || target === "-h") {
|
||||
console.log("Usage: node build/dev.mjs [ui|cli|electron]");
|
||||
process.exit(0);
|
||||
}
|
||||
if (target === "ui" || target === "cli" || target === "electron") {
|
||||
return target;
|
||||
}
|
||||
console.error(`Unknown dev target "${target}". Expected ui, cli, or electron.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function logDev(message) {
|
||||
console.log(`[dev] ${new Date().toISOString()} ${message}`);
|
||||
|
|
@ -57,6 +90,7 @@ function relativePath(file) {
|
|||
|
||||
function readyState() {
|
||||
return Object.entries(ready)
|
||||
.filter(([name]) => activeReadyNames.has(name))
|
||||
.map(([name, value]) => `${name}:${value ? "ready" : "pending"}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -163,7 +197,63 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on
|
|||
}
|
||||
|
||||
onChange();
|
||||
scheduleRestart(reason);
|
||||
if (enabled.electron && options?.restart !== false) {
|
||||
scheduleRestart(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStyleBuild(reason) {
|
||||
queuedStyleBuildReason = reason;
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
styleBuildTimer = setTimeout(() => {
|
||||
styleBuildTimer = null;
|
||||
void rebuildStyles(queuedStyleBuildReason ?? reason);
|
||||
}, styleBuildDelayMs);
|
||||
}
|
||||
|
||||
async function rebuildStyles(reason) {
|
||||
if (styleBuildInFlight) {
|
||||
queuedStyleBuildReason = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
styleBuildInFlight = true;
|
||||
queuedStyleBuildReason = null;
|
||||
try {
|
||||
logDev(`rebuilding styles: ${reason}`);
|
||||
await buildStyles({ minify: false });
|
||||
syncUiRendererToRuntimeDists();
|
||||
if (enabled.electron) {
|
||||
scheduleRestart(`styles rebuilt: ${reason}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logDev(`style rebuild failed: ${message}`);
|
||||
} finally {
|
||||
styleBuildInFlight = false;
|
||||
if (queuedStyleBuildReason) {
|
||||
const queuedReason = queuedStyleBuildReason;
|
||||
queuedStyleBuildReason = null;
|
||||
scheduleStyleBuild(queuedReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pollStyleWatchRoots() {
|
||||
for (const styleWatchRoot of styleWatchRoots) {
|
||||
const label = `styles ${relativePath(styleWatchRoot)}`;
|
||||
const signature = contentSignature(styleWatchRoot);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
if (previousSignature === signature.key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${label}; ${signature.summary}; content=changed`);
|
||||
scheduleStyleBuild(label);
|
||||
}
|
||||
}
|
||||
|
||||
function markReady(name, reason = `${name} esbuild completed`) {
|
||||
|
|
@ -171,7 +261,7 @@ function markReady(name, reason = `${name} esbuild completed`) {
|
|||
ready[name] = true;
|
||||
}
|
||||
logDev(`build ready: ${reason}; ${readyState()}`);
|
||||
if (ready.browser && ready.cli && ready.main && ready.renderer && ready.tray && ready.webBridge) {
|
||||
if (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) {
|
||||
scheduleRestart(reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -221,114 +311,153 @@ function restartElectron() {
|
|||
});
|
||||
}
|
||||
|
||||
logDev("starting dev build");
|
||||
logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`);
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
if (enabled.electron) {
|
||||
copyAppAssets();
|
||||
}
|
||||
if (enabled.cli || enabled.electron) {
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
}
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
copyTrayRendererHtml();
|
||||
await buildStyles({ minify: false });
|
||||
|
||||
const tailwindProcess = spawn(binPath("tailwindcss"), ["-i", cssInput, "-o", cssOutput, "--watch"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32"
|
||||
});
|
||||
logDev(`Tailwind watcher started pid=${tailwindProcess.pid ?? "unknown"} input=${relativePath(cssInput)} output=${relativePath(cssOutput)}`);
|
||||
tailwindProcess.on("exit", (code, signal) => {
|
||||
logDev(`Tailwind watcher exited code=${code ?? "null"} signal=${signal ?? "null"}`);
|
||||
});
|
||||
syncUiRendererToRuntimeDists();
|
||||
|
||||
rememberWatchSignature("home html", rendererHtmlInput);
|
||||
rememberWatchSignature("browser html", browserRendererHtmlInput);
|
||||
rememberWatchSignature("tray html", trayRendererHtmlInput);
|
||||
rememberWatchSignature("app assets", appAssetsInput);
|
||||
if (existsSync(modelCatalogInput)) {
|
||||
for (const styleWatchRoot of styleWatchRoots) {
|
||||
rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot);
|
||||
}
|
||||
if (enabled.electron) {
|
||||
rememberWatchSignature("app assets", appAssetsInput);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
rememberWatchSignature("model catalog", modelCatalogInput);
|
||||
}
|
||||
|
||||
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml);
|
||||
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);
|
||||
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);
|
||||
handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyTrayRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
|
||||
});
|
||||
const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs);
|
||||
|
||||
const modelCatalogWatcher = existsSync(modelCatalogInput)
|
||||
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 mainContext = await esbuild.context(
|
||||
createMainBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [watchPlugin("main", (name) => markReady(name))]
|
||||
})
|
||||
);
|
||||
const contexts = [];
|
||||
|
||||
const cliContext = await esbuild.context(
|
||||
createCliBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [watchPlugin("cli", (name) => markReady(name))]
|
||||
})
|
||||
);
|
||||
|
||||
const rendererContext = await esbuild.context(
|
||||
createRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("renderer", (name) => {
|
||||
copyRendererHtml();
|
||||
markReady(name);
|
||||
if (enabled.electron) {
|
||||
contexts.push(
|
||||
await esbuild.context(
|
||||
createMainBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [watchPlugin("main", (name) => markReady(name))]
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const trayRendererContext = await esbuild.context(
|
||||
createTrayRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("tray", (name) => {
|
||||
copyTrayRendererHtml();
|
||||
markReady(name);
|
||||
if (enabled.cli) {
|
||||
contexts.push(
|
||||
await esbuild.context(
|
||||
createCliBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("cli", (name) => {
|
||||
if (enabled.electron) {
|
||||
copyCliRuntimeToElectronDist();
|
||||
}
|
||||
markReady(name);
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const browserRendererContext = await esbuild.context(
|
||||
createBrowserRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("browser", (name) => {
|
||||
copyBrowserRendererHtml();
|
||||
markReady(name);
|
||||
if (enabled.ui) {
|
||||
contexts.push(
|
||||
await esbuild.context(
|
||||
createRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("renderer", (name) => {
|
||||
copyRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
markReady(name);
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
),
|
||||
await esbuild.context(
|
||||
createTrayRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("tray", (name) => {
|
||||
copyTrayRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
markReady(name);
|
||||
})
|
||||
]
|
||||
})
|
||||
),
|
||||
await esbuild.context(
|
||||
createBrowserRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("browser", (name) => {
|
||||
copyBrowserRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
markReady(name);
|
||||
})
|
||||
]
|
||||
})
|
||||
),
|
||||
await esbuild.context(
|
||||
createWebClientBridgeBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("webBridge", (name) => {
|
||||
syncUiRendererToRuntimeDists();
|
||||
markReady(name);
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const webClientBridgeContext = await esbuild.context(
|
||||
createWebClientBridgeBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [watchPlugin("webBridge", (name) => markReady(name))]
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([mainContext.watch(), cliContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch(), webClientBridgeContext.watch()]);
|
||||
await Promise.all(contexts.map((context) => context.watch()));
|
||||
logDev("watchers are active");
|
||||
|
||||
async function shutdown() {
|
||||
|
|
@ -340,13 +469,16 @@ async function shutdown() {
|
|||
if (electronProcess) {
|
||||
electronProcess.kill();
|
||||
}
|
||||
tailwindProcess.kill();
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
htmlWatcher.close();
|
||||
browserHtmlWatcher.close();
|
||||
trayHtmlWatcher.close();
|
||||
clearInterval(stylePoller);
|
||||
appAssetsWatcher.close();
|
||||
modelCatalogWatcher.close();
|
||||
await Promise.all([mainContext.dispose(), cliContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose(), webClientBridgeContext.dispose()]);
|
||||
await Promise.all(contexts.map((context) => context.dispose()));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
37
build/docker-build.mjs
Normal file
37
build/docker-build.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
buildBrowserRenderer,
|
||||
buildCoreServer,
|
||||
buildRenderer,
|
||||
buildStyles,
|
||||
buildTrayRenderer,
|
||||
buildWebClientBridge,
|
||||
cleanDist,
|
||||
copyBrowserRendererHtml,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
syncUiRendererToRuntimeDists
|
||||
} from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
cleanDist();
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
copyTrayRendererHtml();
|
||||
|
||||
await Promise.all([
|
||||
buildCoreServer({ mode }),
|
||||
buildBrowserRenderer({ mode }),
|
||||
buildRenderer({ mode }),
|
||||
buildTrayRenderer({ mode }),
|
||||
buildWebClientBridge({ mode }),
|
||||
buildStyles({ minify: mode === "production" })
|
||||
]);
|
||||
|
||||
syncUiRendererToRuntimeDists();
|
||||
|
||||
console.log(`Built Docker core server and UI assets in ${mode} mode.`);
|
||||
|
|
@ -1,23 +1,63 @@
|
|||
import esbuild from "esbuild";
|
||||
import { spawn } from "node:child_process";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { builtinModules } from "node:module";
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { builtinModules, createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
|
||||
export const projectRoot = path.resolve(__dirname, "..");
|
||||
export const distDir = path.join(projectRoot, "dist");
|
||||
export const mainOutDir = path.join(distDir, "main");
|
||||
export const rendererOutDir = path.join(distDir, "renderer");
|
||||
export const appAssetsDir = path.join(distDir, "assets");
|
||||
export const packagesRoot = path.join(projectRoot, "packages");
|
||||
export const cliRoot = path.join(packagesRoot, "cli");
|
||||
export const coreRoot = path.join(packagesRoot, "core");
|
||||
export const electronRoot = path.join(packagesRoot, "electron");
|
||||
export const uiRoot = path.join(packagesRoot, "ui");
|
||||
export const cliSourceRoot = path.join(cliRoot, "src");
|
||||
export const coreSourceRoot = path.join(coreRoot, "src");
|
||||
export const electronSourceRoot = path.join(electronRoot, "src");
|
||||
export const uiSourceRoot = path.join(uiRoot, "src");
|
||||
export const legacyDistDir = path.join(projectRoot, "dist");
|
||||
export const cliDistDir = path.join(cliRoot, "dist");
|
||||
export const coreDistDir = path.join(coreRoot, "dist");
|
||||
export const electronDistDir = path.join(electronRoot, "dist");
|
||||
export const uiDistDir = path.join(uiRoot, "dist");
|
||||
export const distDir = electronDistDir;
|
||||
export const cliMainOutDir = path.join(cliDistDir, "main");
|
||||
export const coreMainOutDir = path.join(coreDistDir, "main");
|
||||
export const electronMainOutDir = path.join(electronDistDir, "main");
|
||||
export const mainOutDir = electronMainOutDir;
|
||||
export const gatewayPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json"));
|
||||
export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js");
|
||||
export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js");
|
||||
export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json"));
|
||||
export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js");
|
||||
export const botGatewaySdkRunnerInput = path.join(botGatewaySdkPackageRoot, "bin", "bot-gateway-stdio.mjs");
|
||||
export const electronBotGatewaySdkRootDir = path.join(electronMainOutDir, "bot-gateway-sdk");
|
||||
export const electronBotGatewaySdkDistDir = path.join(electronBotGatewaySdkRootDir, "dist");
|
||||
export const electronBotGatewaySdkBinDir = path.join(electronBotGatewaySdkRootDir, "bin");
|
||||
export const electronBotGatewaySdkPackageOutput = path.join(electronBotGatewaySdkRootDir, "package.json");
|
||||
export const electronBotGatewaySdkEntryOutput = path.join(electronBotGatewaySdkDistDir, "index.js");
|
||||
export const electronBotGatewaySdkRunnerOutput = path.join(electronBotGatewaySdkBinDir, "bot-gateway-stdio.mjs");
|
||||
export const rendererOutDir = path.join(uiDistDir, "renderer");
|
||||
export const cliRendererOutDir = path.join(cliDistDir, "renderer");
|
||||
export const coreRendererOutDir = path.join(coreDistDir, "renderer");
|
||||
export const electronRendererOutDir = path.join(electronDistDir, "renderer");
|
||||
export const runtimeRendererOutDirs = [cliRendererOutDir, coreRendererOutDir, electronRendererOutDir];
|
||||
export const appAssetsDir = path.join(electronDistDir, "assets");
|
||||
export const rendererAssetsDir = path.join(rendererOutDir, "assets");
|
||||
export const marketplacePluginsDir = path.join(distDir, "marketplace", "plugins");
|
||||
export const appAssetsInput = path.join(projectRoot, "assets");
|
||||
export const modelCatalogInput = path.join(projectRoot, "models.json");
|
||||
export const modelCatalogOutput = path.join(distDir, "models.json");
|
||||
export const rendererRoot = path.join(projectRoot, "src", "renderer");
|
||||
export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "plugins");
|
||||
export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins");
|
||||
export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins");
|
||||
export const marketplacePluginsDir = electronMarketplacePluginsDir;
|
||||
export const appAssetsInput = path.join(electronRoot, "assets");
|
||||
export const modelCatalogInput = path.join(coreRoot, "models.json");
|
||||
export const cliModelCatalogOutput = path.join(cliDistDir, "models.json");
|
||||
export const coreModelCatalogOutput = path.join(coreDistDir, "models.json");
|
||||
export const electronModelCatalogOutput = path.join(electronDistDir, "models.json");
|
||||
export const modelCatalogOutput = electronModelCatalogOutput;
|
||||
export const rendererRoot = uiSourceRoot;
|
||||
export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html");
|
||||
export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html");
|
||||
export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html");
|
||||
|
|
@ -27,11 +67,14 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray",
|
|||
export const cssInput = path.join(rendererRoot, "styles", "globals.css");
|
||||
export const cssOutput = path.join(rendererAssetsDir, "main.css");
|
||||
export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js");
|
||||
const lightweightMcpBundleNames = ["fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
|
||||
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
|
||||
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
|
||||
const lightweightMcpBundleMaxBytes = 128 * 1024;
|
||||
const forbiddenLightweightMcpInputs = [
|
||||
{ prefix: "src/main/", reason: "main-process modules can pull in config, Electron, or native storage side effects" },
|
||||
{ prefix: "src/renderer/", reason: "renderer modules do not belong in stdio MCP subprocesses" },
|
||||
{ prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" },
|
||||
{ prefix: "packages/core/src/storage/", reason: "native SQLite storage is not allowed in lightweight MCP subprocesses" },
|
||||
{ prefix: "packages/electron/src/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" },
|
||||
{ prefix: "packages/ui/src/", reason: "UI modules do not belong in stdio MCP subprocesses" },
|
||||
{ prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" },
|
||||
{ prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" }
|
||||
];
|
||||
|
|
@ -45,15 +88,28 @@ const nodeExternals = [
|
|||
];
|
||||
|
||||
export function cleanDist() {
|
||||
rmSync(distDir, { force: true, recursive: true });
|
||||
rmSync(legacyDistDir, { force: true, recursive: true });
|
||||
rmSync(cliDistDir, { force: true, recursive: true });
|
||||
rmSync(coreDistDir, { force: true, recursive: true });
|
||||
rmSync(electronDistDir, { force: true, recursive: true });
|
||||
rmSync(uiDistDir, { force: true, recursive: true });
|
||||
ensureDist();
|
||||
}
|
||||
|
||||
export function ensureDist() {
|
||||
mkdirSync(mainOutDir, { recursive: true });
|
||||
mkdirSync(cliMainOutDir, { recursive: true });
|
||||
mkdirSync(coreMainOutDir, { recursive: true });
|
||||
mkdirSync(electronMainOutDir, { recursive: true });
|
||||
mkdirSync(electronBotGatewaySdkDistDir, { recursive: true });
|
||||
mkdirSync(electronBotGatewaySdkBinDir, { recursive: true });
|
||||
mkdirSync(appAssetsDir, { recursive: true });
|
||||
mkdirSync(marketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(cliMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(coreMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(electronMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(rendererAssetsDir, { recursive: true });
|
||||
for (const outputDir of runtimeRendererOutDirs) {
|
||||
mkdirSync(path.join(outputDir, "assets"), { recursive: true });
|
||||
}
|
||||
mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true });
|
||||
|
|
@ -69,12 +125,16 @@ export function copyAppAssets() {
|
|||
export function copyModelCatalog() {
|
||||
ensureDist();
|
||||
if (existsSync(modelCatalogInput)) {
|
||||
cpSync(modelCatalogInput, modelCatalogOutput);
|
||||
cpSync(modelCatalogInput, cliModelCatalogOutput);
|
||||
cpSync(modelCatalogInput, coreModelCatalogOutput);
|
||||
cpSync(modelCatalogInput, electronModelCatalogOutput);
|
||||
}
|
||||
}
|
||||
|
||||
export function copyRendererHtml() {
|
||||
copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js");
|
||||
copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", {
|
||||
beforeModuleScriptTags: [' <script src="../../assets/web-client-bridge.js"></script>']
|
||||
});
|
||||
}
|
||||
|
||||
export function copyTrayRendererHtml() {
|
||||
|
|
@ -89,14 +149,25 @@ export function copyMarketplacePlugins() {
|
|||
ensureDist();
|
||||
for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) {
|
||||
const source = path.join(projectRoot, "examples", "plugins", filename);
|
||||
const target = path.join(marketplacePluginsDir, filename);
|
||||
if (existsSync(source)) {
|
||||
cpSync(source, target);
|
||||
cpSync(source, path.join(cliMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(coreMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(electronMarketplacePluginsDir, filename));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyRendererPageHtml(input, output, scriptName) {
|
||||
export function syncUiRendererToRuntimeDists() {
|
||||
ensureDist();
|
||||
for (const outputDir of runtimeRendererOutDirs) {
|
||||
rmSync(outputDir, { force: true, recursive: true });
|
||||
if (existsSync(rendererOutDir)) {
|
||||
cpSync(rendererOutDir, outputDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyRendererPageHtml(input, output, scriptName, options = {}) {
|
||||
ensureDist();
|
||||
const source = readFileSync(input, "utf8");
|
||||
const styleTag = ' <link rel="stylesheet" href="../../assets/main.css" />';
|
||||
|
|
@ -105,6 +176,12 @@ function copyRendererPageHtml(input, output, scriptName) {
|
|||
? source.replace(' <script type="module" src="./main.tsx"></script>', scriptTag)
|
||||
: source.replace("</body>", `${scriptTag}\n </body>`);
|
||||
|
||||
for (const extraScriptTag of options.beforeModuleScriptTags ?? []) {
|
||||
if (!hasScriptTag(html, extraScriptTag)) {
|
||||
html = html.replace(scriptTag, `${extraScriptTag}\n${scriptTag}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!html.includes('href="../../assets/main.css"')) {
|
||||
html = html.replace("</head>", `${styleTag}\n </head>`);
|
||||
}
|
||||
|
|
@ -112,17 +189,38 @@ function copyRendererPageHtml(input, output, scriptName) {
|
|||
writeFileSync(output, html, "utf8");
|
||||
}
|
||||
|
||||
function hasScriptTag(html, scriptTag) {
|
||||
const sourceMatch = scriptTag.match(/\bsrc="([^"]+)"/);
|
||||
return sourceMatch ? html.includes(sourceMatch[1]) : html.includes(scriptTag);
|
||||
}
|
||||
|
||||
function normalizeDuplicateShebangs(source) {
|
||||
const lines = source.split("\n");
|
||||
if (!lines[0]?.startsWith("#!")) {
|
||||
return source;
|
||||
}
|
||||
let index = 1;
|
||||
while (lines[index]?.startsWith("#!")) {
|
||||
index += 1;
|
||||
}
|
||||
return [lines[0], ...lines.slice(index)].join("\n");
|
||||
}
|
||||
|
||||
export function createMainBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: [
|
||||
path.join(projectRoot, "src", "main", "main.ts"),
|
||||
path.join(projectRoot, "src", "main", "browser-preload.ts"),
|
||||
path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(projectRoot, "src", "server", "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(projectRoot, "src", "main", "preload.ts")
|
||||
path.join(electronSourceRoot, "main", "main.ts"),
|
||||
path.join(electronSourceRoot, "main", "browser-preload.ts"),
|
||||
gatewayRuntimeInput,
|
||||
path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
|
||||
electronUndiciProxyAgentInput,
|
||||
path.join(electronSourceRoot, "main", "preload.ts")
|
||||
],
|
||||
external: nodeExternals,
|
||||
format: "cjs",
|
||||
|
|
@ -130,9 +228,9 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
|
|||
logLevel: "info",
|
||||
metafile: true,
|
||||
minify: mode === "production",
|
||||
outdir: mainOutDir,
|
||||
outdir: electronMainOutDir,
|
||||
platform: "node",
|
||||
plugins,
|
||||
plugins: [packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
|
|
@ -143,15 +241,44 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {}
|
|||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: [path.join(projectRoot, "src", "main", "cli.ts")],
|
||||
entryPoints: [
|
||||
path.join(cliSourceRoot, "cli.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
],
|
||||
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outdir: mainOutDir,
|
||||
outdir: cliMainOutDir,
|
||||
platform: "node",
|
||||
plugins: [forbidCliElectronPlugin(), ...plugins],
|
||||
plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
}
|
||||
|
||||
export function createCoreServerBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: [
|
||||
path.join(coreSourceRoot, "entrypoints", "server.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
],
|
||||
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outdir: coreMainOutDir,
|
||||
platform: "node",
|
||||
plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
|
|
@ -182,7 +309,7 @@ export function createRendererBuildOptions({ mode = "production", plugins = [] }
|
|||
minify: mode === "production",
|
||||
outfile: path.join(rendererAssetsDir, "main.js"),
|
||||
platform: "browser",
|
||||
plugins: [rendererAliasPlugin(), ...plugins],
|
||||
plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
|
||||
publicPath: "../../assets",
|
||||
sourcemap: mode !== "production",
|
||||
target: "chrome120"
|
||||
|
|
@ -209,19 +336,40 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins
|
|||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryPoints: [path.join(projectRoot, "src", "main", "web-client-bridge.ts")],
|
||||
entryPoints: [path.join(uiSourceRoot, "web-client-bridge.ts")],
|
||||
format: "iife",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outfile: webClientBridgeOutput,
|
||||
platform: "browser",
|
||||
plugins,
|
||||
plugins: [packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "chrome120"
|
||||
};
|
||||
}
|
||||
|
||||
export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryPoints: [botGatewaySdkEntryInput],
|
||||
external: [
|
||||
...builtinModules,
|
||||
...builtinModules.map((moduleName) => `node:${moduleName}`)
|
||||
],
|
||||
format: "esm",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outfile: electronBotGatewaySdkEntryOutput,
|
||||
platform: "node",
|
||||
plugins,
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
}
|
||||
|
||||
export function watchPlugin(name, onEnd) {
|
||||
return {
|
||||
name: `${name}-watch`,
|
||||
|
|
@ -238,11 +386,38 @@ export function watchPlugin(name, onEnd) {
|
|||
export async function buildMain(options = {}) {
|
||||
const [mainBuildResult] = await Promise.all([
|
||||
esbuild.build(createMainBuildOptions(options)),
|
||||
esbuild.build(createCliBuildOptions(options))
|
||||
buildBotGatewaySdkRuntime(options),
|
||||
buildCoreServer(options),
|
||||
buildCli(options)
|
||||
]);
|
||||
copyCliRuntimeToElectronDist();
|
||||
validateLightweightMcpBundles(mainBuildResult.metafile);
|
||||
}
|
||||
|
||||
export async function buildBotGatewaySdkRuntime(options = {}) {
|
||||
ensureDist();
|
||||
await esbuild.build(createBotGatewaySdkBuildOptions(options));
|
||||
writeFileSync(
|
||||
electronBotGatewaySdkPackageOutput,
|
||||
`${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
writeFileSync(
|
||||
electronBotGatewaySdkRunnerOutput,
|
||||
normalizeDuplicateShebangs(readFileSync(botGatewaySdkRunnerInput, "utf8")),
|
||||
"utf8"
|
||||
);
|
||||
chmodSync(electronBotGatewaySdkRunnerOutput, 0o755);
|
||||
}
|
||||
|
||||
export async function buildCli(options = {}) {
|
||||
await esbuild.build(createCliBuildOptions(options));
|
||||
}
|
||||
|
||||
export async function buildCoreServer(options = {}) {
|
||||
await esbuild.build(createCoreServerBuildOptions(options));
|
||||
}
|
||||
|
||||
export async function buildRenderer(options = {}) {
|
||||
await esbuild.build(createRendererBuildOptions(options));
|
||||
}
|
||||
|
|
@ -259,6 +434,14 @@ export async function buildWebClientBridge(options = {}) {
|
|||
await esbuild.build(createWebClientBridgeBuildOptions(options));
|
||||
}
|
||||
|
||||
export function copyCliRuntimeToElectronDist() {
|
||||
ensureDist();
|
||||
const cliRuntime = path.join(cliMainOutDir, "cli.js");
|
||||
if (existsSync(cliRuntime)) {
|
||||
cpSync(cliRuntime, path.join(electronMainOutDir, "cli.js"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildStyles({ minify = false } = {}) {
|
||||
ensureDist();
|
||||
const args = ["-i", cssInput, "-o", cssOutput];
|
||||
|
|
@ -304,6 +487,26 @@ function rendererAliasPlugin() {
|
|||
};
|
||||
}
|
||||
|
||||
function packageAliasPlugin() {
|
||||
return {
|
||||
name: "ccr-package-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@ccr\/cli\// }, (args) => {
|
||||
return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) };
|
||||
});
|
||||
build.onResolve({ filter: /^@ccr\/core\// }, (args) => {
|
||||
return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) };
|
||||
});
|
||||
build.onResolve({ filter: /^@ccr\/electron\// }, (args) => {
|
||||
return { path: resolvePackageImport(electronSourceRoot, args.path.slice("@ccr/electron/".length)) };
|
||||
});
|
||||
build.onResolve({ filter: /^@ccr\/ui\// }, (args) => {
|
||||
return { path: resolvePackageImport(uiSourceRoot, args.path.slice("@ccr/ui/".length)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function forbidCliElectronPlugin() {
|
||||
return {
|
||||
name: "forbid-cli-electron",
|
||||
|
|
@ -370,19 +573,23 @@ function normalizeBuildPath(value) {
|
|||
}
|
||||
|
||||
function resolveRendererImport(importPath) {
|
||||
const basePath = path.resolve(rendererRoot, importPath);
|
||||
return resolvePackageImport(rendererRoot, importPath);
|
||||
}
|
||||
|
||||
function resolvePackageImport(rootDir, importPath) {
|
||||
const packageBasePath = path.resolve(rootDir, importPath);
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.jsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.json`,
|
||||
`${basePath}.css`,
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
path.join(basePath, "index.js")
|
||||
packageBasePath,
|
||||
`${packageBasePath}.tsx`,
|
||||
`${packageBasePath}.ts`,
|
||||
`${packageBasePath}.jsx`,
|
||||
`${packageBasePath}.js`,
|
||||
`${packageBasePath}.json`,
|
||||
`${packageBasePath}.css`,
|
||||
path.join(packageBasePath, "index.tsx"),
|
||||
path.join(packageBasePath, "index.ts"),
|
||||
path.join(packageBasePath, "index.jsx"),
|
||||
path.join(packageBasePath, "index.js")
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
|
|
@ -391,5 +598,5 @@ function resolveRendererImport(importPath) {
|
|||
}
|
||||
}
|
||||
|
||||
return basePath;
|
||||
return packageBasePath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function runSuite(suite) {
|
|||
console.log(`\nRunning ${suite} tests...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], {
|
||||
const child = spawn(electron, ["--test", `dist/tests/${suite}/*.test.js`], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { fileURLToPath } from "node:url";
|
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const testsOutDir = path.join(projectRoot, "dist", "tests");
|
||||
const rendererRoot = path.join(projectRoot, "src", "renderer");
|
||||
const rendererRoot = path.join(projectRoot, "packages", "ui", "src");
|
||||
const cliSourceRoot = path.join(projectRoot, "packages", "cli", "src");
|
||||
const coreSourceRoot = path.join(projectRoot, "packages", "core", "src");
|
||||
const testSuites = [
|
||||
{ name: "main", testDir: path.join(projectRoot, "tests", "main") },
|
||||
{ name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") }
|
||||
|
|
@ -25,7 +27,10 @@ if (unknownSuites.length > 0) {
|
|||
rmSync(testsOutDir, { force: true, recursive: true });
|
||||
|
||||
for (const suite of selectedSuites) {
|
||||
const entryPoints = findTestFiles(suite.testDir);
|
||||
const entryPoints = [
|
||||
...findTestFiles(suite.testDir),
|
||||
...runtimeEntryPointsForSuite(suite.name)
|
||||
];
|
||||
if (entryPoints.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -53,11 +58,21 @@ for (const suite of selectedSuites) {
|
|||
logLevel: "info",
|
||||
outdir: path.join(testsOutDir, suite.name),
|
||||
platform: "node",
|
||||
plugins: [rendererAliasPlugin()],
|
||||
plugins: [rendererAliasPlugin(), packageAliasPlugin()],
|
||||
target: "node22"
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeEntryPointsForSuite(suiteName) {
|
||||
if (suiteName !== "main") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
];
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
|
|
@ -87,8 +102,26 @@ function rendererAliasPlugin() {
|
|||
};
|
||||
}
|
||||
|
||||
function packageAliasPlugin() {
|
||||
return {
|
||||
name: "test-package-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@ccr\/cli\// }, (args) => {
|
||||
return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) };
|
||||
});
|
||||
build.onResolve({ filter: /^@ccr\/core\// }, (args) => {
|
||||
return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRendererImport(importPath) {
|
||||
const basePath = path.resolve(rendererRoot, importPath);
|
||||
return resolvePackageImport(rendererRoot, importPath);
|
||||
}
|
||||
|
||||
function resolvePackageImport(rootDir, importPath) {
|
||||
const basePath = path.resolve(rootDir, importPath);
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.tsx`,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,19 @@ const betterSqliteNativeRelativePath = path.join(
|
|||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
const betterSqlitePackageRelativePath = path.join("app.asar.unpacked", "node_modules", "better-sqlite3");
|
||||
const betterSqlitePrunablePaths = [
|
||||
"deps",
|
||||
"src",
|
||||
"binding.gyp",
|
||||
"README.md",
|
||||
"docs",
|
||||
"benchmark",
|
||||
"benchmarks",
|
||||
"test",
|
||||
path.join("build", "Release", "obj"),
|
||||
path.join("build", "Release", "obj.target")
|
||||
];
|
||||
|
||||
module.exports = async function verifyPackagedApp(context) {
|
||||
const platform = context?.electronPlatformName;
|
||||
|
|
@ -21,6 +34,7 @@ module.exports = async function verifyPackagedApp(context) {
|
|||
|
||||
const resourcesDir = findResourcesDir(appOutDir, platform);
|
||||
assertFile(path.join(resourcesDir, "app.asar"), "Packaged app archive");
|
||||
cleanupBetterSqlitePackage(resourcesDir);
|
||||
|
||||
const nativeModule = path.join(resourcesDir, betterSqliteNativeRelativePath);
|
||||
assertFile(nativeModule, "better-sqlite3 native module");
|
||||
|
|
@ -41,6 +55,13 @@ module.exports = async function verifyPackagedApp(context) {
|
|||
}
|
||||
};
|
||||
|
||||
function cleanupBetterSqlitePackage(resourcesDir) {
|
||||
const packageDir = path.join(resourcesDir, betterSqlitePackageRelativePath);
|
||||
for (const relativePath of betterSqlitePrunablePaths) {
|
||||
fs.rmSync(path.join(packageDir, relativePath), { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function findResourcesDir(appOutDir, platform) {
|
||||
if (platform !== "darwin") {
|
||||
return path.join(appOutDir, "resources");
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/renderer/styles/globals.css",
|
||||
"css": "packages/ui/src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
|
|
|
|||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
services:
|
||||
ccr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: claude-code-router:local
|
||||
ports:
|
||||
# Publish only Nginx. Internal web/gateway listeners stay inside the container.
|
||||
- "3458:8080"
|
||||
volumes:
|
||||
- ccr-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ccr-data:
|
||||
91
docker/README.md
Normal file
91
docker/README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# 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.
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- Web UI: <http://localhost:3458>
|
||||
- Gateway endpoint: <http://localhost:3458>
|
||||
|
||||
`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:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "8088:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
|
||||
```
|
||||
|
||||
The container stores config and SQLite databases under `/data`, backed by the
|
||||
`ccr-data` volume in `docker-compose.yml`.
|
||||
|
||||
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.
|
||||
|
||||
## Image scripts
|
||||
|
||||
```sh
|
||||
npm run docker:build
|
||||
npm run docker:run
|
||||
```
|
||||
|
||||
## Smoke test
|
||||
|
||||
```sh
|
||||
npm run test:docker
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE=node:22-bookworm \
|
||||
--build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
|
||||
-t claude-code-router:local .
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
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`.
|
||||
|
||||
| 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 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.
|
||||
207
docker/entrypoint.sh
Normal file
207
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CCR_DATA_DIR="${CCR_DATA_DIR:-/data}"
|
||||
CCR_WEB_HOST="${CCR_WEB_HOST:-127.0.0.1}"
|
||||
CCR_WEB_PORT="${CCR_WEB_PORT:-3459}"
|
||||
CCR_NGINX_PORT="${CCR_NGINX_PORT:-8080}"
|
||||
CCR_GATEWAY_HOST="${CCR_GATEWAY_HOST:-127.0.0.1}"
|
||||
CCR_GATEWAY_PORT="${CCR_GATEWAY_PORT:-3456}"
|
||||
CCR_GATEWAY_CORE_PORT="${CCR_GATEWAY_CORE_PORT:-3457}"
|
||||
CCR_PUBLIC_HOST="${CCR_PUBLIC_HOST:-127.0.0.1}"
|
||||
CCR_PUBLIC_PORT="${CCR_PUBLIC_PORT:-3458}"
|
||||
CCR_PUBLIC_BASE_URL="${CCR_PUBLIC_BASE_URL:-http://${CCR_PUBLIC_HOST}:${CCR_PUBLIC_PORT}}"
|
||||
CCR_NO_GATEWAY="${CCR_NO_GATEWAY:-0}"
|
||||
|
||||
if [ -z "${CCR_WEB_AUTH_TOKEN:-}" ]; then
|
||||
CCR_WEB_AUTH_TOKEN="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")"
|
||||
fi
|
||||
CCR_WEB_AUTH_TOKEN_QUERY="$(node -e "process.stdout.write(encodeURIComponent(process.argv[1] || ''))" "${CCR_WEB_AUTH_TOKEN}")"
|
||||
|
||||
export HOME="${CCR_DATA_DIR}"
|
||||
export CCR_DATA_DIR
|
||||
export CCR_GATEWAY_CORE_PORT
|
||||
export CCR_GATEWAY_HOST
|
||||
export CCR_GATEWAY_PORT
|
||||
export CCR_NGINX_PORT
|
||||
export CCR_NO_GATEWAY
|
||||
export CCR_PUBLIC_BASE_URL
|
||||
export CCR_PUBLIC_HOST
|
||||
export CCR_PUBLIC_PORT
|
||||
export CCR_WEB_AUTH_TOKEN
|
||||
export CCR_WEB_AUTH_TOKEN_QUERY
|
||||
export CCR_WEB_HOST
|
||||
export CCR_WEB_PORT
|
||||
|
||||
CONFIG_DIR="${HOME}/.claude-code-router"
|
||||
CONFIG_FILE="${CONFIG_DIR}/config.json"
|
||||
APP_CONFIG_DB_FILE="${CONFIG_DIR}/config.sqlite"
|
||||
|
||||
mkdir -p "${CONFIG_DIR}" "${CONFIG_DIR}/app-data" /run/nginx /var/lib/nginx /var/log/nginx
|
||||
|
||||
if [ "${CCR_DOCKER_INIT_CONFIG:-1}" != "0" ] && [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${APP_CONFIG_DB_FILE}" ]; then
|
||||
node - <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const configDir = path.join(process.env.HOME, ".claude-code-router");
|
||||
const configFile = path.join(configDir, "config.json");
|
||||
const gatewayHost = process.env.CCR_GATEWAY_HOST || "0.0.0.0";
|
||||
const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456");
|
||||
const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457");
|
||||
const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, "");
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(configFile, `${JSON.stringify({
|
||||
HOST: gatewayHost,
|
||||
PORT: gatewayPort,
|
||||
gateway: {
|
||||
coreHost: "127.0.0.1",
|
||||
corePort: gatewayCorePort,
|
||||
enabled: true,
|
||||
host: gatewayHost,
|
||||
port: gatewayPort
|
||||
},
|
||||
routerEndpoint: publicBaseUrl
|
||||
}, null, 2)}\n`, { mode: 0o600 });
|
||||
NODE
|
||||
fi
|
||||
|
||||
if [ "${CCR_DOCKER_SYNC_PUBLIC_ENDPOINT:-1}" != "0" ]; then
|
||||
node - <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const configDir = path.join(process.env.HOME, ".claude-code-router");
|
||||
const configFile = path.join(configDir, "config.json");
|
||||
const appConfigDbFile = path.join(configDir, "config.sqlite");
|
||||
const gatewayHost = process.env.CCR_GATEWAY_HOST || "127.0.0.1";
|
||||
const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456");
|
||||
const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457");
|
||||
const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, "");
|
||||
|
||||
function syncConfig(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
value.HOST = gatewayHost;
|
||||
value.PORT = gatewayPort;
|
||||
value.gateway = {
|
||||
...(value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway) ? value.gateway : {}),
|
||||
coreHost: "127.0.0.1",
|
||||
corePort: gatewayCorePort,
|
||||
enabled: true,
|
||||
host: gatewayHost,
|
||||
port: gatewayPort
|
||||
};
|
||||
value.routerEndpoint = publicBaseUrl;
|
||||
return value;
|
||||
}
|
||||
|
||||
function syncJsonFile() {
|
||||
if (!fs.existsSync(configFile)) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
||||
fs.writeFileSync(configFile, `${JSON.stringify(syncConfig(parsed), null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function syncSqliteConfig() {
|
||||
if (!fs.existsSync(appConfigDbFile)) {
|
||||
return;
|
||||
}
|
||||
let Database;
|
||||
try {
|
||||
Database = require("better-sqlite3");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const db = new Database(appConfigDbFile);
|
||||
try {
|
||||
const row = db.prepare("select value_json from app_config where key = ?").get("default");
|
||||
if (!row?.value_json) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(row.value_json);
|
||||
db.prepare("update app_config set value_json = ?, updated_at = ? where key = ?")
|
||||
.run(JSON.stringify(syncConfig(parsed)), new Date().toISOString(), "default");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
syncJsonFile();
|
||||
syncSqliteConfig();
|
||||
NODE
|
||||
fi
|
||||
|
||||
cat > /etc/nginx/conf.d/default.conf <<EOF
|
||||
server {
|
||||
listen ${CCR_NGINX_PORT};
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index pages/home/index.html;
|
||||
absolute_redirect off;
|
||||
|
||||
client_max_body_size 8m;
|
||||
|
||||
location = / {
|
||||
return 302 /pages/home/index.html?ccr_web_token=${CCR_WEB_AUTH_TOKEN_QUERY};
|
||||
}
|
||||
|
||||
location = /pages/home/index.html {
|
||||
if (\$arg_ccr_web_token = "") {
|
||||
return 302 /pages/home/index.html?ccr_web_token=${CCR_WEB_AUTH_TOKEN_QUERY};
|
||||
}
|
||||
try_files /pages/home/index.html =404;
|
||||
}
|
||||
|
||||
location = /api/ccr/rpc {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host ${CCR_WEB_HOST}:${CCR_WEB_PORT};
|
||||
proxy_set_header Origin http://${CCR_WEB_HOST}:${CCR_WEB_PORT};
|
||||
proxy_set_header Referer http://${CCR_WEB_HOST}:${CCR_WEB_PORT}/;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host \$host;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_pass http://${CCR_WEB_HOST}:${CCR_WEB_PORT};
|
||||
}
|
||||
|
||||
location = /health {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host ${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host \$host;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_pass http://${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
|
||||
}
|
||||
|
||||
location ~ ^/(v1|v1beta|mcp|messages|chat/completions|responses|interactions)(/|$) {
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host ${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host \$host;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_pass http://${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /pages/home/index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
if [ -x /app/node_modules/.bin/pm2-runtime ]; then
|
||||
exec /app/node_modules/.bin/pm2-runtime docker/pm2.config.cjs
|
||||
fi
|
||||
|
||||
exec /app/packages/core/node_modules/.bin/pm2-runtime docker/pm2.config.cjs
|
||||
35
docker/pm2.config.cjs
Normal file
35
docker/pm2.config.cjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const noGateway = /^(1|true|yes)$/i.test(process.env.CCR_NO_GATEWAY || "");
|
||||
const serverArgs = [
|
||||
"--host",
|
||||
process.env.CCR_WEB_HOST || "127.0.0.1",
|
||||
"--port",
|
||||
process.env.CCR_WEB_PORT || "3459",
|
||||
"--no-open"
|
||||
];
|
||||
|
||||
if (noGateway) {
|
||||
serverArgs.push("--no-gateway");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "ccr-core-server",
|
||||
script: "/app/packages/core/dist/main/server.js",
|
||||
args: serverArgs,
|
||||
cwd: "/app",
|
||||
interpreter: "node",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "ccr-nginx",
|
||||
script: "/usr/sbin/nginx",
|
||||
args: ["-g", "daemon off;"],
|
||||
cwd: "/app",
|
||||
interpreter: "none"
|
||||
}
|
||||
]
|
||||
};
|
||||
BIN
docs/public/provider-icons/claudeapi.png
Normal file
BIN
docs/public/provider-icons/claudeapi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/public/provider-icons/code0.png
Normal file
BIN
docs/public/provider-icons/code0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/public/provider-icons/teamorouter.png
Normal file
BIN
docs/public/provider-icons/teamorouter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
|
|
@ -2,29 +2,37 @@
|
|||
title: Claude Code Router Detailed Configuration
|
||||
pageTitle: Detailed Configuration
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Configure the overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and the config database location in detail.
|
||||
lead: "Separate main app pages from settings pages while following the app's actual order: main pages cover overview, providers, Agent Config, routing, Fusion, API keys, logs and observability, server, and extensions; settings pages cover ToolHub, Bots, data, and tray."
|
||||
---
|
||||
|
||||
## Page Structure
|
||||
|
||||
Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page.
|
||||
Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page. Main pages follow the app's left navigation order. Settings pages are grouped separately and follow the settings dialog order.
|
||||
|
||||
## Main Pages
|
||||
|
||||
| Page | Covers |
|
||||
| --- | --- |
|
||||
| Overview Dashboard | System status, account balance, usage widgets, layout editing, and share cards |
|
||||
| API Keys | Client access keys, expiration, and local limits |
|
||||
| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate |
|
||||
| Provider Config | Upstream services, protocol, Base URL, model list, and credentials |
|
||||
| One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries |
|
||||
| Routing Config | Default routing, conditional rules, fallback, and request rewrites |
|
||||
| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results |
|
||||
| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model |
|
||||
| Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding |
|
||||
| Routing Config | Default routing, conditional rules, fallback, and request rewrites |
|
||||
| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model |
|
||||
| API Keys | Client access keys, expiration, and local limits |
|
||||
| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results |
|
||||
| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate |
|
||||
| Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging |
|
||||
|
||||
## Settings Pages
|
||||
|
||||
| Page | Covers |
|
||||
| --- | --- |
|
||||
| ToolHub | Collapse many MCP servers into one dynamic tool resolution entry point for agents |
|
||||
| Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages |
|
||||
| Tray Configuration | Tray icon, balance progress, and tray window widgets |
|
||||
| Config Database Location | SQLite config database location maintained by the desktop app |
|
||||
| Tray Configuration | Tray icon, balance progress, and tray window widgets |
|
||||
|
||||
## Content Relationships
|
||||
|
||||
Overview Dashboard shows system status and usage. API Keys control client access to CCR. Server controls the local gateway listener and proxy features. Provider Config and One click import cover how upstream model services enter CCR. Routing determines where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Extension Mechanism covers local plugin creation, installation, and debugging. Bots cover IM platform relay. Tray Configuration covers the menu bar icon and tray window.
|
||||
Overview Dashboard shows system status and usage. Provider Config and One click import cover how upstream model services enter CCR. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Routing determines where model requests go. Fusion covers vision, web search, and MCP tools. API Keys control client access to CCR. Logs & Observability cover request logs and agent execution traces. Server controls the local gateway listener and proxy features. Extension Mechanism covers local plugin creation, installation, and debugging. ToolHub, Bots, Config Database Location, and Tray Configuration match the corresponding settings pages in the settings dialog.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,20 @@ Use `ccr-fusion-builtins / web_search`.
|
|||
|
||||
## Search Providers
|
||||
|
||||
Supported providers include Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.
|
||||
Supported providers include In-app Browser, Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.
|
||||
|
||||
## In-app Browser
|
||||
|
||||
`In-app Browser` runs searches through a hidden built-in browser window in CCR Desktop, opens result pages, extracts visible content, and passes that evidence to the Fusion model. It does not require an external search API key, so it is useful when you want desktop-side browser retrieval.
|
||||
|
||||
Configuration options include search engine, language, country or region, and safe-search level:
|
||||
|
||||
- Search engine: Bing, Google, or DuckDuckGo.
|
||||
- Language: for example `en` or `zh-CN`.
|
||||
- Country or region: for example `US` or `CN`.
|
||||
- Safe search: default, moderate, strict, or off.
|
||||
|
||||
> Note: `In-app Browser` depends on CCR Desktop's Electron built-in browser capability and is only available in the desktop app. CLI, server deployments, and pure web environments do not have the built-in browser integration; use Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, or Exa instead.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -2,52 +2,130 @@
|
|||
title: Overview Dashboard
|
||||
pageTitle: Overview Dashboard
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Customize the CCR home dashboard for system status, account balance, requests, tokens, cost, model distribution, and share cards.
|
||||
lead: Customize the CCR home dashboard to inspect system status, account balance, requests, tokens, cost, and model distribution.
|
||||
---
|
||||
|
||||
## Top Controls
|
||||
## When To Use It
|
||||
|
||||
| Field | Capability |
|
||||
| Scenario | What to inspect |
|
||||
| --- | --- |
|
||||
| Usage over time | Changes the statistics range used by the dashboard. |
|
||||
| Today / 24h / 7d / 30d | Available time windows. Widgets recalculate requests, tokens, cost, and trends for the selected range. |
|
||||
| Edit widgets | Enters layout editing mode. |
|
||||
| Reset layout | Restores the default overview layout. |
|
||||
| Done | Leaves editing mode and keeps the current widget configuration. |
|
||||
| Check gateway health | System status, success rate, errors, average latency |
|
||||
| Estimate recent spend | Requests, input / output / cache tokens, estimated cost |
|
||||
| Compare upstream usage | Provider analysis, model distribution, client analysis |
|
||||
| Watch account quota | Balance, subscription quota, remaining quota, account status |
|
||||
| Report or share usage | AI Usage Wrapped, CCR Route Map, Model Leaderboard, Spend Receipt, and other share cards |
|
||||
|
||||
## Widget Editing
|
||||
## Time Range
|
||||
|
||||
In editing mode, the left `Components` panel adds widgets, the middle `Preview` panel shows the current layout, and the right `Component properties` panel edits the selected widget.
|
||||
The `Usage over time` control at the top drives every widget that depends on usage stats. After you switch ranges, requests, tokens, cost, trends, distribution, and share cards are recomputed for the selected window.
|
||||
|
||||
| Field | Capability |
|
||||
| Option | Window |
|
||||
| --- | --- |
|
||||
| Components | List of widgets that can be added. |
|
||||
| Preview | Current dashboard layout. Widgets can be dragged to reorder. |
|
||||
| Component properties | Configuration for the selected widget. |
|
||||
| Component category | Changes the widget category, such as status, account, metric, trend, activity, breakdown, analysis, or share card. |
|
||||
| Data | Selects the data shown by the widget, such as requests, tokens, cost, account, client analysis, or provider analysis. |
|
||||
| Widget size | Controls the widget's grid width and height. |
|
||||
| Style | Changes visual style, such as cards, compact, bar, line, ring, and more. |
|
||||
| Remove widget | Removes the selected widget from the overview. |
|
||||
| `Today` | Current local date from 00:00 to now, bucketed hourly. |
|
||||
| `24h` | Last 24 hours, bucketed hourly. |
|
||||
| `7d` | Last 7 days, bucketed daily. |
|
||||
| `30d` | Last 30 days, bucketed daily. |
|
||||
|
||||
## Widget Types
|
||||
The account balance widget does not use this time range. It shows the latest snapshot returned by provider account connectors.
|
||||
|
||||
| Widget | Capability |
|
||||
## Edit Layout
|
||||
|
||||
Click the pencil button in the upper-right corner to enter editing mode. Editing mode has three columns:
|
||||
|
||||
| Area | Purpose |
|
||||
| --- | --- |
|
||||
| Status component | Shows a system status timeline for recent gateway health. |
|
||||
| Account component | Shows provider account balance, quota, or usage. Requires provider `Fetch usage`. |
|
||||
| Metric component | Shows requests, total tokens, input tokens, output tokens, cache tokens, cache ratio, estimated cost, success rate, errors, or average latency. |
|
||||
| Trend component | Shows usage trend over time. |
|
||||
| Activity component | Shows token activity as a heatmap. |
|
||||
| Breakdown component | Shows Token mix or Model distribution. |
|
||||
| Analysis component | Shows Client Analysis or Provider Analysis. |
|
||||
| Share card | Generates shareable PNG cards such as AI Usage Wrapped, CCR Route Map, Model Leaderboard, AI Fuel Cockpit, Token Calendar Poster, and Spend Receipt. |
|
||||
| Components | Left palette. Click a template to add it to the dashboard. |
|
||||
| Preview | Middle layout preview. Drag widgets to reorder them or click a widget to select it. |
|
||||
| Component properties | Right property panel for changing type, data, size, style, or removing the selected widget. |
|
||||
|
||||
## Data Sources
|
||||
Common operations:
|
||||
|
||||
| Data | Source |
|
||||
1. Add a widget: click a template in `Components`.
|
||||
2. Reorder widgets: drag them in `Preview`.
|
||||
3. Resize a widget: select it, then drag the right, bottom, or bottom-right resize handle.
|
||||
4. Change data: use `Component category` and `Data` in `Component properties`.
|
||||
5. Change presentation: choose `Widget size` and `Style`.
|
||||
6. Save the result: click `Done`; the layout is persisted in app configuration.
|
||||
7. Restore defaults: click `Reset layout` while editing.
|
||||
|
||||
Removing a widget only removes that card from the overview layout. It does not delete request logs, providers, account connectors, or upstream configuration. If all widgets are removed, the page shows `No widgets configured`.
|
||||
|
||||
## Widget Catalog
|
||||
|
||||
Sizes are written as `width:height`, with both dimensions from `1` to `4`. The overview grid has up to 4 columns on desktop and collapses automatically on narrow screens.
|
||||
|
||||
| Widget | Data | Default size | Default style | Styles |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Status component | System status | `4:1` | Timeline | Timeline, Compact |
|
||||
| Account component | All accounts or one account | `4:2` | Cards | Cards, Compact, Bars, Ring, Semicircle, Arc, Nested rings |
|
||||
| Metric component | Requests, tokens, cost | `1:1` | Cards | Cards, Compact, Bar, Ring |
|
||||
| Trend component | Usage over time | `3:2` | Composed | Composed, Area, Line, Bar |
|
||||
| Activity component | Token activity | `4:2` | Heatmap | Heatmap |
|
||||
| Breakdown component | Token distribution / Model distribution | Token distribution: `1:2`; Model distribution: `2:2` | Token distribution: Bars; Model distribution: Pie | Bars, Stacked, Donut, Pie |
|
||||
| Analysis component | Client Analysis / Provider Analysis | `2:2` | Table | Table, Compact |
|
||||
| Share card | AI Usage Wrapped, CCR Route Map, Model Leaderboard, AI Fuel Cockpit, Token Calendar Poster, Spend Receipt | `1:4` | Card | Card |
|
||||
|
||||
Size constraints:
|
||||
|
||||
| Rule | Reason |
|
||||
| --- | --- |
|
||||
| Requests, tokens, cost, success rate, latency | Request logs and usage stats. |
|
||||
| Account balance, quota, status messages | Provider `Fetch usage` configuration. |
|
||||
| Client analysis, provider analysis, model distribution | Client, provider, model, and token data from request logs. |
|
||||
| Agent analysis data | Agent observability settings and agent execution traces. |
|
||||
| Share cards have a minimum size of `1:4`. | PNG export uses a vertical poster ratio and needs enough height. |
|
||||
| The account widget has a minimum size of `2:2` when showing All accounts with the Compact style. | Multi-account lists need readable space. |
|
||||
| Legacy aliases are still accepted: `small` -> `1:1`, `medium` / `large` -> `2:2`, `wide` -> `3:2`, `full` -> `4:1` or `4:2`. | Backward compatibility for older config. |
|
||||
|
||||
## Metric Data
|
||||
|
||||
`metric` widgets use the `metric` field to choose the displayed value.
|
||||
|
||||
| `metric` | Meaning |
|
||||
| --- | --- |
|
||||
| `requests` | Request count |
|
||||
| `total-tokens` | Total tokens |
|
||||
| `input-tokens` | Input tokens |
|
||||
| `output-tokens` | Output tokens |
|
||||
| `cache-tokens` | Cache tokens |
|
||||
| `cache-ratio` | Cache ratio |
|
||||
| `estimated-cost` | Estimated cost, calculated from model pricing data |
|
||||
| `success-rate` | Success rate |
|
||||
| `errors` | Error count |
|
||||
| `avg-latency` | Average latency |
|
||||
|
||||
## Account Widget
|
||||
|
||||
The account widget reads provider account / usage connectors. To show balance or remaining quota, first enable and test `Fetch usage` in provider configuration.
|
||||
|
||||
| Data selection | Behavior |
|
||||
| --- | --- |
|
||||
| `All accounts` | Shows every available account snapshot. |
|
||||
| One account | Shows only one provider or credential snapshot. The internal config value is usually `provider` or `provider::credentialId`. |
|
||||
|
||||
If the account widget is empty, check:
|
||||
|
||||
1. Whether the provider has an account / usage connector configured.
|
||||
2. Whether the `Fetch usage` test succeeds.
|
||||
3. Whether the API key or account endpoint is still valid.
|
||||
4. Whether the selected account was deleted or renamed.
|
||||
|
||||
## Share Cards
|
||||
|
||||
Share card widgets can export PNGs through the download button in the card header. The desktop app uses native export when available; browser environments fall back to frontend canvas export. The exported image size is `1080 x 1350`.
|
||||
|
||||
| Card | `type` | Content |
|
||||
| --- | --- | --- |
|
||||
| AI Usage Wrapped | `share-usage-wrapped` | Total tokens, requests, estimated cost, cache ratio, longest activity streak, top model, top provider, peak day. |
|
||||
| CCR Route Map | `share-route-map` | Main client-to-provider/model route relationships, plus client, provider, and model counts. |
|
||||
| Model Leaderboard | `share-model-leaderboard` | Models ranked by tokens. |
|
||||
| AI Fuel Cockpit | `share-fuel-cockpit` | Up to 3 account quota gauges. Requires account / usage connectors. |
|
||||
| Token Calendar Poster | `share-token-calendar` | Contribution-calendar style token activity poster. |
|
||||
| Spend Receipt | `share-spend-receipt` | Estimated cost, requests, tokens, latency, and success rate for the selected range. |
|
||||
|
||||
## Data Sources And Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | What to do |
|
||||
| --- | --- | --- |
|
||||
| Requests, tokens, or cost are 0 | No requests went through CCR in the selected range, or usage capture has not recorded data yet. | Try `24h` / `7d`, and confirm the client is actually using CCR. |
|
||||
| Cost shows `$0.00` | The model has no pricing data, or usage is very small. | Check model catalog matching and provider model names; values under 0.01 USD are shown with extra decimals. |
|
||||
| Success rate or errors look unexpected | The overview only aggregates request results captured by CCR. | Compare with records on the Logs page. |
|
||||
| Account balance is empty | No account connector exists, or `Fetch usage` failed. | Test account / usage field mapping in provider configuration. |
|
||||
| Distribution charts have no data | Request logs lack model, provider, or token information. | Confirm requests go through CCR and upstream responses include token usage. |
|
||||
| PNG export fails | Canvas export is unavailable, the element has no size, or the save dialog was canceled. | Retry in the desktop app, and make sure the card is visible and not resized too small. |
|
||||
|
|
|
|||
|
|
@ -74,6 +74,18 @@ Choose a provider below to get started. CCR shows what will be added before savi
|
|||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/runapi.jpg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">RunAPI</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-teamorouter" href="ccr://provider?name=TeamoRouter&base_url=https%3A%2F%2Fapi.teamorouter.com&protocol=anthropic_messages&source=https%3A%2F%2Fteamorouter.com%2F" aria-label="Import TeamoRouter provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/teamorouter.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">TeamoRouter</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&base_url=https%3A%2F%2Fconsole.code0.ai&protocol=anthropic_messages&source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="Import code0.ai provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/code0.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&base_url=https%3A%2F%2Fgw.claudeapi.com&protocol=anthropic_messages&source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="Import claudeapi provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Embeddable Button Component
|
||||
|
|
@ -133,7 +145,7 @@ For larger configs, pass a manifest:
|
|||
| `name` | Provider display name |
|
||||
| `base_url` | Provider API Base URL, required for direct imports |
|
||||
| `api_key` | Optional provider API key |
|
||||
| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content` |
|
||||
| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content`, `gemini_interactions` |
|
||||
| `models` | Model list. Use comma/newline-separated text in HTML, or a string/array in JavaScript |
|
||||
| `icon` | Provider icon URL |
|
||||
| `source` | Provider website or config source |
|
||||
|
|
@ -261,7 +273,7 @@ Complete manifest example:
|
|||
| `name` | Provider display name |
|
||||
| `base_url` | Provider API Base URL, required |
|
||||
| `api_key` | Optional provider API key |
|
||||
| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content` |
|
||||
| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content`, `gemini_interactions` |
|
||||
| `models` | Model list, comma-separated, newline-separated, or repeated |
|
||||
| `icon` | Provider icon URL |
|
||||
| `source` | Provider website or config source |
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ Network errors move to the next attempt. Status-code fallback depends on the mod
|
|||
| Retry | `408`, `409`, `429`, `5xx` |
|
||||
| Fallback targets | Any `4xx` or `5xx` |
|
||||
|
||||
For `429` rate-limit responses, CCR waits before the next attempt. It honors `Retry-After` when the upstream provides it; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt.
|
||||
Before moving to the next attempt, CCR waits for every fallback-triggering failure, including network errors. It honors a positive `Retry-After` header when the upstream provides one; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt.
|
||||
|
||||
**Fallback targets** also switches on `4xx` because model-not-found, auth, or provider-side rejection errors may only affect the current target. If the fallback model works, the request can still succeed.
|
||||
|
||||
|
|
|
|||
167
docs/src/content/docs/en/configuration/toolhub.md
Normal file
167
docs/src/content/docs/en/configuration/toolhub.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
---
|
||||
title: ToolHub
|
||||
pageTitle: ToolHub
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Collapse many MCP servers into one compact entry point so agents lazy-load task-specific tools and save context.
|
||||
---
|
||||
|
||||
## When To Use It
|
||||
|
||||
As your MCP setup grows, exposing every tool directly to an agent makes the eager tool list large and easier to misuse. ToolHub exposes one `ccr-toolhub` MCP server with two meta tools:
|
||||
|
||||
- `tool_hub.resolve`: searches the available MCP tool catalog for the current task.
|
||||
- `tool_hub.invoke`: calls a real MCP tool that was selected for this task.
|
||||
|
||||
Use ToolHub for tools that are not needed often but are still useful occasionally. It lazy-loads them only when a task actually needs them. The main value is saving context: large low-frequency tool catalogs do not have to stay in the agent's eager tool list, which reduces context use and the chance of selecting the wrong tool. Simple local code, file, or conversation tasks usually do not need ToolHub.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Enable ToolHub in **Settings → ToolHub**.
|
||||
2. Select a configured model as the **Resolver model**. It reads the MCP tool catalog and chooses the tools needed for the task. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier.
|
||||
3. Add or import backend MCP servers. ToolHub supports `stdio`, `streamable-http`, and `sse`.
|
||||
4. Open Claude Code or Codex from CCR. CCR writes the `ccr-toolhub` MCP server into that agent config.
|
||||
5. When the agent receives a request about external services, installed MCP capabilities, or business APIs, it calls `tool_hub.resolve` first, then uses `tool_hub.invoke` to run the selected tools.
|
||||
|
||||
ToolHub combines MCP servers configured on the ToolHub page with compatible global Agent MCP servers from older configs, and excludes `ccr-toolhub` itself to avoid recursive calls.
|
||||
|
||||
## Built-In Browser Automation
|
||||
|
||||
When ToolHub is enabled in CCR Desktop and **Built-in browser automation** is turned on, agents can use the desktop built-in browser for web tasks. You do not need to add a browser backend on the ToolHub page, and you do not need a separate API key; CCR connects to it through the local gateway authentication path.
|
||||
|
||||
To enable it:
|
||||
|
||||
1. Open **Settings → ToolHub** and turn on **Enable ToolHub**.
|
||||
2. Turn on **Built-in browser automation** on the same page. This switch is shown only after ToolHub is enabled.
|
||||
3. After saving settings, reopen Claude Code or Codex from CCR so the new agent instance loads the latest configuration.
|
||||
|
||||
> Already-running agent instances usually do not pick up this switch immediately. Restart the agent instance, or use the agent's own controls to restart ToolHub.
|
||||
|
||||
Built-in browser automation is useful for tasks that need real browser state, such as opening sites, reading pages, filling forms, clicking buttons, scrolling, or completing web flows like ordering, booking, lookup, and checkout when no domain-specific capability exists. After it is enabled, the agent can:
|
||||
|
||||
- Open or attach built-in browser tabs, then navigate to URLs or search queries.
|
||||
- Read page content and find buttons, links, form fields, and other page elements.
|
||||
- Click, type, select, press keys, and scroll page elements.
|
||||
- Wait for page loads, navigation, dialogs, or human handoff results before continuing.
|
||||
- Request human help for login, verification codes, CAPTCHA, human checks, or manual confirmation.
|
||||
|
||||
When a web flow needs login, verification codes, CAPTCHA, a human check, or manual confirmation, CCR shows the built-in browser window and displays the requested action in the top toolbar. After the user clicks **Done** or **Hide**, the agent receives the result and continues. Handoff waits support up to 10 minutes.
|
||||
|
||||
### Chrome Login Import Extension
|
||||
|
||||
Built-in browser automation can also import login state for selected domains from system Chrome into CCR's in-app browser. This lets the agent reuse sites where you are already signed in to Chrome. It requires the unpacked Chrome extension in this repository: `extension/chrome`.
|
||||
|
||||
Install it:
|
||||
|
||||
1. Open `chrome://extensions` in Chrome.
|
||||
2. Enable **Developer mode**.
|
||||
3. Click **Load unpacked**.
|
||||
4. Select the repository's `extension/chrome` directory.
|
||||
|
||||
Import flow:
|
||||
|
||||
1. When a task needs existing Chrome login state, the agent can request an import; the user can also click the key button in CCR's in-app browser toolbar.
|
||||
2. CCR creates a one-time import job and opens a confirmation page. If your default browser is not Chrome, copy the confirmation URL into Chrome with the extension installed.
|
||||
3. Review the requested domains on the confirmation page, then click **Confirm and Import**.
|
||||
4. The Chrome extension reads cookies and localStorage for those domains and submits them to CCR. After it completes, the agent can continue the task in the built-in browser.
|
||||
|
||||
The extension reads only the domains listed in the CCR import job. It does not enumerate every Chrome cookie. For localStorage, the extension temporarily opens non-active tabs for the selected origins, reads `localStorage`, and closes those tabs. If the confirmation page says the extension does not have site access, allow the extension to access the target domains in Chrome extension settings, reload the unpacked extension, and try again.
|
||||
|
||||
> Note: Built-in browser automation depends on CCR Desktop's built-in browser and is only available in the desktop app. CLI, server deployments, and pure web environments do not include this built-in capability; use an external browser automation MCP server instead.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| Enable ToolHub | Exposes `ccr-toolhub` to agents. If no backend MCP server is available, CCR does not generate a ToolHub MCP config. |
|
||||
| Built-in browser automation | Shown only after ToolHub is enabled. Lets agents use CCR Desktop's built-in browser for web tasks. |
|
||||
| Resolver model | Choose from configured provider models. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier with enough tool-description understanding. |
|
||||
| Max tools | Maximum tools returned by one resolve call. Range `1` to `20`, default `10`. |
|
||||
| Timeout ms | Base timeout for ToolHub resolving and invocation. Range `8000` to `300000`, default `60000`. If a backend MCP server needs a longer request timeout, CCR raises the effective invocation timeout to match the backend. |
|
||||
| MCP servers | Backend tool sources. Each server needs a unique name plus transport, command or URL, environment variables, headers, and timeouts. |
|
||||
| Import JSON | Imports common MCP JSON shapes. Supports a root object, array, `mcpServers`, or `mcp_servers`. |
|
||||
|
||||
## Add MCP Servers
|
||||
|
||||
### stdio
|
||||
|
||||
Use `stdio` for local command-line MCP servers. Configure:
|
||||
|
||||
- **Command**: launch command, such as `npx`, `node`, or `python`.
|
||||
- **Arguments**: command arguments.
|
||||
- **Working directory**: optional working directory.
|
||||
- **Stdio message mode**: keep `content-length` by default; use `newline-json` for line-delimited JSON servers.
|
||||
- **Environment variables**: variables needed only by this MCP server.
|
||||
|
||||
### streamable-http / sse
|
||||
|
||||
Remote MCP servers need a URL. Authentication can use:
|
||||
|
||||
- **API key**: stored directly in the config.
|
||||
- **API key env**: read from an environment variable.
|
||||
- **Headers**: custom request headers.
|
||||
|
||||
If a remote server starts slowly or has long-running calls, adjust that server's **Startup timeout** or **Request timeout**.
|
||||
|
||||
## JSON Example
|
||||
|
||||
The desktop app's SQLite config is the effective source, so prefer editing through the UI. The fields below are useful for backups, migration, or troubleshooting:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolHub": {
|
||||
"enabled": true,
|
||||
"browserAutomation": true,
|
||||
"llm": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-5-mini"
|
||||
},
|
||||
"maxTools": 10,
|
||||
"requestTimeoutMs": 60000,
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "filesystem",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env": {},
|
||||
"stdioMessageMode": "content-length",
|
||||
"requestTimeoutMs": 30000,
|
||||
"startupTimeoutMs": 600000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The import dialog also accepts common MCP JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ToolHub vs Fusion MCP
|
||||
|
||||
| Capability | ToolHub | Fusion Custom MCP Tool |
|
||||
| --- | --- | --- |
|
||||
| Entry point | Agent-side `ccr-toolhub` MCP server | Capability inside one Fusion model |
|
||||
| Tool selection | Dynamically resolves a tool bundle for each task | Fixed tools selected in the model config |
|
||||
| Best for | Many MCP servers, changing tool catalogs, agent-led capability discovery | Adding a known tool set to one model |
|
||||
| Visibility | Claude Code or Codex configs opened through CCR | Routes or agents that select that Fusion model |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Agent cannot see ToolHub: make sure ToolHub is enabled and at least one backend MCP server is configured or **Built-in browser automation** is turned on, then reopen Claude Code or Codex from CCR.
|
||||
- Missing resolver model or API key: select a configured resolver model and confirm the provider credential works.
|
||||
- The agent cannot use built-in browser automation: make sure you are using CCR Desktop, turned on **Built-in browser automation** in **Settings → ToolHub**, and reopened Claude Code or Codex from CCR. CLI, server deployments, and pure web environments do not include this built-in capability.
|
||||
- Chrome login import confirmation keeps waiting for the extension: make sure the unpacked `extension/chrome` extension is loaded in Chrome and has site access for the target domains. If your default browser is not Chrome, copy the confirmation URL into Chrome manually.
|
||||
- No tools are resolved: confirm the MCP server can list tools, improve tool names and descriptions, or increase **Max tools**.
|
||||
- Calls time out: check ToolHub **Timeout ms** and the backend server request/startup timeouts.
|
||||
- Import fails: validate JSON, avoid duplicate server names, make sure `stdio` entries have a command and remote entries have a URL.
|
||||
|
|
@ -13,7 +13,7 @@ The top navigation is split into four standalone pages:
|
|||
| --- | --- |
|
||||
| [Documentation](./) | Product positioning, architecture overview, and reading path |
|
||||
| [Quick Start](guides/) | From installation and provider setup to connecting an agent |
|
||||
| [Detailed Configuration](configuration/providers/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
|
||||
| [Detailed Configuration](configuration/overview/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
|
||||
| [Q&A](troubleshooting/) | Request logs, observability panel, and common questions |
|
||||
|
||||
Bot platform guides are child pages under Detailed Configuration. Each platform has its own page so platform dashboard fields, callback URLs, signatures, and FAQs can be expanded independently.
|
||||
|
|
@ -24,5 +24,5 @@ If this is your first time using CCR:
|
|||
|
||||
1. Start with [Quick Start](guides/) to connect a provider and Agent Config.
|
||||
2. Use the app's request logs to confirm whether requests are passing through CCR.
|
||||
3. Open [Detailed Configuration](configuration/providers/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
|
||||
3. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
|
||||
4. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions.
|
||||
|
|
|
|||
|
|
@ -2,29 +2,37 @@
|
|||
title: Claude Code Router 详细配置
|
||||
pageTitle: 详细配置
|
||||
eyebrow: 详细配置
|
||||
lead: 深入配置概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置。这里是按功能查字段和扩展能力的地方。
|
||||
lead: 按应用中的实际顺序,将主页页面和设置页分开说明:主页覆盖概览、供应商、Agent配置、路由、Fusion、API 密钥、日志&观测、服务和扩展;设置页覆盖 ToolHub、Bot、数据和托盘。
|
||||
---
|
||||
|
||||
## 页面结构
|
||||
|
||||
详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。
|
||||
详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。主页页面跟随应用左侧主导航顺序;设置页单独分组,并按设置弹窗顺序排列。
|
||||
|
||||
## 主页页面
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| 概览仪表盘 | 系统状态、账户余额、用量组件、布局编辑和分享卡片 |
|
||||
| API 密钥 | 客户端访问 Key、过期时间和本地限额 |
|
||||
| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 |
|
||||
| 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 |
|
||||
| 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 |
|
||||
| 路由配置 | 条件规则、fallback 和请求改写 |
|
||||
| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 |
|
||||
| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 |
|
||||
| Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 |
|
||||
| 路由配置 | 条件规则、fallback 和请求改写 |
|
||||
| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 |
|
||||
| API 密钥 | 客户端访问 Key、过期时间和本地限额 |
|
||||
| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 |
|
||||
| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 |
|
||||
| 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 |
|
||||
|
||||
## 设置页
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| ToolHub | 将多个 MCP server 收束成一个 Agent 可用的动态工具检索入口 |
|
||||
| Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 |
|
||||
| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 |
|
||||
| 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 |
|
||||
| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 |
|
||||
|
||||
## 内容关系
|
||||
|
||||
概览仪表盘用于查看系统状态和用量;API 密钥控制客户端访问 CCR;服务配置控制本地网关监听和代理能力;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;扩展机制页面覆盖本地插件的创建、安装和调试;Bot 页面覆盖 IM 平台接力;托盘配置覆盖菜单栏图标和托盘窗口。
|
||||
概览仪表盘用于查看系统状态和用量;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;路由决定模型请求的上游去向;Fusion 页面覆盖图像、搜索和 MCP 工具;API 密钥控制客户端访问 CCR;日志&观测覆盖请求日志和 Agent 执行链路;服务配置控制本地网关监听和代理能力;扩展机制覆盖本地插件的创建、安装和调试。ToolHub、Bot、配置数据库位置和托盘配置对应设置弹窗中的同名配置页。
|
||||
|
|
|
|||
|
|
@ -11,7 +11,20 @@ lead: 使用 CCR 内置 Web Search 工具为模型提供联网检索能力。
|
|||
|
||||
## 搜索服务
|
||||
|
||||
支持 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa 等搜索服务。
|
||||
支持 In-app Browser、Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa 等搜索服务。
|
||||
|
||||
## In-app Browser
|
||||
|
||||
`In-app Browser` 会通过 CCR Desktop 的隐藏内置浏览器窗口执行搜索,打开搜索结果页面并提取可见内容,再把证据提供给 Fusion 模型。它不需要外部搜索 API Key,适合希望用桌面端内置浏览器完成联网检索的场景。
|
||||
|
||||
可配置项包括搜索引擎、语言、地区和安全搜索级别:
|
||||
|
||||
- 搜索引擎:Bing、Google、DuckDuckGo。
|
||||
- 语言:例如 `en`、`zh-CN`。
|
||||
- 地区:例如 `US`、`CN`。
|
||||
- 安全搜索:默认、中等、严格或关闭。
|
||||
|
||||
> 注意:`In-app Browser` 依赖 CCR Desktop 的 Electron 内置浏览器能力,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有内置浏览器集成,请改用 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily 或 Exa 等搜索服务。
|
||||
|
||||
## 排查要点
|
||||
|
||||
|
|
|
|||
|
|
@ -2,52 +2,130 @@
|
|||
title: 概览仪表盘
|
||||
pageTitle: 概览仪表盘
|
||||
eyebrow: 详细配置
|
||||
lead: 自定义 CCR 首页组件,查看系统状态、账户余额、请求量、tokens、成本、模型分布和可分享卡片。
|
||||
lead: 自定义 CCR 首页仪表盘,查看系统状态、账户余额、请求量、令牌、成本和模型分布。
|
||||
---
|
||||
|
||||
## 顶部控件
|
||||
## 适用场景
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| 场景 | 看什么 |
|
||||
| --- | --- |
|
||||
| 按时间查看用量 | 切换仪表盘使用的统计时间范围。 |
|
||||
| 今天 / 24 小时 / 7 天 / 30 天 | 可选统计窗口。不同组件会根据这个窗口重新计算请求、tokens、成本和趋势。 |
|
||||
| 编辑组件 | 进入布局编辑模式。 |
|
||||
| 重置布局 | 恢复默认概览布局。 |
|
||||
| 完成 | 退出布局编辑模式并保留当前组件配置。 |
|
||||
| 检查网关是否正常 | 系统状态、成功率、错误数、平均延迟 |
|
||||
| 估算今日或近期消耗 | 请求数、输入 / 输出 / 缓存令牌、估算成本 |
|
||||
| 比较上游使用情况 | 供应商分析、模型分布、客户端分析 |
|
||||
| 关注账户额度 | 账户余额、套餐额度、剩余额度、账户状态 |
|
||||
| 汇报或分享用量 | AI 用量年报、CCR 路由图、模型排行榜、消费小票等分享卡片 |
|
||||
|
||||
## 组件区
|
||||
## 时间范围
|
||||
|
||||
编辑模式下,左侧“组件”用于添加组件,中间“预览”展示当前布局,右侧“组件属性”编辑选中组件。
|
||||
顶部的 `按时间查看用量` 控制所有依赖用量统计的组件。切换后,请求、令牌、成本、趋势、分布和分享卡片会按新的窗口重新聚合。
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| 选项 | 统计窗口 |
|
||||
| --- | --- |
|
||||
| 组件 | 可添加组件列表。 |
|
||||
| 预览 | 当前仪表盘布局。组件可以拖拽排序。 |
|
||||
| 组件属性 | 当前选中组件的配置区域。 |
|
||||
| 组件类型 | 切换组件大类,例如状态、账户、指标、趋势、活跃度、构成、分析或分享卡片。 |
|
||||
| 数据 | 选择组件展示的数据,例如请求数、tokens、成本、账户、客户端分析或供应商分析。 |
|
||||
| 组件大小 | 控制组件占用的网格宽高。 |
|
||||
| 样式 | 切换组件展示样式,例如卡片、紧凑、柱状图、折线图、圆环等。 |
|
||||
| 移除组件 | 从概览中移除当前组件。 |
|
||||
| `今天` | 当前本地日期从 00:00 到现在,按小时分桶。 |
|
||||
| `24 小时` | 最近 24 小时,按小时分桶。 |
|
||||
| `7 天` | 最近 7 天,按天分桶。 |
|
||||
| `30 天` | 最近 30 天,按天分桶。 |
|
||||
|
||||
## 组件类型
|
||||
账户余额组件不按这个时间窗口重算,它展示供应商账户连接器最近一次获取到的快照。
|
||||
|
||||
| 组件 | 代表的能力 |
|
||||
## 编辑布局
|
||||
|
||||
点击右上角铅笔按钮进入编辑模式。编辑模式分为三栏:
|
||||
|
||||
| 区域 | 作用 |
|
||||
| --- | --- |
|
||||
| 状态组件 | 展示系统状态时间线,帮助判断网关近期是否正常。 |
|
||||
| 账户组件 | 展示供应商账户余额、套餐额度或用量。需要供应商开启“获取用量”。 |
|
||||
| 指标组件 | 展示请求数、总 tokens、输入 tokens、输出 tokens、缓存 tokens、缓存比例、估算成本、成功率、错误数或平均延迟。 |
|
||||
| 趋势组件 | 展示按时间聚合的用量趋势。 |
|
||||
| 活跃度组件 | 展示 token 活跃度热力图。 |
|
||||
| 构成组件 | 展示 Token 构成或模型分布。 |
|
||||
| 分析组件 | 展示客户端分析或供应商分析。 |
|
||||
| 分享卡片 | 生成适合分享的 PNG 卡片,例如 AI Usage Wrapped、CCR Route Map、Model Leaderboard、AI Fuel Cockpit、Token Calendar Poster 和 Spend Receipt。 |
|
||||
| 组件 | 左侧组件库,点击任一组件即可添加到当前仪表盘。 |
|
||||
| 预览 | 中间布局预览。可以拖拽组件排序,也可以点击组件选中它。 |
|
||||
| 组件属性 | 右侧属性面板,用于修改类型、数据、尺寸、样式或移除组件。 |
|
||||
|
||||
## 数据来源
|
||||
常用操作:
|
||||
|
||||
| 数据 | 来源 |
|
||||
1. 添加组件:在左侧 `组件` 中点击组件模板。
|
||||
2. 调整顺序:在 `预览` 中拖拽组件。
|
||||
3. 调整尺寸:选中组件后拖动右侧、底部或右下角的缩放手柄。
|
||||
4. 修改数据:在 `组件属性` 里切换 `组件类型` 和 `数据`。
|
||||
5. 修改展示:在 `组件大小` 和 `样式` 中选择合适的布局。
|
||||
6. 保存结果:点击 `完成` 退出编辑模式;布局会保存在应用配置中。
|
||||
7. 恢复默认:编辑模式下点击 `重置布局`。
|
||||
|
||||
移除组件只会从概览布局中删除该卡片,不会删除请求日志、供应商、账户连接器或任何上游配置。如果所有组件都被移除,页面会显示 `未配置组件`。
|
||||
|
||||
## 组件目录
|
||||
|
||||
尺寸使用 `宽:高` 表示,宽高范围都是 `1` 到 `4`。概览网格在桌面端最多 4 列,在窄屏上会自动折叠。
|
||||
|
||||
| 组件 | 可选数据 | 默认尺寸 | 默认样式 | 可选样式 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 状态组件 | 系统状态 | `4:1` | 时间线 | 时间线、紧凑 |
|
||||
| 账户组件 | 所有账户或指定账户 | `4:2` | 卡片 | 卡片、紧凑、横条、圆环、半圆、弧形、内外圆 |
|
||||
| 指标组件 | 请求、Token、成本 | `1:1` | 卡片 | 卡片、紧凑、柱状图、圆环 |
|
||||
| 趋势组件 | 按时间查看用量 | `3:2` | 组合图 | 组合图、面积图、折线图、柱状图 |
|
||||
| 活跃度组件 | Token 活跃度 | `4:2` | 热力图 | 热力图 |
|
||||
| 构成组件 | Token 分布 / 模型分布 | Token 分布:`1:2`;模型分布:`2:2` | Token 分布:横条;模型分布:饼图 | 横条、堆叠、环形图、饼图 |
|
||||
| 分析组件 | 客户端分析 / 供应商分析 | `2:2` | 表格 | 表格、紧凑 |
|
||||
| 分享卡片 | AI 用量年报、CCR 路由图、模型排行榜、AI 燃料仪表、Token 日历海报、消费小票 | `1:4` | 卡片 | 卡片 |
|
||||
|
||||
尺寸约束:
|
||||
|
||||
| 规则 | 原因 |
|
||||
| --- | --- |
|
||||
| 请求数、tokens、成本、成功率、延迟 | 请求日志和用量统计。 |
|
||||
| 账户余额、套餐额度、状态消息 | 供应商“获取用量”配置。 |
|
||||
| 客户端分析、供应商分析、模型分布 | 请求日志中的客户端、供应商、模型和 token 信息。 |
|
||||
| Agent 分析相关数据 | 设置中的“Agent 观测”和 Agent 执行链路数据。 |
|
||||
| 分享卡片最小高度是 `1:4`。 | 导出图片使用竖版海报比例,需要足够高度。 |
|
||||
| 账户组件在展示所有账户且使用“紧凑”样式时,最小尺寸是 `2:2`。 | 多账户列表需要保留可读空间。 |
|
||||
| 旧版尺寸别名仍可被解析:`small` -> `1:1`,`medium` / `large` -> `2:2`,`wide` -> `3:2`,`full` -> `4:1` 或 `4:2`。 | 用于兼容旧配置。 |
|
||||
|
||||
## 指标数据
|
||||
|
||||
`metric` 组件通过 `metric` 字段选择要展示的指标。
|
||||
|
||||
| `metric` | 含义 |
|
||||
| --- | --- |
|
||||
| `requests` | 请求数 |
|
||||
| `total-tokens` | 总令牌 |
|
||||
| `input-tokens` | 输入令牌 |
|
||||
| `output-tokens` | 输出令牌 |
|
||||
| `cache-tokens` | 缓存令牌 |
|
||||
| `cache-ratio` | 缓存率 |
|
||||
| `estimated-cost` | 估算成本,按模型价格信息计算 |
|
||||
| `success-rate` | 成功率 |
|
||||
| `errors` | 错误数 |
|
||||
| `avg-latency` | 平均延迟 |
|
||||
|
||||
## 账户组件
|
||||
|
||||
账户组件读取供应商配置里的账户 / 用量连接器。要让它显示余额或剩余额度,需要先在供应商配置中启用并测试 `获取用量`。
|
||||
|
||||
| 数据选择 | 行为 |
|
||||
| --- | --- |
|
||||
| `所有账户` | 展示所有可用账户快照。 |
|
||||
| 指定账户 | 只展示某个供应商或某个凭据的账户快照。内部配置值格式通常是 `provider` 或 `provider::credentialId`。 |
|
||||
|
||||
如果账户组件为空,优先检查:
|
||||
|
||||
1. 供应商是否配置了账户 / 用量连接器。
|
||||
2. `获取用量` 测试是否成功。
|
||||
3. API Key 或账户接口是否仍有效。
|
||||
4. 当前选择的指定账户是否已经被删除或重命名。
|
||||
|
||||
## 分享卡片
|
||||
|
||||
分享卡片组件可以通过右上角下载按钮导出 PNG。桌面 App 会优先使用原生导出能力,浏览器环境会退回到前端 canvas 导出。导出图片尺寸为 `1080 x 1350`。
|
||||
|
||||
| 卡片 | `type` | 内容 |
|
||||
| --- | --- | --- |
|
||||
| AI 用量年报 | `share-usage-wrapped` | 总令牌、请求数、估算成本、缓存率、最长连续、最高频模型、最高频供应商、峰值日期。 |
|
||||
| CCR 路由图 | `share-route-map` | 客户端到供应商 / 模型的主要路由关系,以及客户端、供应商、模型数量。 |
|
||||
| 模型排行榜 | `share-model-leaderboard` | 按令牌排序的模型排行榜。 |
|
||||
| AI 燃料仪表 | `share-fuel-cockpit` | 最多 3 个账户额度仪表,依赖账户 / 用量连接器。 |
|
||||
| Token 日历海报 | `share-token-calendar` | 类似贡献日历的 Token 活跃度海报。 |
|
||||
| 消费小票 | `share-spend-receipt` | 当前时间范围内的估算成本、请求、令牌、延迟和成功率小票。 |
|
||||
|
||||
## 数据来源与排障
|
||||
|
||||
| 现象 | 可能原因 | 处理方式 |
|
||||
| --- | --- | --- |
|
||||
| 请求、令牌或成本为 0 | 当前时间范围内没有经过 CCR 的请求,或用量捕获尚未记录。 | 切换到 `24h` / `7d`,确认客户端请求确实走 CCR。 |
|
||||
| 成本显示为 `$0.00` | 模型没有价格信息,或用量过小。 | 检查模型目录和供应商模型名是否能匹配价格;低于 0.01 美元会显示更多小数。 |
|
||||
| 成功率、错误数不符合预期 | 只统计 CCR 捕获到的请求结果。 | 对照日志页面中的请求记录。 |
|
||||
| 账户余额为空 | 没有账户连接器,或 `获取用量` 失败。 | 到供应商配置中测试账户 / 用量字段映射。 |
|
||||
| 分布图没有数据 | 请求日志里缺少模型、供应商或 token 信息。 | 确认请求经过 CCR,并检查上游响应是否返回 token usage。 |
|
||||
| PNG 导出失败 | 浏览器不支持 canvas 导出、元素尺寸为空,或用户取消了保存。 | 在桌面 App 中重试,确保卡片可见且没有被缩到过小。 |
|
||||
|
|
|
|||
|
|
@ -74,6 +74,18 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少
|
|||
<span class="provider-import-icon-shell"><img src="../../provider-icons/runapi.jpg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">RunAPI</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-teamorouter" href="ccr://provider?name=TeamoRouter&base_url=https%3A%2F%2Fapi.teamorouter.com&protocol=anthropic_messages&source=https%3A%2F%2Fteamorouter.com%2F" aria-label="导入 TeamoRouter 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/teamorouter.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">TeamoRouter</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-code0" href="ccr://provider?name=code0.ai&base_url=https%3A%2F%2Fconsole.code0.ai&protocol=anthropic_messages&source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="导入 code0.ai 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/code0.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&base_url=https%3A%2F%2Fgw.claudeapi.com&protocol=anthropic_messages&source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="导入 claudeapi 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 嵌入式按钮组件
|
||||
|
|
@ -133,7 +145,7 @@ CCR 也提供了一个无框架的按钮脚本,供应商可以嵌入到自己
|
|||
| `name` | Provider 展示名称 |
|
||||
| `base_url` | Provider API Base URL,直链导入时必填 |
|
||||
| `api_key` | 可选 Provider API Key |
|
||||
| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content` |
|
||||
| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content`、`gemini_interactions` |
|
||||
| `models` | 模型列表。HTML 中用逗号或换行分隔,JS 中可传字符串或数组 |
|
||||
| `icon` | Provider 图标 URL |
|
||||
| `source` | Provider 官网或配置来源 |
|
||||
|
|
@ -261,7 +273,7 @@ Manifest 可以把供应商信息放在顶层 `provider` 对象中:
|
|||
| `name` | Provider 展示名称 |
|
||||
| `base_url` | Provider API Base URL,必填 |
|
||||
| `api_key` | 可选 Provider API Key |
|
||||
| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content` |
|
||||
| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content`、`gemini_interactions` |
|
||||
| `models` | 模型列表,支持逗号或换行分隔,也可以重复传入 |
|
||||
| `icon` | Provider 图标 URL |
|
||||
| `source` | Provider 官网或配置来源 |
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成;
|
|||
| 继续重试 | `408`、`409`、`429`、`5xx` |
|
||||
| 失败降级目标 | 任意 `4xx` 或 `5xx` |
|
||||
|
||||
对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
|
||||
进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
|
||||
|
||||
**失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。
|
||||
|
||||
|
|
|
|||
167
docs/src/content/docs/zh/configuration/toolhub.md
Normal file
167
docs/src/content/docs/zh/configuration/toolhub.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
---
|
||||
title: ToolHub
|
||||
pageTitle: ToolHub
|
||||
eyebrow: 详细配置
|
||||
lead: 将多个 MCP server 收束成一个紧凑入口,让 Agent 按任务懒加载需要的工具,减少工具列表占用的上下文。
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
当你接入的 MCP server 越来越多时,直接把所有工具暴露给 Agent 会让工具列表变长,也更容易选错工具。ToolHub 会向 Agent 暴露一个 `ccr-toolhub` MCP server,里面只有两个元工具:
|
||||
|
||||
- `tool_hub.resolve`:根据用户任务和上下文检索可用 MCP 工具。
|
||||
- `tool_hub.invoke`:调用已经被本轮任务选中的真实 MCP 工具。
|
||||
|
||||
它适合把不常用但偶尔会用到的工具统一懒加载,在任务真正需要时才交给 Agent 使用。核心价值是节省上下文:避免把大量低频工具常驻在 Agent 的工具列表里,减少上下文占用和选错工具的概率。简单本地代码、文件或普通聊天任务通常不需要经过 ToolHub。
|
||||
|
||||
## 工作方式
|
||||
|
||||
1. 在 **设置 → ToolHub** 中启用 ToolHub。
|
||||
2. 选择一个已配置模型作为 **检索模型**。它负责阅读 MCP 工具目录并挑选本轮任务需要的工具;建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定的轻量模型。
|
||||
3. 添加或导入后端 MCP server。ToolHub 支持 `stdio`、`streamable-http` 和 `sse`。
|
||||
4. 从 CCR 打开 Claude Code 或 Codex。CCR 会在对应 Agent 配置中写入 `ccr-toolhub`。
|
||||
5. Agent 遇到外部服务、已安装 MCP 能力或业务 API 相关请求时,先调用 `tool_hub.resolve`,再用 `tool_hub.invoke` 执行选中的工具。
|
||||
|
||||
ToolHub 会合并 **ToolHub 页面配置的 MCP servers** 和兼容旧配置中的全局 Agent MCP servers,并自动排除 `ccr-toolhub` 自身,避免递归调用。
|
||||
|
||||
## 内置浏览器自动化
|
||||
|
||||
在 CCR Desktop 中启用 ToolHub,并打开 **内置浏览器自动化** 开关后,Agent 可以使用桌面端内置浏览器完成网页操作。不需要在 ToolHub 页面手动添加浏览器后端,也不需要额外 API Key;CCR 会使用本地网关鉴权连接它。
|
||||
|
||||
启用步骤:
|
||||
|
||||
1. 打开 **设置 → ToolHub**,先开启 **启用 ToolHub**。
|
||||
2. 在同一页打开 **内置浏览器自动化** 开关。该开关只会在 ToolHub 已启用时显示。
|
||||
3. 保存设置后,从 CCR 重新打开 Claude Code 或 Codex,让新的 Agent 实例加载最新配置。
|
||||
|
||||
> 已经运行中的 Agent 实例通常不会立即拿到这个开关变化。要让现有会话生效,请重启该 Agent 实例,或使用 Agent 自身能力重启 ToolHub。
|
||||
|
||||
内置浏览器自动化适合让 Agent 处理需要真实浏览器状态的任务,例如打开网站、读取页面、填写表单、点击按钮、在页面中滚动,或在没有专用业务能力时完成下单、预约、查询、结账等网页流程。开启后 Agent 可以:
|
||||
|
||||
- 打开或附加内置浏览器标签页、导航 URL 或搜索词。
|
||||
- 读取页面内容,并找到按钮、链接、输入框等页面元素。
|
||||
- 点击、输入、选择、按键和滚动页面元素。
|
||||
- 等待页面加载、跳转、弹窗或人类接管结果,再继续后续步骤。
|
||||
- 在登录、验证码、CAPTCHA、人机验证或人工确认时请求用户接管。
|
||||
|
||||
当网页流程需要登录、验证码、CAPTCHA、人机验证或人工确认时,CCR 会显示内置浏览器窗口,并在顶部工具栏提示用户需要完成的步骤。用户点击 **Done** 或 **Hide** 后,Agent 会收到结果并继续执行。接管等待最长支持 10 分钟。
|
||||
|
||||
### Chrome 登录态导入扩展
|
||||
|
||||
内置浏览器自动化还支持把系统 Chrome 中指定域名的登录状态导入 CCR 内置浏览器。这样 Agent 处理网页任务时,可以复用你已经在 Chrome 中登录过的网站状态。该能力需要安装仓库里的 Chrome 解包扩展:`extension/chrome`。
|
||||
|
||||
安装方式:
|
||||
|
||||
1. 在 Chrome 打开 `chrome://extensions`。
|
||||
2. 开启 **Developer mode**。
|
||||
3. 点击 **Load unpacked**。
|
||||
4. 选择仓库中的 `extension/chrome` 目录。
|
||||
|
||||
导入流程:
|
||||
|
||||
1. 当任务需要复用 Chrome 登录状态时,Agent 会请求导入;用户也可以在 CCR 内置浏览器工具栏点击钥匙按钮主动发起。
|
||||
2. CCR 创建一次性导入任务,并打开确认页。如果默认浏览器不是 Chrome,请把确认页 URL 复制到已安装扩展的 Chrome 中打开。
|
||||
3. 用户在确认页检查要导入的域名,点击 **Confirm and Import**。
|
||||
4. Chrome 扩展读取这些域名的 cookies 和 localStorage,提交给 CCR;完成后 Agent 可以继续使用内置浏览器执行任务。
|
||||
|
||||
扩展只读取 CCR 导入任务列出的域名,不会枚举 Chrome 中的全部 cookies。读取 localStorage 时,扩展会临时打开对应 origin 的非激活标签页,读取后自动关闭。若确认页提示扩展没有站点访问权限,请在 Chrome 扩展设置中允许该扩展访问目标域名,然后重新加载解包扩展再重试。
|
||||
|
||||
> 注意:内置浏览器自动化依赖 CCR Desktop 的内置浏览器,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有这项内置能力,请改用外部浏览器自动化 MCP server。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| 启用 ToolHub | 开启后才会向 Agent 暴露 `ccr-toolhub`。如果没有可用后端 MCP server,CCR 不会生成 ToolHub MCP 配置。 |
|
||||
| 内置浏览器自动化 | 仅在启用 ToolHub 后显示。开启后让 Agent 可以使用 CCR Desktop 的内置浏览器完成网页操作。 |
|
||||
| 检索模型 | 从已配置供应商模型中选择。建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定、工具理解能力足够的轻量模型。 |
|
||||
| 最大工具数 | 单次解析最多返回的工具数量,范围 `1` 到 `20`,默认 `10`。 |
|
||||
| 超时毫秒 | ToolHub 解析和调用的基础超时时间,范围 `8000` 到 `300000`,默认 `60000`。如果后端 MCP server 需要更长 request timeout,CCR 会按后端超时自动抬高实际调用超时。 |
|
||||
| MCP servers | 后端工具来源。每个 server 需要唯一名称,并配置 transport、命令或 URL、环境变量、headers 和超时。 |
|
||||
| Import JSON | 导入常见 MCP JSON。支持根对象、数组、`mcpServers` 或 `mcp_servers`。 |
|
||||
|
||||
## 添加 MCP Server
|
||||
|
||||
### stdio
|
||||
|
||||
`stdio` 适合本地命令行 MCP server。需要填写:
|
||||
|
||||
- **Command**:启动命令,例如 `npx`、`node`、`python`。
|
||||
- **Arguments**:命令参数。
|
||||
- **Working directory**:可选工作目录。
|
||||
- **Stdio message mode**:默认 `content-length`,如果 server 使用逐行 JSON,选择 `newline-json`。
|
||||
- **Environment variables**:只放这个 MCP server 需要的变量。
|
||||
|
||||
### streamable-http / sse
|
||||
|
||||
远程 MCP server 需要填写 URL。鉴权可以使用:
|
||||
|
||||
- **API key**:直接保存在配置中。
|
||||
- **API key env**:从环境变量读取。
|
||||
- **Headers**:添加自定义请求头。
|
||||
|
||||
如果远程服务启动慢或请求耗时长,可以单独调高该 server 的 **Startup timeout** 或 **Request timeout**。
|
||||
|
||||
## JSON 示例
|
||||
|
||||
桌面 App 的 SQLite 配置是当前生效来源,建议优先通过 UI 修改。下面字段适用于备份、迁移或排查时理解 ToolHub 配置结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolHub": {
|
||||
"enabled": true,
|
||||
"browserAutomation": true,
|
||||
"llm": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-5-mini"
|
||||
},
|
||||
"maxTools": 10,
|
||||
"requestTimeoutMs": 60000,
|
||||
"mcpServers": [
|
||||
{
|
||||
"name": "filesystem",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env": {},
|
||||
"stdioMessageMode": "content-length",
|
||||
"requestTimeoutMs": 30000,
|
||||
"startupTimeoutMs": 600000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
导入 MCP JSON 时也可以使用常见格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 与 Fusion MCP 的区别
|
||||
|
||||
| 能力 | ToolHub | Fusion 自定义 MCP 工具 |
|
||||
| --- | --- | --- |
|
||||
| 使用入口 | Agent 侧的 `ccr-toolhub` MCP server | 某个 Fusion 模型内部能力 |
|
||||
| 工具选择 | 每个任务动态检索并返回工具包 | 模型配置中固定选择工具 |
|
||||
| 适合场景 | MCP server 很多、工具目录经常变化、希望 Agent 自主发现能力 | 给某个模型补一组明确工具 |
|
||||
| 可见范围 | 通过 CCR 打开的 Claude Code 或 Codex 配置 | 选择该 Fusion 模型的路由或 Agent |
|
||||
|
||||
## 排查
|
||||
|
||||
- Agent 看不到 ToolHub:确认已启用 ToolHub,并且至少配置了一个后端 MCP server 或开启了 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。
|
||||
- 提示缺少检索模型或 API Key:在 **检索模型** 中选择已配置模型,并确认供应商凭据可用。
|
||||
- Agent 无法使用内置浏览器自动化:确认正在使用 CCR Desktop,并且已在 **设置 → ToolHub** 中开启 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。CLI、服务器部署或纯 Web 环境没有这项内置能力。
|
||||
- Chrome 登录态导入确认页一直等待扩展:确认已在 Chrome 中加载 `extension/chrome` 解包扩展,并允许扩展访问要导入的目标域名。如果默认浏览器不是 Chrome,请手动把确认页 URL 复制到 Chrome。
|
||||
- 解析不到工具:检查 MCP server 是否能正常列出工具,工具名称和描述是否足够清楚,必要时提高 **最大工具数**。
|
||||
- 调用超时:分别检查 ToolHub 的 **超时毫秒** 和单个 MCP server 的 request/startup timeout。
|
||||
- 导入失败:检查 JSON 是否有效、server 名称是否重复、`stdio` 是否有 command,远程 transport 是否有 URL。
|
||||
|
|
@ -13,7 +13,7 @@ lead: 了解 CCR 的定位、能力边界和文档结构。需要动手配置时
|
|||
| --- | --- |
|
||||
| [文档](./) | 产品定位、架构概览、阅读路径 |
|
||||
| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 |
|
||||
| [详细配置](configuration/provider/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
|
||||
| [详细配置](configuration/overview/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
|
||||
| [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 |
|
||||
|
||||
Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。
|
||||
|
|
@ -24,7 +24,7 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独
|
|||
|
||||
1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。
|
||||
2. App 的请求日志页面展示请求是否经过 CCR。
|
||||
3. [详细配置](configuration/provider/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
|
||||
3. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
|
||||
4. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。
|
||||
|
||||
这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const docsContent = {
|
|||
navItems: [
|
||||
{ label: "文档", href: "/", pageKey: "documentation" },
|
||||
{ label: "快速开始", href: "/guides/", pageKey: "guides" },
|
||||
{ label: "详细配置", href: "/configuration/provider/", pageKey: "configuration" },
|
||||
{ label: "详细配置", href: "/configuration/overview/", pageKey: "configuration" },
|
||||
{ label: "Q&A", href: "/troubleshooting/", pageKey: "troubleshooting" },
|
||||
],
|
||||
pages: {
|
||||
|
|
@ -60,24 +60,32 @@ export const docsContent = {
|
|||
configuration: {
|
||||
sidebarGroups: [
|
||||
{
|
||||
label: "详细配置",
|
||||
label: "主页页面",
|
||||
icon: "wand",
|
||||
items: [
|
||||
"概览仪表盘",
|
||||
"供应商配置",
|
||||
"一键导入供应商",
|
||||
"路由配置",
|
||||
"日志&观测",
|
||||
"Fusion 组合模型",
|
||||
"Agent配置",
|
||||
"路由配置",
|
||||
"Fusion 组合模型",
|
||||
"API 密钥",
|
||||
"日志&观测",
|
||||
"服务配置",
|
||||
"托盘配置",
|
||||
"扩展机制",
|
||||
],
|
||||
active: "概览仪表盘",
|
||||
},
|
||||
{
|
||||
label: "设置页",
|
||||
icon: "book",
|
||||
items: [
|
||||
"ToolHub",
|
||||
"Bot 与 IM 接力 Agent",
|
||||
"配置数据库位置",
|
||||
"托盘配置",
|
||||
],
|
||||
active: "供应商配置",
|
||||
active: "",
|
||||
},
|
||||
],
|
||||
expandableSidebarItems: ["Fusion 组合模型", "Bot 与 IM 接力 Agent"],
|
||||
|
|
@ -105,6 +113,7 @@ export const docsContent = {
|
|||
内置图像能力: "/configuration/fusion-vision/",
|
||||
内置联网搜索: "/configuration/fusion-web-search/",
|
||||
"自定义 MCP 工具": "/configuration/fusion-mcp-tool/",
|
||||
ToolHub: "/configuration/toolhub/",
|
||||
Agent配置: "/configuration/profile/",
|
||||
"API 密钥": "/configuration/api-keys/",
|
||||
服务配置: "/configuration/server/",
|
||||
|
|
@ -163,7 +172,7 @@ export const docsContent = {
|
|||
navItems: [
|
||||
{ label: "Documentation", href: "/en/", pageKey: "documentation" },
|
||||
{ label: "Quick Start", href: "/en/guides/", pageKey: "guides" },
|
||||
{ label: "Detailed Configuration", href: "/en/configuration/providers/", pageKey: "configuration" },
|
||||
{ label: "Detailed Configuration", href: "/en/configuration/overview/", pageKey: "configuration" },
|
||||
{ label: "Q&A", href: "/en/troubleshooting/", pageKey: "troubleshooting" },
|
||||
],
|
||||
pages: {
|
||||
|
|
@ -208,24 +217,32 @@ export const docsContent = {
|
|||
configuration: {
|
||||
sidebarGroups: [
|
||||
{
|
||||
label: "Detailed Configuration",
|
||||
label: "Main Pages",
|
||||
icon: "wand",
|
||||
items: [
|
||||
"Overview Dashboard",
|
||||
"Provider Config",
|
||||
"One click import",
|
||||
"Routing Config",
|
||||
"Logs & Observability",
|
||||
"Fusion Models",
|
||||
"Agent Config",
|
||||
"Routing Config",
|
||||
"Fusion Models",
|
||||
"API Keys",
|
||||
"Logs & Observability",
|
||||
"Server",
|
||||
"Tray Configuration",
|
||||
"Extension Mechanism",
|
||||
],
|
||||
active: "Overview Dashboard",
|
||||
},
|
||||
{
|
||||
label: "Settings Pages",
|
||||
icon: "book",
|
||||
items: [
|
||||
"ToolHub",
|
||||
"Bots And IM Agent Relay",
|
||||
"Config Database Location",
|
||||
"Tray Configuration",
|
||||
],
|
||||
active: "Provider Config",
|
||||
active: "",
|
||||
},
|
||||
],
|
||||
expandableSidebarItems: ["Fusion Models", "Bots And IM Agent Relay"],
|
||||
|
|
@ -243,6 +260,7 @@ export const docsContent = {
|
|||
"Built-In Vision": "/en/configuration/fusion-vision/",
|
||||
"Built-In Web Search": "/en/configuration/fusion-web-search/",
|
||||
"Custom MCP Tool": "/en/configuration/fusion-mcp-tool/",
|
||||
ToolHub: "/en/configuration/toolhub/",
|
||||
"Agent Config": "/en/configuration/profiles/",
|
||||
"API Keys": "/en/configuration/api-keys/",
|
||||
Server: "/en/configuration/server/",
|
||||
|
|
|
|||
|
|
@ -291,6 +291,11 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";
|
|||
<ul class="sidebar-children directory-children">
|
||||
{section.groups.map((group) => (
|
||||
<li class="directory-group">
|
||||
{(section.groups.length > 1 || group.label !== section.label) && (
|
||||
<div class="directory-group-label">
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ul>
|
||||
{group.items.map((item) => {
|
||||
const isExpandable = item.children.length > 0;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
const target = "/configuration/provider/";
|
||||
const target = "/configuration/overview/";
|
||||
---
|
||||
|
||||
<html lang="zh-CN">
|
||||
|
|
@ -8,10 +8,10 @@ const target = "/configuration/provider/";
|
|||
<meta http-equiv="refresh" content={`0;url=${target}`} />
|
||||
<link rel="canonical" href={target} />
|
||||
<script is:inline>
|
||||
window.location.replace("/configuration/provider/");
|
||||
window.location.replace("/configuration/overview/");
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href={target}>供应商配置</a>
|
||||
<a href={target}>概览仪表盘</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function getStaticPaths() {
|
|||
"fusion-vision": "内置图像能力",
|
||||
"fusion-web-search": "内置联网搜索",
|
||||
"fusion-mcp-tool": "自定义 MCP 工具",
|
||||
toolhub: "ToolHub",
|
||||
extensions: "扩展机制",
|
||||
server: "服务配置",
|
||||
tray: "托盘配置",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
const target = "/en/configuration/providers/";
|
||||
const target = "/en/configuration/overview/";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
|
|
@ -8,10 +8,10 @@ const target = "/en/configuration/providers/";
|
|||
<meta http-equiv="refresh" content={`0;url=${target}`} />
|
||||
<link rel="canonical" href={target} />
|
||||
<script is:inline>
|
||||
window.location.replace("/en/configuration/providers/");
|
||||
window.location.replace("/en/configuration/overview/");
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href={target}>Provider Config</a>
|
||||
<a href={target}>Overview Dashboard</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function getStaticPaths() {
|
|||
"fusion-vision": "Built-In Vision",
|
||||
"fusion-web-search": "Built-In Web Search",
|
||||
"fusion-mcp-tool": "Custom MCP Tool",
|
||||
toolhub: "ToolHub",
|
||||
extensions: "Extension Mechanism",
|
||||
server: "Server",
|
||||
tray: "Tray Configuration",
|
||||
|
|
|
|||
|
|
@ -642,28 +642,26 @@ pre {
|
|||
}
|
||||
|
||||
.directory-group {
|
||||
margin: 2px 0 9px;
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
|
||||
.directory-group:last-child {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.directory-group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 7px 8px 3px 0;
|
||||
color: var(--sidebar-heading);
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
line-height: 1.35;
|
||||
.directory-group + .directory-group {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.directory-group-label .group-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.directory-group-label {
|
||||
display: block;
|
||||
min-height: 20px;
|
||||
padding: 2px 8px 5px 0;
|
||||
color: var(--sidebar-subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sidebar-group h2 {
|
||||
|
|
@ -768,7 +766,7 @@ pre {
|
|||
}
|
||||
|
||||
.sidebar-directory .sidebar-children {
|
||||
padding-left: 24px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.sidebar-children::before {
|
||||
|
|
@ -786,6 +784,14 @@ pre {
|
|||
left: 8px;
|
||||
}
|
||||
|
||||
.sidebar-directory .directory-children {
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.sidebar-directory .directory-children::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-details[open] > .sidebar-children {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
|
@ -1306,6 +1312,24 @@ h1 {
|
|||
--provider-brand-3: #f3f3f3;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-teamorouter {
|
||||
--provider-brand: #0c0c0f;
|
||||
--provider-brand-2: #555b64;
|
||||
--provider-brand-3: #f4f4f5;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-code0 {
|
||||
--provider-brand: #101214;
|
||||
--provider-brand-2: #267dff;
|
||||
--provider-brand-3: #d8e8ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-claudeapi {
|
||||
--provider-brand: #0d1b2a;
|
||||
--provider-brand-2: #2f8f83;
|
||||
--provider-brand-3: #d7fff8;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-deepseek {
|
||||
--provider-brand: #173aa8;
|
||||
--provider-brand-2: #4e69ff;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"asarUnpack": [
|
||||
"**/*.node"
|
||||
],
|
||||
"electronLanguages": ["en-US", "zh-CN", "zh-TW", "zh_CN", "zh_TW"],
|
||||
"npmRebuild": true,
|
||||
"publish": [
|
||||
{
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
}
|
||||
],
|
||||
"directories": {
|
||||
"app": "packages/electron",
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"afterPack": "build/verify-packaged-app.cjs",
|
||||
|
|
@ -27,7 +29,14 @@
|
|||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"!node_modules/better-sqlite3/deps/**",
|
||||
"!node_modules/better-sqlite3/src/**",
|
||||
"!node_modules/better-sqlite3/binding.gyp",
|
||||
"!node_modules/better-sqlite3/README.md",
|
||||
"!node_modules/better-sqlite3/docs/**",
|
||||
"!node_modules/better-sqlite3/benchmark/**",
|
||||
"!node_modules/better-sqlite3/test/**"
|
||||
],
|
||||
"mac": {
|
||||
"icon": "build/icon.icns",
|
||||
|
|
@ -52,6 +61,7 @@
|
|||
"artifactName": "Claude-Code-Router_${version}.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"executableName": "claude-code-router",
|
||||
"target": ["AppImage"],
|
||||
"artifactName": "Claude-Code-Router_${version}.${ext}"
|
||||
},
|
||||
|
|
|
|||
23
extension/chrome/README.md
Normal file
23
extension/chrome/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# CCR Login Import Chrome Extension
|
||||
|
||||
This unpacked Chrome extension imports cookies and localStorage for explicitly selected domains into CCR's in-app browser.
|
||||
|
||||
## Development install
|
||||
|
||||
1. Open `chrome://extensions`.
|
||||
2. Enable **Developer mode**.
|
||||
3. Click **Load unpacked**.
|
||||
4. Select this `extension/chrome` directory.
|
||||
|
||||
After changing extension files, click **Reload** for this unpacked extension in `chrome://extensions`.
|
||||
The confirmation-page flow uses the site access declared in `manifest.json`; it does not request new host permissions from the page click.
|
||||
|
||||
## Flow
|
||||
|
||||
1. An agent calls CCR's Chrome login import browser tool, or the user clicks the key button in CCR's in-app browser.
|
||||
2. CCR opens a one-time confirmation page in the system browser.
|
||||
3. Review the requested domains and click **Confirm and Import**.
|
||||
|
||||
The extension reads only the domains listed in the CCR job. It does not enumerate all Chrome cookies.
|
||||
|
||||
For localStorage, the extension temporarily opens non-active tabs for the selected origins, reads `localStorage`, then closes those tabs.
|
||||
262
extension/chrome/background.js
Normal file
262
extension/chrome/background.js
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (!message || message.type !== "ccr-login-import-confirm") {
|
||||
return false;
|
||||
}
|
||||
|
||||
runImport(message.importUrl)
|
||||
.then((result) => sendResponse({ ok: true, result }))
|
||||
.catch((error) => sendResponse({ error: formatError(error), ok: false }));
|
||||
return true;
|
||||
});
|
||||
|
||||
async function runImport(importUrl) {
|
||||
const normalizedImportUrl = normalizeImportUrl(importUrl);
|
||||
if (!normalizedImportUrl) {
|
||||
throw new Error("Invalid CCR import URL.");
|
||||
}
|
||||
|
||||
const job = await fetchImportJob(normalizedImportUrl);
|
||||
const domains = Array.isArray(job.domains) ? job.domains.map(normalizeDomain).filter(Boolean) : [];
|
||||
if (domains.length === 0) {
|
||||
throw new Error("CCR import job does not include any domains.");
|
||||
}
|
||||
|
||||
await ensureHostPermissions(domains);
|
||||
|
||||
const cookies = await readCookiesForDomains(domains);
|
||||
const localStorageEntries = await readLocalStorageForDomains(domains, cookies);
|
||||
if (cookies.length === 0 && localStorageEntries.length === 0) {
|
||||
throw new Error("No cookies or localStorage entries were found for the selected domains.");
|
||||
}
|
||||
|
||||
return await submitLoginState(normalizedImportUrl, cookies, localStorageEntries, domains);
|
||||
}
|
||||
|
||||
async function fetchImportJob(importUrl) {
|
||||
const response = await fetch(importUrl, {
|
||||
headers: {
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
}
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR import job request failed (${response.status}).`);
|
||||
}
|
||||
if (!body.job || body.job.status !== "pending") {
|
||||
throw new Error(`CCR import job is ${body.job?.status || "unavailable"}.`);
|
||||
}
|
||||
return body.job;
|
||||
}
|
||||
|
||||
async function submitLoginState(importUrl, cookies, localStorage, domains) {
|
||||
const response = await fetch(`${importUrl.replace(/\/+$/, "")}/cookies`, {
|
||||
body: JSON.stringify({
|
||||
cookies,
|
||||
domains,
|
||||
localStorage,
|
||||
source: {
|
||||
browser: "chrome",
|
||||
extension: chrome.runtime.getManifest().version
|
||||
}
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR login import failed (${response.status}).`);
|
||||
}
|
||||
return body.result || {};
|
||||
}
|
||||
|
||||
async function readCookiesForDomains(domains) {
|
||||
const cookies = [];
|
||||
for (const domain of domains) {
|
||||
cookies.push(...await chrome.cookies.getAll({ domain }));
|
||||
}
|
||||
return dedupeCookies(cookies);
|
||||
}
|
||||
|
||||
async function readLocalStorageForDomains(domains, cookies) {
|
||||
const origins = localStorageOriginsForDomains(domains, cookies);
|
||||
const entries = [];
|
||||
for (const origin of origins) {
|
||||
const entry = await readLocalStorageForOrigin(origin).catch(() => undefined);
|
||||
if (entry && Object.keys(entry.items).length > 0) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readLocalStorageForOrigin(origin) {
|
||||
const tab = await chrome.tabs.create({
|
||||
active: false,
|
||||
url: `${origin}/`
|
||||
});
|
||||
try {
|
||||
await waitForTabLoad(tab.id, 15000);
|
||||
const [frameResult] = await chrome.scripting.executeScript({
|
||||
func: () => {
|
||||
const items = {};
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key !== null) {
|
||||
items[key] = window.localStorage.getItem(key) || "";
|
||||
}
|
||||
}
|
||||
return {
|
||||
items,
|
||||
origin: window.location.origin
|
||||
};
|
||||
},
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN"
|
||||
});
|
||||
const result = frameResult?.result;
|
||||
if (!result || !allowedLocalStorageOrigin(result.origin, origin)) {
|
||||
return undefined;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (tab.id !== undefined) {
|
||||
await chrome.tabs.remove(tab.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForTabLoad(tabId, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
const listener = (updatedTabId, changeInfo) => {
|
||||
if (updatedTabId === tabId && changeInfo.status === "complete") {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
const timeout = setTimeout(finish, timeoutMs);
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeCookies(cookies) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const cookie of cookies) {
|
||||
const partitionKey = cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : "";
|
||||
const key = `${cookie.storeId || ""}\n${cookie.domain}\n${cookie.path}\n${cookie.name}\n${partitionKey}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(cookie);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function localStorageOriginsForDomains(domains, cookies) {
|
||||
const origins = new Set();
|
||||
for (const domain of domains) {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
origins.add(`http://${domain}`);
|
||||
origins.add(`https://${domain}`);
|
||||
} else {
|
||||
origins.add(`https://${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const host = normalizeDomain(cookie.domain);
|
||||
if (!host || !cookie.hostOnly || !domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) {
|
||||
continue;
|
||||
}
|
||||
origins.add(`${cookie.secure === false ? "http" : "https"}://${host}`);
|
||||
}
|
||||
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
function originsForDomains(domains) {
|
||||
return [...new Set(domains.flatMap((domain) => {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`
|
||||
];
|
||||
}
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`,
|
||||
`http://*.${domain}/*`,
|
||||
`https://*.${domain}/*`
|
||||
];
|
||||
}))];
|
||||
}
|
||||
|
||||
async function ensureHostPermissions(domains) {
|
||||
const origins = originsForDomains(domains);
|
||||
const granted = await chrome.permissions.contains({ origins });
|
||||
if (granted) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`CCR Login Import does not have Chrome site access for ${domains.join(", ")}.`,
|
||||
"Reload the unpacked extension after updating it, then grant the extension site access for the requested domains in Chrome extensions settings."
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImportUrl(value) {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!["127.0.0.1", "localhost"].includes(url.hostname)) {
|
||||
return "";
|
||||
}
|
||||
if (!/^\/chrome-import\/jobs\/[^/]+\/?$/.test(url.pathname)) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, "");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDomain(value) {
|
||||
return typeof value === "string"
|
||||
? value.trim().replace(/^\*\./, "").replace(/^\./, "").toLowerCase()
|
||||
: "";
|
||||
}
|
||||
|
||||
function allowedLocalStorageOrigin(actualOrigin, requestedOrigin) {
|
||||
try {
|
||||
const actual = new URL(actualOrigin);
|
||||
const requested = new URL(requestedOrigin);
|
||||
return actual.protocol === requested.protocol && actual.hostname === requested.hostname && actual.port === requested.port;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isIpAddress(value) {
|
||||
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.includes(":");
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
46
extension/chrome/confirm.js
Normal file
46
extension/chrome/confirm.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
const root = document.getElementById("ccr-chrome-login-import");
|
||||
const button = document.getElementById("ccr-confirm-import");
|
||||
const statusElement = document.getElementById("ccr-import-status");
|
||||
|
||||
if (root && button && statusElement) {
|
||||
const importUrl = root.getAttribute("data-import-url") || "";
|
||||
button.disabled = false;
|
||||
button.textContent = "Confirm and Import";
|
||||
setStatus("CCR Login Import extension is connected. Review the domains, then confirm.");
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
void confirmImport(importUrl);
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmImport(importUrl) {
|
||||
button.disabled = true;
|
||||
setStatus("Importing Chrome cookies and localStorage into CCR...");
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
importUrl,
|
||||
type: "ccr-login-import-confirm"
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Chrome login import failed.");
|
||||
}
|
||||
const result = response.result || {};
|
||||
setStatus(
|
||||
`Imported ${result.cookieImported || 0} cookies and ${result.localStorageImported || 0} localStorage items. Skipped ${result.skipped || 0}.`,
|
||||
"ok"
|
||||
);
|
||||
button.textContent = "Imported";
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
setStatus(formatError(error), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${kind}`.trim();
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
35
extension/chrome/manifest.json
Normal file
35
extension/chrome/manifest.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CCR Login Import",
|
||||
"description": "Import selected Chrome site login cookies into CCR's in-app browser.",
|
||||
"version": "0.1.0",
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "CCR Login Import"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": [
|
||||
"confirm.js"
|
||||
],
|
||||
"matches": [
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"scripting",
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*",
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
]
|
||||
}
|
||||
110
extension/chrome/popup.html
Normal file
110
extension/chrome/popup.html
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CCR Login Import</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 360px;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: color-mix(in srgb, CanvasText 4%, Canvas);
|
||||
border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
|
||||
border-radius: 8px;
|
||||
color: CanvasText;
|
||||
font: inherit;
|
||||
min-height: 76px;
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
background: #0f766e;
|
||||
border: 1px solid #0f766e;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: 8px;
|
||||
color: color-mix(in srgb, CanvasText 74%, transparent);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
min-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
color: #15803d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>CCR Login Import</h1>
|
||||
<label>
|
||||
Import URL
|
||||
<textarea id="import-url" spellcheck="false" placeholder="Paste the URL copied from CCR"></textarea>
|
||||
</label>
|
||||
<button id="import-button" type="button">Import Selected Domains</button>
|
||||
<div id="status" class="status" role="status"></div>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
297
extension/chrome/popup.js
Normal file
297
extension/chrome/popup.js
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
const importUrlInput = document.getElementById("import-url");
|
||||
const importButton = document.getElementById("import-button");
|
||||
const statusElement = document.getElementById("status");
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void restoreLastImportUrl();
|
||||
});
|
||||
|
||||
importButton.addEventListener("click", () => {
|
||||
void runImport();
|
||||
});
|
||||
|
||||
async function restoreLastImportUrl() {
|
||||
const stored = await chrome.storage.local.get(["lastImportUrl"]);
|
||||
if (typeof stored.lastImportUrl === "string") {
|
||||
importUrlInput.value = stored.lastImportUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const importUrl = normalizeImportUrl(importUrlInput.value);
|
||||
if (!importUrl) {
|
||||
setStatus("Paste the import URL copied from CCR.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
importButton.disabled = true;
|
||||
try {
|
||||
await chrome.storage.local.set({ lastImportUrl: importUrl });
|
||||
setStatus("Reading CCR import job...");
|
||||
const job = await fetchImportJob(importUrl);
|
||||
const domains = Array.isArray(job.domains) ? job.domains.map(normalizeDomain).filter(Boolean) : [];
|
||||
if (domains.length === 0) {
|
||||
throw new Error("CCR import job does not include any domains.");
|
||||
}
|
||||
|
||||
await ensureHostPermissions(domains);
|
||||
|
||||
setStatus(`Reading cookies for ${domains.join(", ")}...`);
|
||||
const cookies = await readCookiesForDomains(domains);
|
||||
setStatus(`Reading localStorage for ${domains.join(", ")}...`);
|
||||
const localStorageEntries = await readLocalStorageForDomains(domains, cookies);
|
||||
|
||||
if (cookies.length === 0 && localStorageEntries.length === 0) {
|
||||
throw new Error("No cookies or localStorage entries were found for the selected domains.");
|
||||
}
|
||||
|
||||
setStatus(`Sending ${cookies.length} cookies and ${localStorageItemCount(localStorageEntries)} localStorage items to CCR...`);
|
||||
const result = await submitCookies(importUrl, cookies, localStorageEntries, domains);
|
||||
setStatus(
|
||||
`Imported ${result.cookieImported ?? 0} cookies and ${result.localStorageImported ?? 0} localStorage items. Skipped ${result.skipped ?? 0}.`,
|
||||
"ok"
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(formatError(error), "error");
|
||||
} finally {
|
||||
importButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchImportJob(importUrl) {
|
||||
const response = await fetch(importUrl, {
|
||||
headers: {
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
}
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR import job request failed (${response.status}).`);
|
||||
}
|
||||
if (!body.job || body.job.status !== "pending") {
|
||||
throw new Error(`CCR import job is ${body.job?.status || "unavailable"}.`);
|
||||
}
|
||||
return body.job;
|
||||
}
|
||||
|
||||
async function submitCookies(importUrl, cookies, localStorage, domains) {
|
||||
const response = await fetch(`${importUrl.replace(/\/+$/, "")}/cookies`, {
|
||||
body: JSON.stringify({
|
||||
cookies,
|
||||
domains,
|
||||
localStorage,
|
||||
source: {
|
||||
browser: "chrome",
|
||||
extension: chrome.runtime.getManifest().version
|
||||
}
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ccr-login-import": "chrome-extension"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error?.message || `CCR cookie import failed (${response.status}).`);
|
||||
}
|
||||
return body.result || {};
|
||||
}
|
||||
|
||||
async function readCookiesForDomains(domains) {
|
||||
const cookies = [];
|
||||
for (const domain of domains) {
|
||||
cookies.push(...await chrome.cookies.getAll({ domain }));
|
||||
}
|
||||
return dedupeCookies(cookies);
|
||||
}
|
||||
|
||||
async function readLocalStorageForDomains(domains, cookies) {
|
||||
const origins = localStorageOriginsForDomains(domains, cookies);
|
||||
const entries = [];
|
||||
for (const origin of origins) {
|
||||
const entry = await readLocalStorageForOrigin(origin).catch(() => undefined);
|
||||
if (entry && Object.keys(entry.items).length > 0) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readLocalStorageForOrigin(origin) {
|
||||
const tab = await chrome.tabs.create({
|
||||
active: false,
|
||||
url: `${origin}/`
|
||||
});
|
||||
try {
|
||||
await waitForTabLoad(tab.id, 15000);
|
||||
const [frameResult] = await chrome.scripting.executeScript({
|
||||
func: () => {
|
||||
const items = {};
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key !== null) {
|
||||
items[key] = window.localStorage.getItem(key) || "";
|
||||
}
|
||||
}
|
||||
return {
|
||||
items,
|
||||
origin: window.location.origin
|
||||
};
|
||||
},
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN"
|
||||
});
|
||||
const result = frameResult?.result;
|
||||
if (!result || !allowedLocalStorageOrigin(result.origin, origin)) {
|
||||
return undefined;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (tab.id !== undefined) {
|
||||
await chrome.tabs.remove(tab.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForTabLoad(tabId, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
window.clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
const listener = (updatedTabId, changeInfo) => {
|
||||
if (updatedTabId === tabId && changeInfo.status === "complete") {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
const timeout = window.setTimeout(finish, timeoutMs);
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeCookies(cookies) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const cookie of cookies) {
|
||||
const partitionKey = cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : "";
|
||||
const key = `${cookie.storeId || ""}\n${cookie.domain}\n${cookie.path}\n${cookie.name}\n${partitionKey}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(cookie);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function localStorageOriginsForDomains(domains, cookies) {
|
||||
const origins = new Set();
|
||||
for (const domain of domains) {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
origins.add(`http://${domain}`);
|
||||
origins.add(`https://${domain}`);
|
||||
} else {
|
||||
origins.add(`https://${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const host = normalizeDomain(cookie.domain);
|
||||
if (!host || !cookie.hostOnly || !domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) {
|
||||
continue;
|
||||
}
|
||||
origins.add(`${cookie.secure === false ? "http" : "https"}://${host}`);
|
||||
}
|
||||
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
function allowedLocalStorageOrigin(actualOrigin, requestedOrigin) {
|
||||
try {
|
||||
const actual = new URL(actualOrigin);
|
||||
const requested = new URL(requestedOrigin);
|
||||
return actual.protocol === requested.protocol && actual.hostname === requested.hostname && actual.port === requested.port;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function localStorageItemCount(entries) {
|
||||
return entries.reduce((count, entry) => count + Object.keys(entry.items || {}).length, 0);
|
||||
}
|
||||
|
||||
function originsForDomains(domains) {
|
||||
return [...new Set(domains.flatMap((domain) => {
|
||||
if (domain === "localhost" || isIpAddress(domain)) {
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`
|
||||
];
|
||||
}
|
||||
return [
|
||||
`http://${domain}/*`,
|
||||
`https://${domain}/*`,
|
||||
`http://*.${domain}/*`,
|
||||
`https://*.${domain}/*`
|
||||
];
|
||||
}))];
|
||||
}
|
||||
|
||||
async function ensureHostPermissions(domains) {
|
||||
const origins = originsForDomains(domains);
|
||||
const granted = await chrome.permissions.contains({ origins });
|
||||
if (granted) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`CCR Login Import does not have Chrome site access for ${domains.join(", ")}.`,
|
||||
"Reload the unpacked extension after updating it, then grant the extension site access for the requested domains in Chrome extensions settings."
|
||||
].join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImportUrl(value) {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!["127.0.0.1", "localhost"].includes(url.hostname)) {
|
||||
return "";
|
||||
}
|
||||
if (!/^\/chrome-import\/jobs\/[^/]+\/?$/.test(url.pathname)) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, "");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDomain(value) {
|
||||
return typeof value === "string"
|
||||
? value.trim().replace(/^\*\./, "").replace(/^\./, "").toLowerCase()
|
||||
: "";
|
||||
}
|
||||
|
||||
function isIpAddress(value) {
|
||||
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.includes(":");
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${kind}`.trim();
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
1584
package-lock.json
generated
1584
package-lock.json
generated
File diff suppressed because it is too large
Load diff
34
package.json
34
package.json
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "claude-code-router",
|
||||
"version": "3.0.5",
|
||||
"name": "claude-code-router-monorepo",
|
||||
"version": "3.0.10",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"description": "Local Claude Code Router gateway with CLI and web management UI.",
|
||||
"repository": {
|
||||
|
|
@ -18,15 +19,9 @@
|
|||
"gateway",
|
||||
"router"
|
||||
],
|
||||
"main": "dist/main/main.js",
|
||||
"bin": {
|
||||
"ccr": "dist/main/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"README_zh.md"
|
||||
"main": "packages/electron/dist/main/main.js",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
|
@ -35,9 +30,13 @@
|
|||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node build/dev.mjs",
|
||||
"build": "npm run build:assets && electron-builder",
|
||||
"dev": "npm run dev:cli",
|
||||
"dev:ui": "node build/dev.mjs ui",
|
||||
"dev:cli": "node build/dev.mjs cli",
|
||||
"dev:electron": "node build/dev.mjs electron",
|
||||
"build": "node build/build.mjs && electron-builder",
|
||||
"build:assets": "node build/build.mjs",
|
||||
"build:docker": "node build/docker-build.mjs",
|
||||
"build:app:mac": "npm run build:app:mac:local",
|
||||
"build:app:mac:local": "npm run build:assets && electron-builder --config build/electron-builder.local.cjs --mac --publish never",
|
||||
"build:app:mac:release": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --publish never",
|
||||
|
|
@ -45,24 +44,31 @@
|
|||
"prepack": "npm run build:assets",
|
||||
"prepublishOnly": "npm run typecheck",
|
||||
"preview": "npm run build:assets && electron .",
|
||||
"docker:build": "docker build -t claude-code-router:local .",
|
||||
"docker:run": "docker run --rm -p 3458:8080 -v ccr-data:/data claude-code-router:local",
|
||||
"test": "node build/test.mjs && node build/run-tests.mjs",
|
||||
"test:docker": "node tests/docker/docker-smoke.mjs",
|
||||
"test:e2e": "npm run build:assets && playwright test",
|
||||
"test:e2e:install": "playwright install chromium",
|
||||
"test:main": "node build/test.mjs main && node build/run-tests.mjs main",
|
||||
"test:renderer": "node build/test.mjs renderer && node build/run-tests.mjs renderer",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.3",
|
||||
"@the-next-ai/ai-gateway": "^1.0.6",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"electron-updater": "^6.8.9",
|
||||
"node-forge": "^1.4.0",
|
||||
"openai": "^6.27.0",
|
||||
"undici": "^7.27.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.10.2",
|
||||
|
|
|
|||
21
packages/cli/LICENSE
Normal file
21
packages/cli/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 musistudio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
361
packages/cli/README.md
Normal file
361
packages/cli/README.md
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
<h1 align="center">Claude Code Router Desktop</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README_zh.md"><img alt="Chinese README" src="https://img.shields.io/badge/%F0%9F%87%A8%F0%9F%87%B3-%E4%B8%AD%E6%96%87%E7%89%88-ff0000?style=flat" /></a>
|
||||
<a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
|
||||
<a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
|
||||
<a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
|
||||
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr">API China</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>Thanks to Kimi for sponsoring this project!</strong> 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.
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR already supports Kimi. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">cost-effective Coding Plan</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop screenshot" />
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
|
||||
## Features
|
||||
|
||||
- **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_<version>-mac-Apple-Silicon-arm64.dmg` or `.zip`
|
||||
- macOS Intel: `Claude-Code-Router_<version>-mac-Intel-x64.dmg` or `.zip`
|
||||
- Windows: `Claude Code Router_<version>.exe`
|
||||
- Linux: `Claude Code Router_<version>.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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
CCR can be configured entirely from the desktop UI. Use this setup order for a clean first run.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl).
|
||||
|
||||
## Support & Sponsoring
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="220">
|
||||
<a href="https://ko-fi.com/F1F31GN2GM">
|
||||
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>One-time support via Ko-fi</sub>
|
||||
</td>
|
||||
<td align="center" width="220">
|
||||
<a href="https://paypal.me/musistudio1999">
|
||||
<img src="https://img.shields.io/badge/PayPal-Sponsor-003087?logo=paypal&logoColor=white" alt="Sponsor with PayPal" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>International sponsorship</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="220">
|
||||
<strong>Alipay</strong>
|
||||
<br />
|
||||
<img src="/blog/images/alipay.jpg" width="160" alt="Alipay QR code" />
|
||||
</td>
|
||||
<td align="center" width="220">
|
||||
<strong>WeChat Pay</strong>
|
||||
<br />
|
||||
<img src="/blog/images/wechat.jpg" width="160" alt="WeChat Pay QR code" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
### Our Sponsors
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>A huge thank you to all our sponsors for their generous support.</p>
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ">
|
||||
<img src="/docs/public/provider-icons/zhipu-cn-general.png" width="42" height="42" alt="Zhipu icon" />
|
||||
<br />
|
||||
<strong>Z智谱</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://aihubmix.com/">
|
||||
<img src="https://www.google.com/s2/favicons?domain=aihubmix.com&sz=128" width="42" height="42" alt="AIHubmix icon" />
|
||||
<br />
|
||||
<strong>AIHubmix</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://ai.burncloud.com">
|
||||
<img src="https://www.burncloud.com/favicon.png" width="42" height="42" alt="BurnCloud icon" />
|
||||
<br />
|
||||
<strong>BurnCloud</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://share.302.ai/ZGVF9w">
|
||||
<img src="https://www.google.com/s2/favicons?domain=302.ai&sz=128" width="42" height="42" alt="302.AI icon" />
|
||||
<br />
|
||||
<strong>302.AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://runapi.co/register?aff=IX1t">
|
||||
<img src="/docs/public/provider-icons/runapi.jpg" width="42" height="42" alt="RunAPI icon" />
|
||||
<br />
|
||||
<strong>RunAPI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://teamorouter.com/">
|
||||
<img src="/docs/public/provider-icons/teamorouter.png" width="42" height="42" alt="TeamoRouter icon" />
|
||||
<br />
|
||||
<strong>TeamoRouter</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Community Sponsors</h4>
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="220">@Simon Leischnig</td>
|
||||
<td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
|
||||
<td align="center" width="220">@*o</td>
|
||||
<td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
|
||||
<td align="center" width="220">@*说</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@K*g</td>
|
||||
<td align="center" width="220">@R*R</td>
|
||||
<td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220">@*划</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
|
||||
<td align="center" width="220">@S*r</td>
|
||||
<td align="center" width="220">@*晖</td>
|
||||
<td align="center" width="220">@*敏</td>
|
||||
<td align="center" width="220">@Z*z</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*然</td>
|
||||
<td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
|
||||
<td align="center" width="220">@*应</td>
|
||||
<td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*飞</td>
|
||||
<td align="center" width="220">@董*</td>
|
||||
<td align="center" width="220">@*汀</td>
|
||||
<td align="center" width="220">@*涯</td>
|
||||
<td align="center" width="220">@*:-)</td>
|
||||
<td align="center" width="220">@**磊</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*琢</td>
|
||||
<td align="center" width="220">@*成</td>
|
||||
<td align="center" width="220">@Z*o</td>
|
||||
<td align="center" width="220">@*琨</td>
|
||||
<td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
|
||||
<td align="center" width="220">@*_</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@Z*m</td>
|
||||
<td align="center" width="220">@*鑫</td>
|
||||
<td align="center" width="220">@c*y</td>
|
||||
<td align="center" width="220">@*昕</td>
|
||||
<td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
|
||||
<td align="center" width="220">@b*g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*亿</td>
|
||||
<td align="center" width="220">@*辉</td>
|
||||
<td align="center" width="220">@JACK</td>
|
||||
<td align="center" width="220">@*光</td>
|
||||
<td align="center" width="220">@W*l</td>
|
||||
<td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
|
||||
<td align="center" width="220">@二吉吉</td>
|
||||
<td align="center" width="220">@a*g</td>
|
||||
<td align="center" width="220">@*林</td>
|
||||
<td align="center" width="220">@*咸</td>
|
||||
<td align="center" width="220">@*明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@S*y</td>
|
||||
<td align="center" width="220">@f*o</td>
|
||||
<td align="center" width="220">@*智</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@r*c</td>
|
||||
<td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*军</td>
|
||||
<td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220">@zcutlip</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@*.</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@*政</td>
|
||||
<td align="center" width="220">@*铭</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*叶</td>
|
||||
<td align="center" width="220">@七*o</td>
|
||||
<td align="center" width="220">@*青</td>
|
||||
<td align="center" width="220">@**晨</td>
|
||||
<td align="center" width="220">@*远</td>
|
||||
<td align="center" width="220">@*霄</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@**吉</td>
|
||||
<td align="center" width="220">@**飞</td>
|
||||
<td align="center" width="220">@**驰</td>
|
||||
<td align="center" width="220">@x*g</td>
|
||||
<td align="center" width="220">@**东</td>
|
||||
<td align="center" width="220">@*落</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@哆*k</td>
|
||||
<td align="center" width="220">@*涛</td>
|
||||
<td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
|
||||
<td align="center" width="220">@*呢</td>
|
||||
<td align="center" width="220">@d*u</td>
|
||||
<td align="center" width="220">@crizcraig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">s*s</td>
|
||||
<td align="center" width="220">*火</td>
|
||||
<td align="center" width="220">*勤</td>
|
||||
<td align="center" width="220">**锟</td>
|
||||
<td align="center" width="220">*涛</td>
|
||||
<td align="center" width="220">**明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">*知</td>
|
||||
<td align="center" width="220">*语</td>
|
||||
<td align="center" width="220">*瓜</td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>If your name is masked, please contact me via my homepage email to update it with your GitHub username.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
360
packages/cli/README_zh.md
Normal file
360
packages/cli/README_zh.md
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<h1 align="center">Claude Code Router Desktop</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><img alt="English README" src="https://img.shields.io/badge/%F0%9F%87%AC%F0%9F%87%A7-English-000aff?style=flat" /></a>
|
||||
<a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
|
||||
<a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
|
||||
<a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
|
||||
<a href="https://ccrdesk.top/"><img alt="文档" src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API,或了解高性价比 <a href="https://www.kimi.com/code?aff=ccr">Coding Plan</a> 套餐。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop 项目截图" />
|
||||
</p>
|
||||
|
||||
## 为什么使用 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 轮换、用量统计和请求日志来控制成本和可靠性。
|
||||
|
||||
## 功能和特性
|
||||
|
||||
- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。
|
||||
- **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_<version>-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
|
||||
- macOS Intel 芯片:`Claude-Code-Router_<version>-mac-Intel-x64.dmg` 或 `.zip`
|
||||
- Windows:`Claude Code Router_<version>.exe`
|
||||
- Linux:`Claude Code Router_<version>.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 证书状态。
|
||||
|
||||
## 快速开始
|
||||
|
||||
CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序操作。
|
||||
|
||||
### 1. 添加 Provider
|
||||
|
||||
打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
|
||||
|
||||
### 2. 设置路由
|
||||
|
||||
打开 **路由**,添加条件规则,配置请求改写和失败降级。
|
||||
|
||||
如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
|
||||
|
||||
### 3. 启动网关
|
||||
|
||||
打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
|
||||
|
||||
### 4. 连接 Agent 工具
|
||||
|
||||
打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。
|
||||
|
||||
### 5. 日常查看和调整
|
||||
|
||||
到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。
|
||||
|
||||
## 致谢
|
||||
|
||||
对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
|
||||
|
||||
## 支持与赞助
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="220">
|
||||
<a href="https://ko-fi.com/F1F31GN2GM">
|
||||
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="通过 Ko-fi 赞助" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>通过 Ko-fi 单次赞助</sub>
|
||||
</td>
|
||||
<td align="center" width="220">
|
||||
<a href="https://paypal.me/musistudio1999">
|
||||
<img src="https://img.shields.io/badge/PayPal-Sponsor-003087?logo=paypal&logoColor=white" alt="通过 PayPal 赞助" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>国际赞助通道</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="220">
|
||||
<strong>支付宝</strong>
|
||||
<br />
|
||||
<img src="/blog/images/alipay.jpg" width="160" alt="支付宝收款码" />
|
||||
</td>
|
||||
<td align="center" width="220">
|
||||
<strong>微信支付</strong>
|
||||
<br />
|
||||
<img src="/blog/images/wechat.jpg" width="160" alt="微信支付收款码" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
### 我们的赞助商
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>非常感谢所有赞助商的慷慨支持。</p>
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ">
|
||||
<img src="/docs/public/provider-icons/zhipu-cn-general.png" width="42" height="42" alt="智谱图标" />
|
||||
<br />
|
||||
<strong>Z智谱</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://aihubmix.com/">
|
||||
<img src="https://www.google.com/s2/favicons?domain=aihubmix.com&sz=128" width="42" height="42" alt="AIHubmix 图标" />
|
||||
<br />
|
||||
<strong>AIHubmix</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://ai.burncloud.com">
|
||||
<img src="https://www.burncloud.com/favicon.png" width="42" height="42" alt="BurnCloud 图标" />
|
||||
<br />
|
||||
<strong>BurnCloud</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://share.302.ai/ZGVF9w">
|
||||
<img src="https://www.google.com/s2/favicons?domain=302.ai&sz=128" width="42" height="42" alt="302.AI 图标" />
|
||||
<br />
|
||||
<strong>302.AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://runapi.co/register?aff=IX1t">
|
||||
<img src="/docs/public/provider-icons/runapi.jpg" width="42" height="42" alt="RunAPI 图标" />
|
||||
<br />
|
||||
<strong>RunAPI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://teamorouter.com/">
|
||||
<img src="/docs/public/provider-icons/teamorouter.png" width="42" height="42" alt="TeamoRouter 图标" />
|
||||
<br />
|
||||
<strong>TeamoRouter</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>社区赞助者</h4>
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="220">@Simon Leischnig</td>
|
||||
<td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
|
||||
<td align="center" width="220">@*o</td>
|
||||
<td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
|
||||
<td align="center" width="220">@*说</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@K*g</td>
|
||||
<td align="center" width="220">@R*R</td>
|
||||
<td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220">@*划</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
|
||||
<td align="center" width="220">@S*r</td>
|
||||
<td align="center" width="220">@*晖</td>
|
||||
<td align="center" width="220">@*敏</td>
|
||||
<td align="center" width="220">@Z*z</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*然</td>
|
||||
<td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
|
||||
<td align="center" width="220">@*应</td>
|
||||
<td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*飞</td>
|
||||
<td align="center" width="220">@董*</td>
|
||||
<td align="center" width="220">@*汀</td>
|
||||
<td align="center" width="220">@*涯</td>
|
||||
<td align="center" width="220">@*:-)</td>
|
||||
<td align="center" width="220">@**磊</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*琢</td>
|
||||
<td align="center" width="220">@*成</td>
|
||||
<td align="center" width="220">@Z*o</td>
|
||||
<td align="center" width="220">@*琨</td>
|
||||
<td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
|
||||
<td align="center" width="220">@*_</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@Z*m</td>
|
||||
<td align="center" width="220">@*鑫</td>
|
||||
<td align="center" width="220">@c*y</td>
|
||||
<td align="center" width="220">@*昕</td>
|
||||
<td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
|
||||
<td align="center" width="220">@b*g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*亿</td>
|
||||
<td align="center" width="220">@*辉</td>
|
||||
<td align="center" width="220">@JACK</td>
|
||||
<td align="center" width="220">@*光</td>
|
||||
<td align="center" width="220">@W*l</td>
|
||||
<td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
|
||||
<td align="center" width="220">@二吉吉</td>
|
||||
<td align="center" width="220">@a*g</td>
|
||||
<td align="center" width="220">@*林</td>
|
||||
<td align="center" width="220">@*咸</td>
|
||||
<td align="center" width="220">@*明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@S*y</td>
|
||||
<td align="center" width="220">@f*o</td>
|
||||
<td align="center" width="220">@*智</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@r*c</td>
|
||||
<td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*军</td>
|
||||
<td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220">@zcutlip</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@*.</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@*政</td>
|
||||
<td align="center" width="220">@*铭</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*叶</td>
|
||||
<td align="center" width="220">@七*o</td>
|
||||
<td align="center" width="220">@*青</td>
|
||||
<td align="center" width="220">@**晨</td>
|
||||
<td align="center" width="220">@*远</td>
|
||||
<td align="center" width="220">@*霄</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@**吉</td>
|
||||
<td align="center" width="220">@**飞</td>
|
||||
<td align="center" width="220">@**驰</td>
|
||||
<td align="center" width="220">@x*g</td>
|
||||
<td align="center" width="220">@**东</td>
|
||||
<td align="center" width="220">@*落</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@哆*k</td>
|
||||
<td align="center" width="220">@*涛</td>
|
||||
<td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
|
||||
<td align="center" width="220">@*呢</td>
|
||||
<td align="center" width="220">@d*u</td>
|
||||
<td align="center" width="220">@crizcraig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">s*s</td>
|
||||
<td align="center" width="220">*火</td>
|
||||
<td align="center" width="220">*勤</td>
|
||||
<td align="center" width="220">**锟</td>
|
||||
<td align="center" width="220">*涛</td>
|
||||
<td align="center" width="220">**明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">*知</td>
|
||||
<td align="center" width="220">*语</td>
|
||||
<td align="center" width="220">*瓜</td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。</sub>
|
||||
|
||||
</div>
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [MIT License](LICENSE) 发布。
|
||||
48
packages/cli/package.json
Normal file
48
packages/cli/package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "3.0.2",
|
||||
"license": "MIT",
|
||||
"description": "Local Claude Code Router gateway with CLI and web management UI.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@github.com/musistudio/claude-code-router.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/musistudio/claude-code-router/issues"
|
||||
},
|
||||
"homepage": "https://github.com/musistudio/claude-code-router#readme",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"codex",
|
||||
"llm",
|
||||
"gateway",
|
||||
"router"
|
||||
],
|
||||
"main": "dist/main/cli.js",
|
||||
"bin": {
|
||||
"ccr": "dist/main/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"README_zh.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "npm --prefix ../.. run build:assets",
|
||||
"prepublishOnly": "npm --prefix ../.. run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.4",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"undici": "^7.27.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "../shared/app";
|
||||
import { botGatewayProfileEnv } from "./bot-gateway-env";
|
||||
import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service";
|
||||
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
|
||||
import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch";
|
||||
import { loadAppConfig } from "./config";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import { applyProfileConfig, applyProfileRuntimeConfig } from "./profile-service";
|
||||
import { ensureProfileGateway } from "./profile-launch-service";
|
||||
import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core";
|
||||
import { startWebManagementServer } from "./web-management-server";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
|
||||
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch";
|
||||
import { launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
|
||||
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 { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server";
|
||||
import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app";
|
||||
|
||||
type ProfileCliOptions = {
|
||||
agentArgs: string[];
|
||||
|
|
@ -23,7 +24,7 @@ type ProfileCliOptions = {
|
|||
};
|
||||
|
||||
type WebCliOptions = {
|
||||
command: "start" | "web";
|
||||
command: "start" | "ui" | "web";
|
||||
daemonChild: boolean;
|
||||
help: boolean;
|
||||
host?: string;
|
||||
|
|
@ -42,22 +43,22 @@ type CliOptions = ProfileCliOptions | StopCliOptions | WebCliOptions;
|
|||
type ServiceState = {
|
||||
host?: string;
|
||||
pid: number;
|
||||
serviceToken?: string;
|
||||
startedAt: string;
|
||||
startGateway: boolean;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const serviceStateFileName = "service.json";
|
||||
const serviceInstanceTokenEnv = "CCR_SERVICE_INSTANCE_TOKEN";
|
||||
const serviceRpcTimeoutMs = 2_000;
|
||||
const serviceStartTimeoutMs = 30_000;
|
||||
const serviceStopTimeoutMs = 10_000;
|
||||
const webAuthHeader = "x-ccr-web-auth";
|
||||
const webAuthQueryParam = "ccr_web_token";
|
||||
const defaultCliCommandName = "ccr";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const delegatedExitCode = delegateManagedDesktopCliToExternalCli();
|
||||
if (delegatedExitCode !== undefined) {
|
||||
process.exitCode = delegatedExitCode;
|
||||
return;
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.command === "start") {
|
||||
if (options.help) {
|
||||
|
|
@ -67,6 +68,14 @@ async function main(): Promise<void> {
|
|||
await startService(options);
|
||||
return;
|
||||
}
|
||||
if (options.command === "ui") {
|
||||
if (options.help) {
|
||||
printUiHelp(0);
|
||||
return;
|
||||
}
|
||||
await openManagementUi(options);
|
||||
return;
|
||||
}
|
||||
if (options.command === "stop") {
|
||||
if (options.help) {
|
||||
printStopHelp(0);
|
||||
|
|
@ -175,6 +184,9 @@ function parseArgs(args: string[]): CliOptions {
|
|||
if (args[0] === "start") {
|
||||
return parseWebArgs(args.slice(1), "start");
|
||||
}
|
||||
if (args[0] === "ui") {
|
||||
return parseWebArgs(args.slice(1), "ui", true);
|
||||
}
|
||||
if (args[0] === "stop") {
|
||||
return parseStopArgs(args.slice(1));
|
||||
}
|
||||
|
|
@ -244,12 +256,12 @@ function parseStopArgs(args: string[]): StopCliOptions {
|
|||
return options;
|
||||
}
|
||||
|
||||
function parseWebArgs(args: string[], command: WebCliOptions["command"]): WebCliOptions {
|
||||
function parseWebArgs(args: string[], command: WebCliOptions["command"], defaultOpen = false): WebCliOptions {
|
||||
const options: WebCliOptions = {
|
||||
command,
|
||||
daemonChild: false,
|
||||
help: false,
|
||||
open: false,
|
||||
open: defaultOpen,
|
||||
startGateway: true
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
|
|
@ -303,24 +315,31 @@ function parseWebArgs(args: string[], command: WebCliOptions["command"]): WebCli
|
|||
|
||||
async function startService(options: WebCliOptions): Promise<void> {
|
||||
const current = readServiceState();
|
||||
if (current && isProcessRunning(current.pid)) {
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearServiceState();
|
||||
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)] : []),
|
||||
...(options.open ? ["--open"] : ["--no-open"]),
|
||||
"--no-open",
|
||||
...(options.startGateway ? [] : ["--no-gateway"])
|
||||
];
|
||||
const child = spawn(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
env: serviceChildEnv(),
|
||||
env: serviceChildEnv(serviceToken),
|
||||
stdio: "ignore",
|
||||
windowsHide: true
|
||||
});
|
||||
|
|
@ -335,10 +354,31 @@ async function startService(options: WebCliOptions): Promise<void> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function serviceChildEnv(): NodeJS.ProcessEnv {
|
||||
async function openManagementUi(options: WebCliOptions): Promise<void> {
|
||||
await startService({
|
||||
...options,
|
||||
command: "start"
|
||||
});
|
||||
}
|
||||
|
||||
async function openManagementUrl(url: string): Promise<void> {
|
||||
try {
|
||||
await openSystemExternal(url);
|
||||
process.stdout.write(`Opened CCR management UI at ${url}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`Failed to open browser: ${formatError(error)}\n`);
|
||||
process.stdout.write(`CCR management UI is available at ${url}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function serviceChildEnv(serviceToken: string): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
env[serviceInstanceTokenEnv] = serviceToken;
|
||||
if (process.versions.electron) {
|
||||
env.ELECTRON_RUN_AS_NODE = "1";
|
||||
} else {
|
||||
|
|
@ -355,9 +395,11 @@ async function runWebServer(options: WebCliOptions): Promise<void> {
|
|||
startGateway: options.startGateway
|
||||
});
|
||||
if (options.daemonChild) {
|
||||
const serviceToken = process.env[serviceInstanceTokenEnv]?.trim() || undefined;
|
||||
writeServiceState({
|
||||
host: options.host,
|
||||
pid: process.pid,
|
||||
...(serviceToken ? { serviceToken } : {}),
|
||||
startedAt: new Date().toISOString(),
|
||||
startGateway: options.startGateway,
|
||||
url: runtime.url
|
||||
|
|
@ -389,33 +431,40 @@ async function stopService(): Promise<void> {
|
|||
process.stdout.write("CCR service is not running.\n");
|
||||
return;
|
||||
}
|
||||
if (!isProcessRunning(state.pid)) {
|
||||
const verification = await verifyServiceState(state);
|
||||
if (!verification.ok) {
|
||||
clearServiceState(state.pid);
|
||||
process.stdout.write("CCR service is not running.\n");
|
||||
return;
|
||||
}
|
||||
process.kill(state.pid, "SIGTERM");
|
||||
const stopped = await waitForProcessExit(state.pid, serviceStopTimeoutMs);
|
||||
if (!stopped && isProcessRunning(state.pid)) {
|
||||
throw new Error(`CCR service pid ${state.pid} did not stop within ${serviceStopTimeoutMs}ms.`);
|
||||
|
||||
await callServiceRpc(state, "quitApp");
|
||||
const stopped = verification.trustedPid
|
||||
? await waitForProcessExit(state.pid, serviceStopTimeoutMs)
|
||||
: await waitForServiceUnavailable(state, serviceStopTimeoutMs);
|
||||
if (!stopped) {
|
||||
throw new Error(`CCR service did not stop within ${serviceStopTimeoutMs}ms.`);
|
||||
}
|
||||
clearServiceState(state.pid);
|
||||
process.stdout.write("CCR service stopped.\n");
|
||||
}
|
||||
|
||||
function printHelp(exitCode: number): void {
|
||||
const command = cliCommandName();
|
||||
const output = [
|
||||
"Usage:",
|
||||
" ccr start [--host <host>] [--port <port>] [--open] [--no-gateway]",
|
||||
" ccr stop",
|
||||
" ccr <profile-name-or-id> [cli|app] [-- <agent args>]",
|
||||
` ${command} start [--host <host>] [--port <port>] [--open] [--no-gateway]`,
|
||||
` ${command} ui [--host <host>] [--port <port>] [--no-gateway]`,
|
||||
` ${command} stop`,
|
||||
` ${command} <profile-name-or-id> [cli|app] [-- <agent args>]`,
|
||||
"",
|
||||
"Examples:",
|
||||
" ccr start",
|
||||
" ccr stop",
|
||||
" ccr Codex",
|
||||
" ccr default-codex -- --model gpt-5-codex",
|
||||
" ccr default-codex app"
|
||||
` ${command} start`,
|
||||
` ${command} ui`,
|
||||
` ${command} stop`,
|
||||
` ${command} Codex`,
|
||||
` ${command} default-codex -- --model gpt-5-codex`,
|
||||
` ${command} default-codex app`
|
||||
].join("\n");
|
||||
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${output}\n`);
|
||||
|
|
@ -423,16 +472,42 @@ function printHelp(exitCode: number): void {
|
|||
}
|
||||
|
||||
function printStartHelp(exitCode: number): void {
|
||||
const command = cliCommandName();
|
||||
const output = [
|
||||
"Usage:",
|
||||
" ccr start [--host <host>] [--port <port>] [--open] [--no-gateway]",
|
||||
` ${command} start [--host <host>] [--port <port>] [--open] [--no-gateway]`,
|
||||
"",
|
||||
"Options:",
|
||||
" --host <host> Management server host. Defaults to 127.0.0.1.",
|
||||
" --port <port> Management server port. Defaults to 3458.",
|
||||
" --open Open the management page in the default browser.",
|
||||
" --no-open Do not open the management page.",
|
||||
" --no-gateway Start only the web management server."
|
||||
" --no-gateway Start only the web management server.",
|
||||
"",
|
||||
"Environment:",
|
||||
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
|
||||
].join("\n");
|
||||
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${output}\n`);
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
function printUiHelp(exitCode: number): void {
|
||||
const command = cliCommandName();
|
||||
const output = [
|
||||
"Usage:",
|
||||
` ${command} ui [--host <host>] [--port <port>] [--no-gateway]`,
|
||||
"",
|
||||
"Starts the background CCR service if needed and opens the management UI in the default browser.",
|
||||
"",
|
||||
"Options:",
|
||||
" --host <host> Management server host. Defaults to 127.0.0.1.",
|
||||
" --port <port> Management server port. Defaults to 3458.",
|
||||
" --no-open Start or find the service and print the management URL without opening a browser.",
|
||||
" --no-gateway Start only the web management server when the service is not already running.",
|
||||
"",
|
||||
"Environment:",
|
||||
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
|
||||
].join("\n");
|
||||
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${output}\n`);
|
||||
|
|
@ -440,11 +515,12 @@ function printStartHelp(exitCode: number): void {
|
|||
}
|
||||
|
||||
function printStopHelp(exitCode: number): void {
|
||||
const command = cliCommandName();
|
||||
const output = [
|
||||
"Usage:",
|
||||
" ccr stop",
|
||||
` ${command} stop`,
|
||||
"",
|
||||
"Stops the background CCR service started by `ccr start`."
|
||||
`Stops the background CCR service started by \`${command} start\`.`
|
||||
].join("\n");
|
||||
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${output}\n`);
|
||||
|
|
@ -452,21 +528,32 @@ function printStopHelp(exitCode: number): void {
|
|||
}
|
||||
|
||||
function printWebHelp(exitCode: number): void {
|
||||
const command = cliCommandName();
|
||||
const output = [
|
||||
"Usage:",
|
||||
" ccr serve [--host <host>] [--port <port>] [--open] [--no-gateway]",
|
||||
` ${command} serve [--host <host>] [--port <port>] [--open] [--no-gateway]`,
|
||||
"",
|
||||
"Options:",
|
||||
" --host <host> Management server host. Defaults to 127.0.0.1.",
|
||||
" --port <port> Management server port. Defaults to 3458.",
|
||||
" --open Open the management page in the default browser.",
|
||||
" --no-gateway Start only the web management server."
|
||||
" --no-gateway Start only the web management server.",
|
||||
"",
|
||||
"Environment:",
|
||||
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
|
||||
].join("\n");
|
||||
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${output}\n`);
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
function cliCommandName(): string {
|
||||
const configured = process.env.CCR_CLI_COMMAND_NAME?.trim();
|
||||
return configured && /^[A-Za-z0-9._-]+$/.test(configured)
|
||||
? configured
|
||||
: defaultCliCommandName;
|
||||
}
|
||||
|
||||
function readServiceState(): ServiceState | undefined {
|
||||
const file = serviceStateFile();
|
||||
if (!existsSync(file)) {
|
||||
|
|
@ -481,6 +568,7 @@ function readServiceState(): ServiceState | undefined {
|
|||
return {
|
||||
host: parsed.host,
|
||||
pid,
|
||||
serviceToken: typeof parsed.serviceToken === "string" && parsed.serviceToken.trim() ? parsed.serviceToken.trim() : undefined,
|
||||
startedAt: parsed.startedAt || "",
|
||||
startGateway: parsed.startGateway !== false,
|
||||
url: parsed.url
|
||||
|
|
@ -516,99 +604,11 @@ function currentCliScript(): string {
|
|||
return __filename;
|
||||
}
|
||||
|
||||
function delegateManagedDesktopCliToExternalCli(): number | undefined {
|
||||
if (!isManagedDesktopCliRuntime()) {
|
||||
return undefined;
|
||||
}
|
||||
if (process.env.CCR_MANAGED_CLI_NO_DELEGATE === "1" || process.env.CCR_MANAGED_CLI_DELEGATED === "1") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const externalCcr = findExternalCcrCommand();
|
||||
if (!externalCcr) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const launch = profileLaunchSpawnCommand({
|
||||
args: process.argv.slice(2),
|
||||
command: externalCcr
|
||||
});
|
||||
const result = spawnSync(launch.command, launch.args, {
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_MANAGED_CLI_DELEGATED: "1"
|
||||
},
|
||||
stdio: "inherit",
|
||||
windowsVerbatimArguments: !!launch.windowsVerbatimArguments
|
||||
});
|
||||
if (result.error) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof result.status === "number") {
|
||||
return result.status;
|
||||
}
|
||||
return result.signal === "SIGINT" ? 130 : 1;
|
||||
}
|
||||
|
||||
function isManagedDesktopCliRuntime(): boolean {
|
||||
const script = process.argv[1] || __filename;
|
||||
return samePath(path.resolve(script), path.join(CONFIGDIR, "bin", "ccr-cli.js"));
|
||||
}
|
||||
|
||||
function findExternalCcrCommand(): string | undefined {
|
||||
const pathKey = process.platform === "win32"
|
||||
? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path"
|
||||
: "PATH";
|
||||
const pathValue = process.env[pathKey] || "";
|
||||
const managedBinDir = path.resolve(CONFIGDIR, "bin");
|
||||
const names = process.platform === "win32"
|
||||
? ["ccr.cmd", "ccr.exe", "ccr.bat", "ccr"]
|
||||
: ["ccr"];
|
||||
|
||||
for (const rawSegment of pathValue.split(path.delimiter)) {
|
||||
const dir = path.resolve(rawSegment || ".");
|
||||
if (samePath(dir, managedBinDir)) {
|
||||
continue;
|
||||
}
|
||||
for (const name of names) {
|
||||
const candidate = path.join(dir, name);
|
||||
if (isExecutableFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isExecutableFile(file: string): boolean {
|
||||
try {
|
||||
const stats = statSync(file);
|
||||
if (!stats.isFile() && !stats.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return true;
|
||||
}
|
||||
accessSync(file, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.normalize(left);
|
||||
const normalizedRight = path.normalize(right);
|
||||
return process.platform === "win32"
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
async function waitForServiceState(pid: number | undefined, timeoutMs: number): Promise<ServiceState | undefined> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const state = readServiceState();
|
||||
if (state && (!pid || state.pid === pid) && isProcessRunning(state.pid)) {
|
||||
if (state && (!pid || state.pid === pid) && (await verifyServiceState(state)).ok) {
|
||||
return state;
|
||||
}
|
||||
await delay(150);
|
||||
|
|
@ -640,6 +640,98 @@ function isProcessRunning(pid: number | undefined): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
type ServiceStateVerification =
|
||||
| { ok: true; trustedPid: boolean }
|
||||
| { ok: false };
|
||||
|
||||
type ServiceIdentity = {
|
||||
pid?: unknown;
|
||||
serviceTokenConfigured?: unknown;
|
||||
serviceTokenMatches?: unknown;
|
||||
};
|
||||
|
||||
async function verifyServiceState(state: ServiceState): Promise<ServiceStateVerification> {
|
||||
if (!isProcessRunning(state.pid)) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (state.serviceToken) {
|
||||
const identity = await callServiceRpc<ServiceIdentity>(state, "getServiceIdentity", [state.serviceToken]).catch(() => undefined);
|
||||
if (identity?.serviceTokenMatches === true && Number(identity.pid) === state.pid) {
|
||||
return { ok: true, trustedPid: true };
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined);
|
||||
return appInfo?.name === "Claude Code Router"
|
||||
? { ok: true, trustedPid: false }
|
||||
: { ok: false };
|
||||
}
|
||||
|
||||
async function waitForServiceUnavailable(state: ServiceState, timeoutMs: number): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined);
|
||||
if (appInfo?.name !== "Claude Code Router") {
|
||||
return true;
|
||||
}
|
||||
await delay(150);
|
||||
}
|
||||
const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined);
|
||||
return appInfo?.name !== "Claude Code Router";
|
||||
}
|
||||
|
||||
async function callServiceRpc<T>(state: ServiceState, method: string, args: unknown[] = []): Promise<T> {
|
||||
const endpoint = serviceRpcEndpoint(state.url);
|
||||
const authToken = serviceAuthToken(state.url);
|
||||
if (!endpoint || !authToken) {
|
||||
throw new Error("CCR service state does not include a usable management URL.");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), serviceRpcTimeoutMs);
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
body: JSON.stringify({ args, method }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
[webAuthHeader]: authToken
|
||||
},
|
||||
method: "POST",
|
||||
signal: controller.signal
|
||||
});
|
||||
const payload = await response.json().catch(() => undefined) as { ok?: boolean; value?: T } | undefined;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(`CCR service RPC ${method} failed with HTTP ${response.status}`);
|
||||
}
|
||||
return payload.value as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function serviceRpcEndpoint(url: string): string | undefined {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.origin}/api/ccr/rpc`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function serviceAuthToken(url: string): string {
|
||||
try {
|
||||
return new URL(url).searchParams.get(webAuthQueryParam)?.trim() ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function generateServiceToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
7
packages/cli/tsconfig.json
Normal file
7
packages/cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
21
packages/core/package.json
Normal file
21
packages/core/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@claude-code-router/core",
|
||||
"version": "3.0.2",
|
||||
"private": true,
|
||||
"description": "Claude Code Router core gateway, routing, provider, and storage services.",
|
||||
"main": "dist/main/server.js",
|
||||
"bin": {
|
||||
"ccr-core-server": "dist/main/server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.4",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"pm2": "^6.0.13",
|
||||
"undici": "^7.27.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import os from "node:os";
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "../shared/app";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
|
||||
|
|
@ -126,6 +127,11 @@ function botGatewaySdkEnv(): Record<string, string> {
|
|||
}
|
||||
|
||||
function resolveBotGatewaySdkModule(): string {
|
||||
const bundled = resolveBundledBotGatewaySdkModule();
|
||||
if (bundled) {
|
||||
return bundled;
|
||||
}
|
||||
|
||||
try {
|
||||
return path.join(path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")), "dist", "index.js");
|
||||
} catch {
|
||||
|
|
@ -133,6 +139,20 @@ function resolveBotGatewaySdkModule(): string {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveBundledBotGatewaySdkModule(): string {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
const candidates = [
|
||||
path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"),
|
||||
...(resourcesPath
|
||||
? [
|
||||
path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"),
|
||||
path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js")
|
||||
]
|
||||
: [])
|
||||
];
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? "";
|
||||
}
|
||||
|
||||
function normalizeBotGatewayForWebSocket(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig {
|
||||
const platform = normalizeBotGatewayPlatform(bot.platform);
|
||||
return {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { BotHandoffScanTarget } from "../shared/app";
|
||||
import { windowsSystemCommand } from "./windows-system";
|
||||
import type { BotHandoffScanTarget } from "@ccr/core/contracts/app";
|
||||
import { windowsSystemCommand } from "@ccr/core/platform/windows-system";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import type {
|
||||
BotGatewayQrLoginCancelRequest,
|
||||
BotGatewayQrLoginCancelResult,
|
||||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
BotGatewayQrLoginWaitRequest,
|
||||
BotGatewayQrLoginWaitResult,
|
||||
BotGatewayRuntimeConfig
|
||||
} from "../shared/app";
|
||||
} from "@ccr/core/contracts/app";
|
||||
|
||||
type BotGatewayClientWithRequest = {
|
||||
close?: () => Promise<void> | void;
|
||||
|
|
@ -244,6 +244,7 @@ async function loadBotGatewaySdk(): Promise<BotGatewaySdkModule> {
|
|||
async function importBotGatewaySdk(): Promise<BotGatewaySdkModule> {
|
||||
const candidates = [
|
||||
process.env.CCR_BOT_GATEWAY_SDK_MODULE,
|
||||
resolveBundledBotGatewaySdkModule(),
|
||||
"@the-next-ai/bot-gateway-sdk"
|
||||
].filter((value): value is string => Boolean(value?.trim()));
|
||||
const errors: string[] = [];
|
||||
|
|
@ -261,6 +262,20 @@ async function importBotGatewaySdk(): Promise<BotGatewaySdkModule> {
|
|||
throw new Error(`Unable to load @the-next-ai/bot-gateway-sdk. ${errors.join("; ")}`);
|
||||
}
|
||||
|
||||
function resolveBundledBotGatewaySdkModule(): string {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
const candidates = [
|
||||
path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"),
|
||||
...(resourcesPath
|
||||
? [
|
||||
path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"),
|
||||
path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js")
|
||||
]
|
||||
: [])
|
||||
];
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? "";
|
||||
}
|
||||
|
||||
function botGatewaySdkImportSpecifier(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
|
||||
|
|
@ -46,12 +46,9 @@ const claudeAppDesignCdpConnectTimeoutMs = 15_000;
|
|||
const claudeAppDesignCdpKeepAliveMs = 45_000;
|
||||
const claudeAppDesignCdpPollIntervalMs = 250;
|
||||
|
||||
export function shouldEnableClaudeAppDesignCdp(enabledByConfig = false): boolean {
|
||||
export function shouldEnableClaudeAppDesignCdp(_enabledByConfig = false): boolean {
|
||||
const configured = process.env.CCR_CLAUDE_APP_DESIGN_CDP?.trim().toLowerCase();
|
||||
if (configured === "false" || configured === "0" || configured === "off") {
|
||||
return false;
|
||||
}
|
||||
return enabledByConfig || configured === "true" || configured === "1" || configured === "on";
|
||||
return configured === "true" || configured === "1" || configured === "on";
|
||||
}
|
||||
|
||||
export async function reserveClaudeAppCdpPort(logger: ClaudeAppCdpLogger = console, enabledByConfig = false): Promise<number | undefined> {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AppConfig } from "./app";
|
||||
import { normalizeProfileScopeValue } from "./app";
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import { normalizeProfileScopeValue } from "@ccr/core/contracts/app";
|
||||
|
||||
export const CLAUDE_APP_FALLBACK_MODEL = "claude-sonnet-4-5";
|
||||
export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]";
|
||||
|
|
@ -2,16 +2,16 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { resolveRuntimeAppPath } from "./app-paths";
|
||||
import { saveAppConfig } from "./config";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import { resolveRuntimeAppPath } from "@ccr/core/runtime/app-paths";
|
||||
import { saveAppConfig } from "@ccr/core/config/config";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import {
|
||||
buildClaudeAppGatewayInferenceModels,
|
||||
type ClaudeAppGatewayInferenceModel,
|
||||
type ClaudeAppGatewayModelRouteOptions
|
||||
} from "../shared/claude-app-gateway";
|
||||
import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ClaudeAppGatewayApplyResult } from "../shared/app";
|
||||
import { findModelCatalogEntry } from "../server/gateway/model-catalog";
|
||||
} from "@ccr/core/agents/claude-app/gateway-routes";
|
||||
import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ClaudeAppGatewayApplyResult } from "@ccr/core/contracts/app";
|
||||
import { findModelCatalogEntry } from "@ccr/core/gateway/model-catalog";
|
||||
|
||||
const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311";
|
||||
const CLAUDE_APP_CONFIG_NAME = "Claude Code Router";
|
||||
|
|
@ -2,12 +2,12 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|||
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig } from "../shared/app";
|
||||
import { botGatewayProfileEnv } from "./bot-gateway-env";
|
||||
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "./claude-app-cdp";
|
||||
import { claudeCodeUtcTimezoneEnvOverride } from "./claude-environment";
|
||||
import { resolveClaudeCodeSettingsFile } from "./profile-launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "./windows-app-discovery";
|
||||
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "@ccr/core/agents/claude-app/cdp";
|
||||
import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment";
|
||||
import { resolveClaudeCodeSettingsFile } from "@ccr/core/profiles/launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
|
||||
|
||||
type ClaudeAppLookupResult = {
|
||||
checked: string[];
|
||||
|
|
@ -19,6 +19,7 @@ export type ClaudeAppLaunchResult = {
|
|||
command: string;
|
||||
cdpPort?: number;
|
||||
claudeDesignProxy?: boolean;
|
||||
pidIsLauncher?: boolean;
|
||||
pid?: number;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
|
@ -52,9 +53,9 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
|
|||
const shouldOpenDesign = shouldOpenClaudeAppDesign(config);
|
||||
const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign);
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
const appEnv: Record<string, string> = {
|
||||
...profileEnv(profile),
|
||||
...claudeCodeModelEnv(profile),
|
||||
...(config ? botGatewayProfileEnv(config, profile, "app") : {}),
|
||||
CLAUDE_CONFIG_DIR: settingsDir,
|
||||
CLAUDE_USER_DATA_DIR: userDataDir,
|
||||
|
|
@ -63,11 +64,16 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
|
|||
ELECTRON_ENABLE_LOGGING: "1",
|
||||
...claudeCodeUtcTimezoneEnvOverride()
|
||||
};
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...appEnv
|
||||
};
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const designUrl = claudeAppDesignUrl(config);
|
||||
const proxyUrl = claudeAppProxyUrl(config);
|
||||
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir, cdpPort, proxyUrl), {
|
||||
const launch = claudeAppLaunchCommand(lookup.executable, userDataDir, cdpPort, proxyUrl, appEnv);
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
detached: true,
|
||||
env,
|
||||
stdio: "ignore"
|
||||
|
|
@ -83,8 +89,9 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
|
|||
return {
|
||||
child,
|
||||
claudeDesignProxy: Boolean(proxyUrl),
|
||||
command: lookup.executable,
|
||||
command: launch.command,
|
||||
...(cdpPort ? { cdpPort } : {}),
|
||||
...(launch.pidIsLauncher ? { pidIsLauncher: launch.pidIsLauncher } : {}),
|
||||
pid: child.pid,
|
||||
userDataDir
|
||||
};
|
||||
|
|
@ -162,6 +169,52 @@ function claudeElectronArgs(userDataDir: string, cdpPort?: number, proxyUrl?: st
|
|||
];
|
||||
}
|
||||
|
||||
export function claudeAppLaunchCommand(
|
||||
executable: string,
|
||||
userDataDir: string,
|
||||
cdpPort: number | undefined,
|
||||
proxyUrl: string | undefined,
|
||||
env: Record<string, string>
|
||||
): { args: string[]; command: string; pidIsLauncher?: boolean } {
|
||||
const args = claudeElectronArgs(userDataDir, cdpPort, proxyUrl);
|
||||
const appBundle = process.platform === "darwin" ? macAppBundleFromExecutable(executable) : undefined;
|
||||
if (appBundle) {
|
||||
return {
|
||||
args: [
|
||||
"-W",
|
||||
"-n",
|
||||
...macOpenEnvArgs(env),
|
||||
appBundle,
|
||||
"--args",
|
||||
...args
|
||||
],
|
||||
command: "/usr/bin/open",
|
||||
pidIsLauncher: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
args,
|
||||
command: executable
|
||||
};
|
||||
}
|
||||
|
||||
function macAppBundleFromExecutable(executable: string): string | undefined {
|
||||
const normalized = path.normalize(executable);
|
||||
const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
|
||||
const markerIndex = normalized.lastIndexOf(marker);
|
||||
if (markerIndex <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const appBundle = normalized.slice(0, markerIndex);
|
||||
return appBundle.endsWith(".app") && isDirectory(appBundle) ? appBundle : undefined;
|
||||
}
|
||||
|
||||
function macOpenEnvArgs(env: Record<string, string>): string[] {
|
||||
return Object.entries(env)
|
||||
.filter(([key, value]) => isEnvName(key) && typeof value === "string")
|
||||
.flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
||||
}
|
||||
|
||||
function claudeElectronUserDataDir(settingsDir: string, profile: ProfileConfig): string {
|
||||
return path.join(
|
||||
settingsDir,
|
||||
|
|
@ -314,6 +367,35 @@ function profileEnv(profile: ProfileConfig): Record<string, string> {
|
|||
}, {});
|
||||
}
|
||||
|
||||
function claudeCodeModelEnv(profile: ProfileConfig): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
const model = normalizeClientModel(profile.model);
|
||||
if (model) {
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
env.CCR_CLAUDE_CODE_MODEL = model;
|
||||
env.CODEXL_CLAUDE_CODE_MODEL = model;
|
||||
}
|
||||
const smallFastModel = normalizeClientModel(profile.smallFastModel);
|
||||
if (smallFastModel) {
|
||||
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function normalizeClientModel(value: string | undefined): string {
|
||||
const trimmed = value?.trim() || "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const commaIndex = trimmed.indexOf(",");
|
||||
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
|
||||
const provider = trimmed.slice(0, commaIndex).trim();
|
||||
const model = trimmed.slice(commaIndex + 1).trim();
|
||||
return provider && model ? `${provider}/${model}` : "";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isEnvName(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
export const CLAUDE_CODE_MCP_CONFIG_ENV = "CCR_CLAUDE_CODE_MCP_CONFIG";
|
||||
export const CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV = "CODEXL_CLAUDE_CODE_MCP_CONFIG";
|
||||
|
||||
const chinaTimeZones = new Set([
|
||||
"asia/chongqing",
|
||||
"asia/chungking",
|
||||
|
|
@ -9,6 +12,15 @@ const chinaTimeZones = new Set([
|
|||
"prc"
|
||||
]);
|
||||
|
||||
export function claudeCodeMcpConfigEnv(configFile: string | undefined): Record<string, string> {
|
||||
return configFile
|
||||
? {
|
||||
[CLAUDE_CODE_MCP_CONFIG_ENV]: configFile,
|
||||
[CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]: configFile
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
export function claudeCodeUtcTimezoneEnvOverride(timeZone = currentTimeZone()): Record<string, string> {
|
||||
return isChinaTimeZone(timeZone) ? { TZ: "UTC" } : {};
|
||||
}
|
||||
|
|
@ -2,12 +2,12 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig } from "../shared/app";
|
||||
import { botGatewayProfileEnv } from "./bot-gateway-env";
|
||||
import { codexModelCatalogJson } from "./codex-model-catalog";
|
||||
import { buildProfileLaunchPlan, resolveCodexConfigFile } from "./profile-launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "./windows-app-discovery";
|
||||
import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "./zcode-profile-config";
|
||||
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import { codexModelCatalogJson } from "@ccr/core/agents/codex/model-catalog";
|
||||
import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profiles/launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
|
||||
import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config";
|
||||
|
||||
type CodexAppLookupResult = {
|
||||
checked: string[];
|
||||
|
|
@ -18,6 +18,8 @@ const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60
|
|||
const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000);
|
||||
const CONFIG_DIR = resolveConfigDir();
|
||||
const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || "";
|
||||
const CLAUDE_CODE_MCP_CONFIG_ENV = "CCR_CLAUDE_CODE_MCP_CONFIG";
|
||||
const CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV = "CODEXL_CLAUDE_CODE_MCP_CONFIG";
|
||||
const CLAUDE_CODE_CHINA_TIME_ZONES = new Set([
|
||||
"asia/chongqing",
|
||||
"asia/chungking",
|
||||
|
|
@ -87,7 +89,8 @@ async function main() {
|
|||
|
||||
async function runClaudeCodeCliWrapper(args) {
|
||||
const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude");
|
||||
log("claude_code_wrapper_start", { realCli, args });
|
||||
const realArgs = claudeCodeCliWrapperArgs(args);
|
||||
log("claude_code_wrapper_start", { realCli, args, realArgs });
|
||||
const captureStdout = shouldCaptureClaudeCodeCliStdout(args);
|
||||
const remoteSync = createRemoteSyncClient({
|
||||
args,
|
||||
|
|
@ -96,7 +99,7 @@ async function runClaudeCodeCliWrapper(args) {
|
|||
title: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") || "Claude Code"
|
||||
});
|
||||
const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN");
|
||||
const child = childProcess.spawn(realCli, args, {
|
||||
const child = childProcess.spawn(realCli, realArgs, {
|
||||
env: {
|
||||
...withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
|
||||
...claudeCodeUtcTimezoneEnvOverride()
|
||||
|
|
@ -145,6 +148,102 @@ async function runClaudeCodeCliWrapper(args) {
|
|||
process.exitCode = code;
|
||||
}
|
||||
|
||||
function claudeCodeCliWrapperArgs(args) {
|
||||
const modelArgs = claudeCodeArgsWithModel(args);
|
||||
return claudeCodeArgsWithMcpConfig(modelArgs, process.env);
|
||||
}
|
||||
|
||||
function claudeCodeArgsWithModel(args) {
|
||||
const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || nonEmptyEnv("ANTHROPIC_MODEL");
|
||||
if (!model || claudeCodeArgsHaveModel(args) || claudeCodeArgsShouldSkipModelInjection(args)) {
|
||||
return args;
|
||||
}
|
||||
return ["--model", model, ...args];
|
||||
}
|
||||
|
||||
function claudeCodeArgsWithMcpConfig(args, env) {
|
||||
const mcpConfig = nonEmptyEnvFrom(env, CLAUDE_CODE_MCP_CONFIG_ENV) || nonEmptyEnvFrom(env, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV);
|
||||
if (!mcpConfig || claudeCodeArgsHaveMcpConfig(args) || claudeCodeArgsShouldSkipModelInjection(args)) {
|
||||
return args;
|
||||
}
|
||||
return ["--mcp-config", mcpConfig, ...args];
|
||||
}
|
||||
|
||||
function claudeCodeArgsHaveModel(args) {
|
||||
for (const arg of args) {
|
||||
if (arg === "--model" || arg === "-m" || arg.startsWith("--model=")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function claudeCodeArgsHaveMcpConfig(args) {
|
||||
for (const arg of args) {
|
||||
if (arg === "--mcp-config" || arg.startsWith("--mcp-config=")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function claudeCodeArgsShouldSkipModelInjection(args) {
|
||||
if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v")) {
|
||||
return true;
|
||||
}
|
||||
const command = firstClaudeCodePositionalArg(args);
|
||||
return Boolean(command && new Set([
|
||||
"config",
|
||||
"doctor",
|
||||
"help",
|
||||
"install",
|
||||
"login",
|
||||
"logout",
|
||||
"mcp",
|
||||
"update",
|
||||
"upgrade",
|
||||
"version"
|
||||
]).has(command.toLowerCase()));
|
||||
}
|
||||
|
||||
function firstClaudeCodePositionalArg(args) {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--") {
|
||||
return undefined;
|
||||
}
|
||||
if (!arg.startsWith("-")) {
|
||||
return arg;
|
||||
}
|
||||
if (claudeCodeOptionTakesValue(arg) && !arg.includes("=")) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function claudeCodeOptionTakesValue(arg) {
|
||||
return new Set([
|
||||
"--add-dir",
|
||||
"--append-system-prompt",
|
||||
"--config",
|
||||
"--continue",
|
||||
"--debug-to",
|
||||
"--fallback-model",
|
||||
"--model",
|
||||
"--mcp-config",
|
||||
"--output-format",
|
||||
"--permission-mode",
|
||||
"--resume",
|
||||
"--settings",
|
||||
"--system-prompt",
|
||||
"-c",
|
||||
"-m",
|
||||
"-p",
|
||||
"-r"
|
||||
]).has(arg);
|
||||
}
|
||||
|
||||
function shouldCaptureClaudeCodeCliStdout(args) {
|
||||
if (boolEnv("CCR_CLAUDE_CODE_CAPTURE_STDOUT") || boolEnv("CODEXL_CLAUDE_CODE_CAPTURE_STDOUT")) {
|
||||
return true;
|
||||
|
|
@ -736,7 +835,14 @@ async function runClaudeCodeBotWorker(args) {
|
|||
try {
|
||||
const server = new ClaudeCodeAppServer(options);
|
||||
server.ensureBotBridgeRegistered();
|
||||
log("claude_bot_worker_start", { workspaceName: options.workspaceName, pid: process.pid, lockPath: lock.path });
|
||||
log("claude_bot_worker_start", {
|
||||
workspaceName: options.workspaceName,
|
||||
pid: process.pid,
|
||||
lockPath: lock.path,
|
||||
claudeConfigDir: nonEmptyEnv("CLAUDE_CONFIG_DIR"),
|
||||
claudeUserDataDir: currentClaudeAppUserDataDir(),
|
||||
model: nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || agentEnv(codexRuntimeAgent(), "MODEL") || ""
|
||||
});
|
||||
await waitForTerminationSignal();
|
||||
await botBridge().stop();
|
||||
log("claude_bot_worker_stop", { pid: process.pid });
|
||||
|
|
@ -1295,13 +1401,23 @@ class ClaudeCodeAppServer {
|
|||
return null;
|
||||
}
|
||||
if (!entry.claudeSessionId && !entry.claudeAppSessionId) return null;
|
||||
const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || "");
|
||||
if (!botSessionEntryMatchesCurrentProfile(entry, appSession)) {
|
||||
log("bot_gateway_session_scope_skip", {
|
||||
conversationKeyPrefix: key.slice(0, 80),
|
||||
threadId: entry.threadId || "",
|
||||
claudeConfigDir: entry.claudeConfigDir || appSession.claudeConfigDir || "",
|
||||
claudeAppSessionFile: entry.claudeAppSessionFile || "",
|
||||
expectedUserDataDir: currentClaudeAppUserDataDir()
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const thread = this.createThread({
|
||||
cwd: entry.cwd || process.cwd(),
|
||||
model: entry.model || undefined,
|
||||
workspaceKind: "local",
|
||||
claudeConfigDir: entry.claudeConfigDir || null
|
||||
});
|
||||
const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || "");
|
||||
this.replaceThreadId(thread, entry.threadId || thread.id);
|
||||
thread.sessionId = entry.sessionId || thread.id;
|
||||
thread.claudeSessionId = entry.claudeSessionId || appSession.cliSessionId || null;
|
||||
|
|
@ -1537,7 +1653,15 @@ class ClaudeCodeAppServer {
|
|||
if (!thread || !turn) return;
|
||||
const started = Date.now();
|
||||
const command = claudeCommand(work);
|
||||
log("claude_turn_spawn", { threadId: work.threadId, turnId: work.turnId, command: command.command, args: command.args });
|
||||
log("claude_turn_spawn", {
|
||||
threadId: work.threadId,
|
||||
turnId: work.turnId,
|
||||
command: command.command,
|
||||
args: command.args,
|
||||
cwd: work.cwd,
|
||||
claudeConfigDir: work.claudeConfigDir || "",
|
||||
expectedUserDataDir: currentClaudeAppUserDataDir()
|
||||
});
|
||||
const child = childProcess.spawn(command.command, command.args, {
|
||||
cwd: work.cwd,
|
||||
env: command.env,
|
||||
|
|
@ -1855,7 +1979,7 @@ function claudeCommand(work) {
|
|||
}
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
args: claudeCodeArgsWithMcpConfig(args, env),
|
||||
env
|
||||
};
|
||||
}
|
||||
|
|
@ -2256,8 +2380,12 @@ function configRead(params, values) {
|
|||
model: agentEnv(runtimeAgent, "MODEL") || DEFAULT_MODEL,
|
||||
model_catalog_json: JSON.stringify(modelCatalogConfigValue()),
|
||||
model_provider: agentEnv(runtimeAgent, "MODEL_PROVIDER") || "claude-code",
|
||||
approval_policy: "default",
|
||||
sandbox_mode: "workspace-write"
|
||||
approval_policy: "default"
|
||||
// sandbox_mode intentionally omitted: let Codex read it from its own
|
||||
// config.toml (e.g. [windows] sandbox) instead of forcing workspace-write.
|
||||
// Forcing workspace-write triggers codex-windows-sandbox-setup.exe on every
|
||||
// command, which fails on systems where the COM+ catalog is unavailable
|
||||
// (see openai/codex#29332), surfacing as repeated error dialogs.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -3147,6 +3275,10 @@ async function importBotGatewaySdk() {
|
|||
if (configured) {
|
||||
candidates.push(configured);
|
||||
}
|
||||
const bundled = bundledBotGatewaySdkModule();
|
||||
if (bundled) {
|
||||
candidates.push(bundled);
|
||||
}
|
||||
candidates.push("@the-next-ai/bot-gateway-sdk");
|
||||
const errors = [];
|
||||
for (const candidate of candidates) {
|
||||
|
|
@ -3163,6 +3295,20 @@ async function importBotGatewaySdk() {
|
|||
throw new Error("Unable to load @the-next-ai/bot-gateway-sdk. " + errors.join("; "));
|
||||
}
|
||||
|
||||
function bundledBotGatewaySdkModule() {
|
||||
const resourcesPath = process["resourcesPath"];
|
||||
const candidates = [
|
||||
path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"),
|
||||
...(resourcesPath
|
||||
? [
|
||||
path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"),
|
||||
path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js")
|
||||
]
|
||||
: [])
|
||||
];
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function botGatewaySdkImportSpecifier(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) return "@the-next-ai/bot-gateway-sdk";
|
||||
|
|
@ -3414,9 +3560,9 @@ function latestClaudeAppLocalAgentSession() {
|
|||
}
|
||||
|
||||
function claudeAppLocalAgentSessions() {
|
||||
const baseDir = nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR");
|
||||
const baseDir = currentClaudeAppUserDataDir();
|
||||
if (!baseDir) return [];
|
||||
const root = path.join(expandHome(baseDir), "local-agent-mode-sessions");
|
||||
const root = path.join(baseDir, "local-agent-mode-sessions");
|
||||
const files = listClaudeAppSessionFiles(root, 6);
|
||||
const sessions = [];
|
||||
for (const file of files) {
|
||||
|
|
@ -3449,6 +3595,37 @@ function claudeAppLocalAgentSessions() {
|
|||
return sessions;
|
||||
}
|
||||
|
||||
function currentClaudeAppUserDataDir() {
|
||||
return expandHome(nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR") || "");
|
||||
}
|
||||
|
||||
function botSessionEntryMatchesCurrentProfile(entry, appSession) {
|
||||
const expectedUserDataDir = currentClaudeAppUserDataDir();
|
||||
if (!expectedUserDataDir) return true;
|
||||
const candidates = [
|
||||
entry && entry.claudeConfigDir,
|
||||
entry && entry.claudeAppSessionFile,
|
||||
entry && entry.cwd,
|
||||
appSession && appSession.claudeConfigDir
|
||||
];
|
||||
return candidates.some((candidate) => pathIsInside(candidate, expectedUserDataDir));
|
||||
}
|
||||
|
||||
function pathIsInside(candidate, parentDir) {
|
||||
const child = expandHome(String(candidate || ""));
|
||||
const parent = expandHome(String(parentDir || ""));
|
||||
if (!child || !parent) return false;
|
||||
const childPath = normalizeComparablePath(path.resolve(child));
|
||||
const parentPath = normalizeComparablePath(path.resolve(parent));
|
||||
if (childPath === parentPath) return true;
|
||||
const relative = path.relative(parentPath, childPath);
|
||||
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function normalizeComparablePath(value) {
|
||||
return process.platform === "win32" ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function resolveClaudeAppLocalAgentSession(selector) {
|
||||
const query = String(selector || "").trim();
|
||||
if (!query) return null;
|
||||
|
|
@ -4098,6 +4275,11 @@ function nonEmptyEnv(name) {
|
|||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
function nonEmptyEnvFrom(env, name) {
|
||||
const value = env?.[name];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
function codexRuntimeAgent() {
|
||||
return nonEmptyEnv("CCR_ZCODE_PROFILE") ||
|
||||
nonEmptyEnv("CODEXL_ZCODE_PROFILE") ||
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "../shared/app";
|
||||
import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "../shared/app";
|
||||
import { BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "@ccr/core/contracts/app";
|
||||
import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "@ccr/core/contracts/app";
|
||||
import {
|
||||
findModelCatalogEntry,
|
||||
modelCatalogMaxInputTokens,
|
||||
readCatalogCapability,
|
||||
type ModelCatalogEntry
|
||||
} from "../server/gateway/model-catalog";
|
||||
} from "@ccr/core/gateway/model-catalog";
|
||||
|
||||
const fusionModelProviderName = "Fusion";
|
||||
const codexDefaultContextWindow = 128_000;
|
||||
|
|
@ -178,7 +178,11 @@ function codexModelCapabilityProfile(
|
|||
supportsFusionWebSearch ||
|
||||
(
|
||||
readCatalogCapability(capabilities, "webSearch") &&
|
||||
(providerProtocol === "openai_responses" || providerProtocol === "anthropic_messages")
|
||||
(
|
||||
providerProtocol === "openai_responses" ||
|
||||
providerProtocol === "anthropic_messages" ||
|
||||
providerProtocol === "gemini_interactions"
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -231,7 +235,7 @@ function findConfiguredProvider(
|
|||
|
||||
function codexProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol | undefined {
|
||||
const capabilityProtocols = uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type)));
|
||||
for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content"] as GatewayProviderProtocol[]) {
|
||||
for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content", "gemini_interactions"] as GatewayProviderProtocol[]) {
|
||||
if (capabilityProtocols.includes(protocol)) {
|
||||
return protocol;
|
||||
}
|
||||
|
|
@ -253,6 +257,9 @@ function inferProviderProtocol(provider: GatewayProviderConfig): GatewayProvider
|
|||
if (providerEndpointLooksLikeResponses(provider)) {
|
||||
return "openai_responses";
|
||||
}
|
||||
if (url.includes("/interactions") || transformer.includes("gemini_interactions")) {
|
||||
return "gemini_interactions";
|
||||
}
|
||||
if (url.includes("generativelanguage.googleapis.com") || transformer.includes("gemini")) {
|
||||
return "gemini_generate_content";
|
||||
}
|
||||
|
|
@ -308,6 +315,16 @@ function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | un
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -415,11 +432,18 @@ function virtualModelProfileSupportsFusionWebSearch(profile: VirtualModelProfile
|
|||
|
||||
return (profile.tools ?? []).some((tool) => {
|
||||
const name = tool.name.trim();
|
||||
return name === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME ||
|
||||
name.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`);
|
||||
return fusionWebSearchToolNameMatches(name);
|
||||
});
|
||||
}
|
||||
|
||||
function fusionWebSearchToolNameMatches(name: string): boolean {
|
||||
const normalized = name.toLowerCase().replace(/[-.]/g, "_");
|
||||
return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME ||
|
||||
normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`) ||
|
||||
normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) ||
|
||||
normalized.includes("search_web");
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
|
|
@ -5,10 +6,11 @@ import type {
|
|||
LocalAgentProviderImportResult,
|
||||
ProviderAccountConfig,
|
||||
ProviderAccountMappingConfig
|
||||
} from "../../shared/app";
|
||||
} from "@ccr/core/contracts/app";
|
||||
import {
|
||||
bearerAuthPlugin,
|
||||
findOauthTokenSet,
|
||||
isRecord,
|
||||
missingCandidate,
|
||||
providerInternalNamePlaceholder,
|
||||
providerPayload,
|
||||
|
|
@ -16,9 +18,10 @@ import {
|
|||
uniqueProviderName,
|
||||
uniqueStrings,
|
||||
type OAuthTokenSet
|
||||
} from "./shared";
|
||||
} from "@ccr/core/agents/local-providers/shared";
|
||||
|
||||
const claudeDefaultModels = ["claude-sonnet-4-20250514"];
|
||||
const claudeDefaultModels = ["claude-sonnet-5"];
|
||||
const claudeCodeKeychainService = "Claude Code-credentials";
|
||||
|
||||
const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({
|
||||
id,
|
||||
|
|
@ -148,6 +151,19 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined {
|
|||
sourceFile
|
||||
};
|
||||
}
|
||||
|
||||
const keychainRecord = readClaudeCodeKeychainRecord();
|
||||
if (keychainRecord) {
|
||||
const credential = findOauthTokenSet(keychainRecord);
|
||||
if (credential) {
|
||||
return {
|
||||
accessToken: credential.accessToken,
|
||||
refreshToken: credential.refreshToken,
|
||||
sourceFile: `keychain:${claudeCodeKeychainService}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -158,3 +174,24 @@ function claudeCredentialFiles(): string[] {
|
|||
path.join(os.homedir(), ".config", "claude", "credentials.json")
|
||||
]);
|
||||
}
|
||||
|
||||
// Newer macOS builds of the Claude Code CLI store credentials in the
|
||||
// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the
|
||||
// standard macOS keychain access prompt (Allow / Always Allow); the user
|
||||
// declining or the item not existing both surface as a non-zero exit here.
|
||||
function readClaudeCodeKeychainRecord(): Record<string, unknown> | undefined {
|
||||
if (process.platform !== "darwin") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"security",
|
||||
["find-generic-password", "-s", claudeCodeKeychainService, "-w"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
);
|
||||
const parsed = JSON.parse(output.trim()) as unknown;
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
642
packages/core/src/agents/local-providers/codex.ts
Normal file
642
packages/core/src/agents/local-providers/codex.ts
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
LocalAgentProviderCandidate,
|
||||
LocalAgentProviderImportResult,
|
||||
GatewayProviderConfig,
|
||||
ProviderAccountConfig,
|
||||
ProviderAccountConnectorConfig,
|
||||
ProviderAccountMappingConfig,
|
||||
ProviderAccountMeter,
|
||||
ProviderAccountMeterDetail
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { normalizeProviderBaseUrl } from "@ccr/core/providers/url";
|
||||
import {
|
||||
isRecord,
|
||||
localAgentProviderApiKey,
|
||||
missingCandidate,
|
||||
modelDisplayNamesForModels,
|
||||
providerInternalNamePlaceholder,
|
||||
providerNamePlaceholder,
|
||||
providerNameSlugPlaceholder,
|
||||
providerPayload,
|
||||
readBoolean,
|
||||
readJsonRecord,
|
||||
readString,
|
||||
uniqueProviderName,
|
||||
uniqueStrings,
|
||||
type OAuthTokenSet
|
||||
} from "@ccr/core/agents/local-providers/shared";
|
||||
|
||||
export const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex";
|
||||
|
||||
const codexAccountBaseUrl = "https://chatgpt.com/backend-api";
|
||||
const codexDefaultModels = ["gpt-5-codex"];
|
||||
|
||||
type LocalAgentModelCatalog = {
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
const codexAccountRateLimitMapping: ProviderAccountMappingConfig = {
|
||||
meters: [
|
||||
{
|
||||
id: "codex_primary_quota",
|
||||
kind: "quota",
|
||||
label: "Primary quota",
|
||||
limit: 100,
|
||||
remaining: [
|
||||
"100 - $.rate_limit.primary_window.used_percent",
|
||||
"100 - $.rate_limits.primary.used_percent"
|
||||
],
|
||||
resetAt: [
|
||||
"$.rate_limit.primary_window.reset_at",
|
||||
"$.rate_limit.primary_window.resets_at",
|
||||
"$.rate_limits.primary.resets_at"
|
||||
],
|
||||
unit: "%",
|
||||
used: [
|
||||
"$.rate_limit.primary_window.used_percent",
|
||||
"$.rate_limits.primary.used_percent"
|
||||
],
|
||||
window: "primary"
|
||||
},
|
||||
{
|
||||
id: "codex_manual_resets",
|
||||
kind: "requests",
|
||||
label: "Manual resets",
|
||||
remaining: [
|
||||
"$.resetsAvailable",
|
||||
"$.availableRateLimitResetCount",
|
||||
"$.rate_limit_reset_credits.available_count",
|
||||
"$.rate_limit.resetsAvailable",
|
||||
"$.rate_limits.resetsAvailable",
|
||||
"$.rate_limit.manual_resets.remaining",
|
||||
"$.rate_limit.manual_resets.resetsAvailable",
|
||||
"$.rate_limit.manual_reset.remaining",
|
||||
"$.rate_limit.manual_reset.resetsAvailable",
|
||||
"$.rate_limits.manual_resets.remaining",
|
||||
"$.rate_limits.manual_resets.resetsAvailable",
|
||||
"$.rate_limits.manual_reset.remaining",
|
||||
"$.rate_limits.manual_reset.resetsAvailable",
|
||||
"$.manual_resets.remaining",
|
||||
"$.manual_resets.resetsAvailable",
|
||||
"$.manual_reset.remaining",
|
||||
"$.manual_reset.resetsAvailable",
|
||||
"$.resets.remaining",
|
||||
"$.resets.resetsAvailable",
|
||||
"$.rate_limit.manual_resets.available",
|
||||
"$.rate_limit.manual_reset.available",
|
||||
"$.rate_limit.resets.available",
|
||||
"$.rate_limits.resets.available",
|
||||
"$.manual_resets.available",
|
||||
"$.manual_reset.available",
|
||||
"$.resets.available",
|
||||
0
|
||||
],
|
||||
resetAt: [
|
||||
"$.resetExpires",
|
||||
"$.expires_at",
|
||||
"$.resets_at",
|
||||
"$.rate_limit.manual_resets.expires_at",
|
||||
"$.rate_limit.manual_resets.expire_at",
|
||||
"$.rate_limit.manual_resets.reset_at",
|
||||
"$.rate_limit.manual_resets.resets_at",
|
||||
"$.rate_limit.manual_reset.expires_at",
|
||||
"$.rate_limit.manual_reset.expire_at",
|
||||
"$.rate_limit.manual_reset.reset_at",
|
||||
"$.rate_limit.manual_reset.resets_at",
|
||||
"$.rate_limits.manual_resets.expires_at",
|
||||
"$.rate_limits.manual_resets.expire_at",
|
||||
"$.rate_limits.manual_resets.reset_at",
|
||||
"$.rate_limits.manual_resets.resets_at",
|
||||
"$.rate_limits.manual_reset.expires_at",
|
||||
"$.rate_limits.manual_reset.expire_at",
|
||||
"$.rate_limits.manual_reset.reset_at",
|
||||
"$.rate_limits.manual_reset.resets_at",
|
||||
"$.rate_limit.resets.expires_at",
|
||||
"$.rate_limit.resets.expire_at",
|
||||
"$.rate_limit.resets.reset_at",
|
||||
"$.rate_limit.resets.resets_at",
|
||||
"$.rate_limits.resets.expires_at",
|
||||
"$.rate_limits.resets.expire_at",
|
||||
"$.rate_limits.resets.reset_at",
|
||||
"$.rate_limits.resets.resets_at",
|
||||
"$.manual_resets.expires_at",
|
||||
"$.manual_resets.expire_at",
|
||||
"$.manual_resets.reset_at",
|
||||
"$.manual_resets.resets_at",
|
||||
"$.manual_reset.expires_at",
|
||||
"$.manual_reset.expire_at",
|
||||
"$.manual_reset.reset_at",
|
||||
"$.manual_reset.resets_at",
|
||||
"$.resets.expires_at",
|
||||
"$.resets.expire_at",
|
||||
"$.resets.reset_at",
|
||||
"$.resets.resets_at"
|
||||
],
|
||||
unit: "resets",
|
||||
used: [
|
||||
"$.rate_limit.manual_resets.used",
|
||||
"$.rate_limit.manual_reset.used",
|
||||
"$.rate_limits.manual_resets.used",
|
||||
"$.manual_resets.used",
|
||||
"$.manual_reset.used",
|
||||
"$.resets.used"
|
||||
],
|
||||
window: "manual-reset"
|
||||
},
|
||||
{
|
||||
id: "codex_secondary_quota",
|
||||
kind: "quota",
|
||||
label: "Secondary quota",
|
||||
limit: 100,
|
||||
remaining: [
|
||||
"100 - $.rate_limit.secondary_window.used_percent",
|
||||
"100 - $.rate_limits.secondary.used_percent"
|
||||
],
|
||||
resetAt: [
|
||||
"$.rate_limit.secondary_window.reset_at",
|
||||
"$.rate_limit.secondary_window.resets_at",
|
||||
"$.rate_limits.secondary.resets_at"
|
||||
],
|
||||
unit: "%",
|
||||
used: [
|
||||
"$.rate_limit.secondary_window.used_percent",
|
||||
"$.rate_limits.secondary.used_percent"
|
||||
],
|
||||
window: "secondary"
|
||||
},
|
||||
{
|
||||
id: "codex_individual_limit",
|
||||
kind: "quota",
|
||||
label: "Individual limit",
|
||||
limit: "$.spend_control.individual_limit.limit",
|
||||
remaining: "$.spend_control.individual_limit.remaining",
|
||||
resetAt: "$.spend_control.individual_limit.reset_at",
|
||||
unit: "credits",
|
||||
used: "$.spend_control.individual_limit.used",
|
||||
window: "monthly"
|
||||
},
|
||||
{
|
||||
id: "codex_credit_balance",
|
||||
kind: "balance",
|
||||
label: "Credit balance",
|
||||
remaining: "$.credits.balance",
|
||||
unit: "credits"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const codexAccountRateLimitResetCreditsMapping: ProviderAccountMappingConfig = {
|
||||
meters: [
|
||||
{
|
||||
id: "codex_manual_resets",
|
||||
kind: "requests",
|
||||
label: "Manual resets",
|
||||
remaining: [
|
||||
"$.available_count",
|
||||
"$.rate_limit_reset_credits.available_count",
|
||||
0
|
||||
],
|
||||
resetAt: [
|
||||
"$.expires_at",
|
||||
"$.expiresAt",
|
||||
"$.reset_at",
|
||||
"$.resetAt"
|
||||
],
|
||||
unit: "resets",
|
||||
window: "manual-reset"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const codexAccountTokenUsageMapping: ProviderAccountMappingConfig = {
|
||||
meters: [
|
||||
{
|
||||
id: "codex_lifetime_tokens",
|
||||
kind: "tokens",
|
||||
label: "Lifetime tokens",
|
||||
unit: "tokens",
|
||||
used: "$.stats.lifetime_tokens"
|
||||
},
|
||||
{
|
||||
id: "codex_peak_daily_tokens",
|
||||
kind: "tokens",
|
||||
label: "Peak daily tokens",
|
||||
unit: "tokens",
|
||||
used: "$.stats.peak_daily_tokens",
|
||||
window: "daily"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function codexCandidate(): LocalAgentProviderCandidate {
|
||||
const auth = readCodexAuth();
|
||||
const catalog = readCodexModelCatalog();
|
||||
if (auth?.refreshToken || auth?.accessToken) {
|
||||
return {
|
||||
detail: "ChatGPT login detected. Click Import to add it as a gateway provider.",
|
||||
id: "codex-api",
|
||||
importable: true,
|
||||
kind: "codex",
|
||||
modelDisplayNames: catalog.modelDisplayNames,
|
||||
models: catalog.models,
|
||||
name: "Codex API",
|
||||
protocol: "openai_responses",
|
||||
sourceFile: auth.sourceFile,
|
||||
status: "available"
|
||||
};
|
||||
}
|
||||
return missingCandidate("codex", "codex-api", "Codex API", "openai_responses", catalog.models, catalog.modelDisplayNames);
|
||||
}
|
||||
|
||||
export function importCodexProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
|
||||
const auth = readCodexAuth();
|
||||
if (!auth?.refreshToken && !auth?.accessToken) {
|
||||
throw new Error("Codex login token was not found.");
|
||||
}
|
||||
const provider = providerPayload(candidate, uniqueProviderName(providerNames, "Codex API"), codexDefaultBaseUrl, codexProviderAccountConfig());
|
||||
return {
|
||||
candidate,
|
||||
provider,
|
||||
providerPlugins: [
|
||||
codexOauthPlugin("codex-oauth"),
|
||||
codexOauthPlugin("codex-oauth-internal", providerInternalNamePlaceholder)
|
||||
].map((plugin) => ({
|
||||
...plugin,
|
||||
...(auth.isFedrampAccount ? { auth: { headers: { "X-OpenAI-Fedramp": "true" } } } : {}),
|
||||
codexOauth: {
|
||||
accessToken: auth.accessToken,
|
||||
...(auth.accountId ? { accountId: auth.accountId } : {}),
|
||||
refreshIfMissingAccessToken: true,
|
||||
refreshToken: auth.refreshToken,
|
||||
required: true
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function readCodexAuth(): OAuthTokenSet | undefined {
|
||||
const sourceFile = path.join(os.homedir(), ".codex", "auth.json");
|
||||
const record = readJsonRecord(sourceFile);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const tokens = isRecord(record.tokens) ? record.tokens : {};
|
||||
const idToken = readString(tokens.id_token) || readString(tokens.idToken);
|
||||
const idTokenClaims = readCodexIdTokenClaims(idToken);
|
||||
return {
|
||||
accountId:
|
||||
readString(tokens.account_id) ||
|
||||
readString(tokens.accountId) ||
|
||||
idTokenClaims.accountId,
|
||||
accessToken: readString(tokens.access_token) || readString(tokens.accessToken),
|
||||
isFedrampAccount: idTokenClaims.isFedrampAccount,
|
||||
refreshToken: readString(tokens.refresh_token) || readString(tokens.refreshToken),
|
||||
sourceFile
|
||||
};
|
||||
}
|
||||
|
||||
export function codexProviderAccountConfig(): ProviderAccountConfig {
|
||||
return {
|
||||
connectors: [
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
endpoint: `${codexAccountBaseUrl}/wham/usage`,
|
||||
headers: {
|
||||
"User-Agent": "codex-cli"
|
||||
},
|
||||
mapping: codexAccountRateLimitMapping,
|
||||
type: "http-json"
|
||||
},
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
endpoint: `${codexAccountBaseUrl}/wham/rate-limit-reset-credits`,
|
||||
headers: {
|
||||
"User-Agent": "codex-cli"
|
||||
},
|
||||
mapping: codexAccountRateLimitResetCreditsMapping,
|
||||
type: "http-json"
|
||||
},
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
endpoint: `${codexAccountBaseUrl}/wham/profiles/me`,
|
||||
headers: {
|
||||
"User-Agent": "codex-cli"
|
||||
},
|
||||
mapping: codexAccountTokenUsageMapping,
|
||||
type: "http-json"
|
||||
}
|
||||
],
|
||||
enabled: true
|
||||
};
|
||||
}
|
||||
|
||||
export function attachCodexRateLimitResetCreditDetails(meters: ProviderAccountMeter[], payload: unknown): ProviderAccountMeter[] {
|
||||
const details = codexRateLimitResetCreditDetails(payload);
|
||||
if (details.length === 0) {
|
||||
return meters;
|
||||
}
|
||||
const resetAt = firstCodexResetCreditExpiry(details);
|
||||
return meters.map((meter) => {
|
||||
if (!isCodexManualResetMeter(meter)) {
|
||||
return meter;
|
||||
}
|
||||
return {
|
||||
...meter,
|
||||
details,
|
||||
resetAt: meter.resetAt ?? resetAt
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function codexRateLimitResetCreditDetails(payload: unknown): ProviderAccountMeterDetail[] {
|
||||
const records = codexRateLimitResetCreditRecords(payload);
|
||||
if (records.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const availableRecords = records.filter(isAvailableCodexResetCreditRecord);
|
||||
const sourceRecords = availableRecords.length > 0 ? availableRecords : records;
|
||||
return sourceRecords
|
||||
.map(codexRateLimitResetCreditDetail)
|
||||
.filter((detail): detail is ProviderAccountMeterDetail => Boolean(detail))
|
||||
.sort(compareCodexResetCreditDetails);
|
||||
}
|
||||
|
||||
export function normalizeCodexProviderAccountConfig(provider: GatewayProviderConfig): GatewayProviderConfig {
|
||||
if (!isLocalCodexProvider(provider) || !shouldUseCurrentCodexAccountConfig(provider.account)) {
|
||||
return provider;
|
||||
}
|
||||
const account = codexProviderAccountConfig();
|
||||
return {
|
||||
...provider,
|
||||
account: {
|
||||
...account,
|
||||
refreshIntervalMs: provider.account?.refreshIntervalMs ?? account.refreshIntervalMs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalCodexProvider(provider: GatewayProviderConfig): boolean {
|
||||
return (
|
||||
providerApiKey(provider) === localAgentProviderApiKey &&
|
||||
normalizeProviderBaseUrl(providerBaseUrl(provider)) === normalizeProviderBaseUrl(codexDefaultBaseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseCurrentCodexAccountConfig(account: ProviderAccountConfig | undefined): boolean {
|
||||
if (account?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const connectors = account?.connectors ?? [];
|
||||
if (connectors.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return connectors.every(isCodexAccountConnector);
|
||||
}
|
||||
|
||||
function isCodexAccountConnector(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:\/\/chatgpt\.com\/backend-api\/wham\//i.test(connector.endpoint.trim());
|
||||
}
|
||||
|
||||
function codexRateLimitResetCreditRecords(payload: unknown): Record<string, unknown>[] {
|
||||
if (!isRecord(payload)) {
|
||||
return [];
|
||||
}
|
||||
const containers = [
|
||||
payload.rate_limit_reset_credits,
|
||||
payload.rateLimitResetCredits,
|
||||
payload
|
||||
].filter(isRecord);
|
||||
for (const container of containers) {
|
||||
const candidates = [
|
||||
container.credits,
|
||||
container.items,
|
||||
container.data,
|
||||
container.available,
|
||||
container.available_credits,
|
||||
container.availableCredits,
|
||||
container.reset_credits,
|
||||
container.resetCredits
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const records = readCodexResetCreditRecordArray(candidate);
|
||||
if (records.length > 0) {
|
||||
return records;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function readCodexResetCreditRecordArray(value: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(isRecord);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
const nested = [value.credits, value.items, value.data];
|
||||
for (const candidate of nested) {
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate.filter(isRecord);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isAvailableCodexResetCreditRecord(record: Record<string, unknown>): boolean {
|
||||
const status = readCodexStringFromKeys(record, ["status", "state"])?.toLowerCase();
|
||||
return !status || status === "available" || status === "active";
|
||||
}
|
||||
|
||||
function codexRateLimitResetCreditDetail(record: Record<string, unknown>, index: number): ProviderAccountMeterDetail | undefined {
|
||||
const id = readCodexStringFromKeys(record, ["id", "credit_id", "creditId"]);
|
||||
const status = readCodexStringFromKeys(record, ["status", "state"]);
|
||||
const effectiveAt = readCodexDateFromKeys(record, [
|
||||
"effective_at",
|
||||
"effectiveAt",
|
||||
"start_date",
|
||||
"startDate",
|
||||
"valid_from",
|
||||
"validFrom",
|
||||
"starts_at",
|
||||
"startsAt",
|
||||
"start_at",
|
||||
"startAt",
|
||||
"available_at",
|
||||
"availableAt",
|
||||
"granted_at",
|
||||
"grantedAt",
|
||||
"created_at",
|
||||
"createdAt"
|
||||
]);
|
||||
const expiresAt = readCodexDateFromKeys(record, [
|
||||
"expires_at",
|
||||
"expiresAt",
|
||||
"expire_at",
|
||||
"expireAt",
|
||||
"expiration_at",
|
||||
"expirationAt",
|
||||
"valid_until",
|
||||
"validUntil",
|
||||
"end_date",
|
||||
"endDate",
|
||||
"ends_at",
|
||||
"endsAt",
|
||||
"end_at",
|
||||
"endAt"
|
||||
]);
|
||||
if (!effectiveAt && !expiresAt) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
description: readCodexStringFromKeys(record, ["description", "message"]),
|
||||
effectiveAt,
|
||||
expiresAt,
|
||||
id: id ?? `codex-reset-credit-${index + 1}`,
|
||||
label: readCodexStringFromKeys(record, ["label", "name", "title"]),
|
||||
redeemable: Boolean(id) && isAvailableCodexResetCreditRecord(record),
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexStringFromKeys(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = readString(record[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readCodexDateFromKeys(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = codexDateString(record[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function codexDateString(value: unknown): string | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const timestamp = value > 1_000_000_000_000 ? value : value * 1000;
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
const text = readString(value);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(text).getTime();
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : text;
|
||||
}
|
||||
|
||||
function compareCodexResetCreditDetails(a: ProviderAccountMeterDetail, b: ProviderAccountMeterDetail): number {
|
||||
return codexDetailTimestamp(a.expiresAt) - codexDetailTimestamp(b.expiresAt)
|
||||
|| codexDetailTimestamp(a.effectiveAt) - codexDetailTimestamp(b.effectiveAt);
|
||||
}
|
||||
|
||||
function codexDetailTimestamp(value: string | undefined): number {
|
||||
if (!value) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function firstCodexResetCreditExpiry(details: ProviderAccountMeterDetail[]): string | undefined {
|
||||
return [...details]
|
||||
.sort(compareCodexResetCreditDetails)
|
||||
.find((detail) => detail.expiresAt)
|
||||
?.expiresAt;
|
||||
}
|
||||
|
||||
function isCodexManualResetMeter(meter: ProviderAccountMeter): boolean {
|
||||
const text = `${meter.id} ${meter.label} ${meter.window ?? ""}`.toLowerCase();
|
||||
return text.includes("manual_reset") || text.includes("manual reset") || text.includes("manual-reset");
|
||||
}
|
||||
|
||||
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 codexOauthPlugin(suffix: string, providerName = providerNamePlaceholder): Record<string, unknown> {
|
||||
return {
|
||||
key: `ccr-local-agent-${providerNameSlugPlaceholder}-${suffix}`,
|
||||
providerName,
|
||||
request: codexBackendRequestTransform()
|
||||
};
|
||||
}
|
||||
|
||||
function codexBackendRequestTransform(): Record<string, unknown> {
|
||||
return {
|
||||
bodyRemove: ["max_output_tokens"]
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexModelCatalog(): LocalAgentModelCatalog {
|
||||
const modelsFile = path.join(os.homedir(), ".codex", "models_cache.json");
|
||||
const record = readJsonRecord(modelsFile);
|
||||
const models: string[] = [];
|
||||
const modelDisplayNames: Record<string, string> = {};
|
||||
for (const item of Array.isArray(record?.models) ? record.models : []) {
|
||||
const model = isRecord(item)
|
||||
? readString(item.slug) || readString(item.id) || readString(item.name)
|
||||
: readString(item);
|
||||
if (!model) {
|
||||
continue;
|
||||
}
|
||||
models.push(model);
|
||||
if (isRecord(item)) {
|
||||
const displayName = readString(item.display_name) || readString(item.displayName) || readString(item.label) || readString(item.name);
|
||||
if (displayName && displayName !== model) {
|
||||
modelDisplayNames[model] = displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
const uniqueModels = uniqueStrings([...models, ...codexDefaultModels]);
|
||||
return {
|
||||
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels),
|
||||
models: uniqueModels
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexIdTokenClaims(idToken: string | undefined): { accountId?: string; isFedrampAccount?: boolean } {
|
||||
const payload = readJwtPayload(idToken);
|
||||
const auth = isRecord(payload?.["https://api.openai.com/auth"])
|
||||
? payload["https://api.openai.com/auth"]
|
||||
: {};
|
||||
return {
|
||||
accountId: readString(auth.chatgpt_account_id) || readString(auth.account_id) || readString(auth.accountId),
|
||||
isFedrampAccount: readBoolean(auth.chatgpt_account_is_fedramp)
|
||||
};
|
||||
}
|
||||
|
||||
function readJwtPayload(jwt: string | undefined): Record<string, unknown> | undefined {
|
||||
const encoded = jwt?.split(".")[1];
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "=");
|
||||
const decoded = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
||||
const payload = JSON.parse(decoded) as unknown;
|
||||
return isRecord(payload) ? payload : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,13 @@ import type {
|
|||
LocalAgentProviderCandidate,
|
||||
LocalAgentProviderImportRequest,
|
||||
LocalAgentProviderImportResult
|
||||
} from "../shared/app";
|
||||
import { claudeCodeCandidate, importClaudeCodeProvider } from "./local-agent-providers/claude-code";
|
||||
import { codexCandidate, importCodexProvider } from "./local-agent-providers/codex";
|
||||
import { importZcodeProvider, zcodeCandidate } from "./local-agent-providers/zcode";
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code";
|
||||
import { codexCandidate, importCodexProvider } from "@ccr/core/agents/local-providers/codex";
|
||||
import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode";
|
||||
|
||||
export { codexDefaultBaseUrl, readCodexAuth } from "./local-agent-providers/codex";
|
||||
export { localAgentProviderApiKey, type OAuthTokenSet } from "./local-agent-providers/shared";
|
||||
export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex";
|
||||
export { localAgentProviderApiKey, type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared";
|
||||
|
||||
export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[] {
|
||||
return [
|
||||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
LocalAgentProviderKind,
|
||||
ProviderAccountConfig,
|
||||
ProviderDeepLinkPayload
|
||||
} from "../../shared/app";
|
||||
} from "@ccr/core/contracts/app";
|
||||
|
||||
export type OAuthTokenSet = {
|
||||
accountId?: string;
|
||||
|
|
@ -4,8 +4,8 @@ import type {
|
|||
LocalAgentProviderCandidate,
|
||||
LocalAgentProviderImportResult,
|
||||
ProviderAccountConfig
|
||||
} from "../../shared/app";
|
||||
import { findProviderPresetByBaseUrl } from "../presets";
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { findProviderPresetByBaseUrl } from "@ccr/core/providers/presets/index";
|
||||
import {
|
||||
apiKeyAuthPlugin,
|
||||
cloneProviderAccountConfig,
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
uniqueProviderName,
|
||||
uniqueStrings,
|
||||
type ApiTokenSet
|
||||
} from "./shared";
|
||||
} from "@ccr/core/agents/local-providers/shared";
|
||||
|
||||
type ZcodeConfiguredProvider = {
|
||||
apiKey: string;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig } from "../shared/app";
|
||||
import { normalizeRouteSelector } from "../server/gateway/claude-code-router-plugin";
|
||||
import { buildCodexModelCatalogIds } from "./codex-model-catalog";
|
||||
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog";
|
||||
|
||||
export type ZcodeProfileConfigWriteResult = {
|
||||
backupFile?: string;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { API_KEYS_DB_FILE, LEGACY_API_KEYS_DB_FILES } from "./constants";
|
||||
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native";
|
||||
import type { ApiKeyConfig, ApiKeyLimitConfig } from "../shared/app";
|
||||
import { API_KEYS_DB_FILE, LEGACY_API_KEYS_DB_FILES } from "@ccr/core/config/constants";
|
||||
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native";
|
||||
import type { ApiKeyConfig, ApiKeyLimitConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
type SqlDatabase = BetterSqliteDatabase;
|
||||
type SqlValue = bigint | Buffer | number | string | null;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { APP_CONFIG_DB_FILE, LEGACY_APP_CONFIG_DB_FILES } from "./constants";
|
||||
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native";
|
||||
import { APP_CONFIG_DB_FILE, LEGACY_APP_CONFIG_DB_FILES } from "@ccr/core/config/constants";
|
||||
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native";
|
||||
|
||||
type SqlDatabase = BetterSqliteDatabase;
|
||||
type SqlValue = bigint | Buffer | number | string | null;
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config-store";
|
||||
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
|
||||
import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "./constants";
|
||||
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 "../shared/app";
|
||||
import { createDefaultAppConfig } from "../shared/default-config";
|
||||
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "./presets";
|
||||
import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/config/app-config-store";
|
||||
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 { 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";
|
||||
import type {
|
||||
AppConfig,
|
||||
ApiKeyConfig,
|
||||
|
|
@ -49,11 +50,12 @@ import type {
|
|||
TrayBalanceProgressConfig,
|
||||
TrayComponentVariants,
|
||||
TrayIconPreference,
|
||||
ToolHubConfig,
|
||||
TrayWidgetConfig,
|
||||
TrayWidgetType,
|
||||
TrayWidgetVariant,
|
||||
TrayWindowModuleId
|
||||
} from "../shared/app";
|
||||
} from "@ccr/core/contracts/app";
|
||||
|
||||
type LoadedProfileConfig = Partial<Omit<ProfileRuntimeConfig, "claudeCode" | "codex" | "profiles">> & {
|
||||
claudeCode?: Partial<ClaudeCodeProfileConfig>;
|
||||
|
|
@ -65,7 +67,7 @@ type LoadedBotGatewayConfig = Partial<Omit<BotGatewayRuntimeConfig, "handoff">>
|
|||
handoff?: Partial<BotGatewayRuntimeConfig["handoff"]>;
|
||||
};
|
||||
|
||||
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "gateway" | "observability" | "profile" | "proxy">> & {
|
||||
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "gateway" | "observability" | "profile" | "proxy" | "toolHub">> & {
|
||||
Router?: Partial<RouterConfig>;
|
||||
agent?: Partial<GatewayAgentConfig>;
|
||||
botConfigs?: BotGatewaySavedConfig[];
|
||||
|
|
@ -74,9 +76,10 @@ type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway"
|
|||
observability?: Partial<ObservabilityConfig>;
|
||||
profile?: LoadedProfileConfig;
|
||||
proxy?: Partial<ProxyRuntimeConfig>;
|
||||
toolHub?: Partial<ToolHubConfig>;
|
||||
};
|
||||
|
||||
type RawAppConfigSource = "default" | "legacy-json" | "sqlite";
|
||||
export type RawAppConfigSource = "default" | "legacy-json" | "sqlite";
|
||||
|
||||
type RawAppConfigLoadResult = {
|
||||
source: RawAppConfigSource;
|
||||
|
|
@ -208,7 +211,7 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
|||
try {
|
||||
const loadedRawConfig = await loadRawAppConfig();
|
||||
const rawValue = loadedRawConfig.value;
|
||||
const value = interpolateEnvVars(rawValue) as Partial<AppConfig>;
|
||||
const value = interpolateRawAppConfigEnvVars(rawValue, loadedRawConfig.source) as Partial<AppConfig>;
|
||||
const picked = pickConfig(value);
|
||||
const providers = picked.Providers ?? DEFAULT_CONFIG.Providers;
|
||||
const port = picked.PORT ?? endpointPort(picked.routerEndpoint) ?? DEFAULT_CONFIG.PORT;
|
||||
|
|
@ -273,7 +276,16 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
|||
...(picked.proxy ?? {}),
|
||||
targets: picked.proxy?.targets?.length ? picked.proxy.targets : DEFAULT_CONFIG.proxy.targets
|
||||
},
|
||||
routerEndpoint: endpoint
|
||||
routerEndpoint: endpoint,
|
||||
toolHub: {
|
||||
...DEFAULT_CONFIG.toolHub,
|
||||
...(picked.toolHub ?? {}),
|
||||
llm: {
|
||||
...DEFAULT_CONFIG.toolHub.llm,
|
||||
...(picked.toolHub?.llm ?? {})
|
||||
},
|
||||
mcpServers: picked.toolHub?.mcpServers ?? DEFAULT_CONFIG.toolHub.mcpServers
|
||||
}
|
||||
});
|
||||
const shouldPersistApiKeys = loadedApiKeys.length === 0 || hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0;
|
||||
if (shouldPersistApiKeys) {
|
||||
|
|
@ -579,6 +591,10 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
|||
if (typeof value.autoStart === "boolean") {
|
||||
config.autoStart = value.autoStart;
|
||||
}
|
||||
const launchAtLogin = (value as Record<string, unknown>).launchAtLogin;
|
||||
if (typeof launchAtLogin === "boolean") {
|
||||
config.launchAtLogin = launchAtLogin;
|
||||
}
|
||||
if (isObject(value.gateway)) {
|
||||
const gateway = value.gateway as Record<string, unknown>;
|
||||
const gatewayConfig: Partial<AppConfig["gateway"]> = {};
|
||||
|
|
@ -610,6 +626,10 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
|||
if (observability) {
|
||||
config.observability = observability;
|
||||
}
|
||||
const toolHub = parseToolHub((value as Record<string, unknown>).toolHub ?? (value as Record<string, unknown>).tool_hub);
|
||||
if (toolHub) {
|
||||
config.toolHub = toolHub;
|
||||
}
|
||||
if (typeof value.preferredProvider === "string" && value.preferredProvider.trim()) {
|
||||
config.preferredProvider = value.preferredProvider.trim();
|
||||
}
|
||||
|
|
@ -671,6 +691,53 @@ function parseObservability(value: unknown): Partial<ObservabilityConfig> | unde
|
|||
return Object.keys(observability).length ? observability : undefined;
|
||||
}
|
||||
|
||||
function parseToolHub(value: unknown): Partial<ToolHubConfig> | undefined {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const toolHub: Partial<ToolHubConfig> = {};
|
||||
if (typeof value.enabled === "boolean") {
|
||||
toolHub.enabled = value.enabled;
|
||||
}
|
||||
const browserAutomation = value.browserAutomation ?? value.browser_automation;
|
||||
if (typeof browserAutomation === "boolean") {
|
||||
toolHub.browserAutomation = browserAutomation;
|
||||
}
|
||||
const maxTools = readNumber(value.maxTools ?? value.max_tools);
|
||||
if (maxTools !== undefined) {
|
||||
toolHub.maxTools = clampNumber(maxTools, 1, 20);
|
||||
}
|
||||
const requestTimeoutMs = readNumber(value.requestTimeoutMs ?? value.request_timeout_ms);
|
||||
if (requestTimeoutMs !== undefined) {
|
||||
toolHub.requestTimeoutMs = clampNumber(requestTimeoutMs, 8000, 300000);
|
||||
}
|
||||
const mcpServers = parseMcpServers(value.mcpServers ?? value.mcp_servers);
|
||||
if (mcpServers) {
|
||||
toolHub.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
const rawLlm = isObject(value.llm) ? value.llm : value;
|
||||
const llm: Partial<ToolHubConfig["llm"]> = {};
|
||||
const apiKey = readString(rawLlm.apiKey) || readString(rawLlm.api_key);
|
||||
if (apiKey !== undefined) {
|
||||
llm.apiKey = apiKey;
|
||||
}
|
||||
const baseUrl = readString(rawLlm.baseUrl) || readString(rawLlm.base_url);
|
||||
if (baseUrl !== undefined) {
|
||||
llm.baseUrl = baseUrl;
|
||||
}
|
||||
const model = readString(rawLlm.model);
|
||||
if (model !== undefined) {
|
||||
llm.model = model;
|
||||
}
|
||||
if (Object.keys(llm).length > 0) {
|
||||
toolHub.llm = llm as ToolHubConfig["llm"];
|
||||
}
|
||||
|
||||
return Object.keys(toolHub).length ? toolHub : undefined;
|
||||
}
|
||||
|
||||
function parseOverviewWidgets(value: unknown): OverviewWidgetConfig[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
|
|
@ -996,7 +1063,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
|
|||
transformer: item.transformer,
|
||||
type: readString(item.type)
|
||||
};
|
||||
return provider;
|
||||
return normalizeCodexProviderAccountConfig(provider);
|
||||
})
|
||||
.filter((item): item is GatewayProviderConfig => Boolean(item));
|
||||
|
||||
|
|
@ -1181,6 +1248,16 @@ function parseProviderCapabilityProtocol(value: string | undefined): GatewayProv
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1722,7 +1799,7 @@ function parseMcpServers(value: unknown): GatewayMcpServerConfig[] | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const transport = parseMcpServerTransport(item.transport);
|
||||
const transport = parseMcpServerTransport(item.transport ?? item.type);
|
||||
const name = readString(item.name) || (transport !== "stdio" ? readString(item.url) : readString(item.command)) || `mcp-${index + 1}`;
|
||||
const protocolVersion = readString(item.protocolVersion) || "2024-11-05";
|
||||
const startupTimeoutMs = clampNumber(readNumber(item.startupTimeoutMs) ?? 600000, 100, 600000);
|
||||
|
|
@ -1777,7 +1854,7 @@ function parseMcpServerTransport(value: unknown): GatewayMcpServerTransport {
|
|||
if (normalized === "sse") {
|
||||
return "sse";
|
||||
}
|
||||
if (normalized === "streamable-http" || normalized === "streamble-http" || normalized === "websocket") {
|
||||
if (normalized === "http" || normalized === "streamable-http" || normalized === "streamablehttp" || normalized === "streamble-http" || normalized === "websocket") {
|
||||
return "streamable-http";
|
||||
}
|
||||
return "stdio";
|
||||
|
|
@ -2474,6 +2551,10 @@ function isDefaultSeedApiKey(apiKey: ApiKeyConfig): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
export function interpolateRawAppConfigEnvVars(value: unknown, source: RawAppConfigSource): unknown {
|
||||
return source === "legacy-json" ? interpolateEnvVars(value) : value;
|
||||
}
|
||||
|
||||
function interpolateEnvVars(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => {
|
||||
|
|
@ -1,20 +1,18 @@
|
|||
import path from "node:path";
|
||||
import { APP_NAME, APP_STORAGE_NAME, LEGACY_CONFIGDIR, resolveRuntimeAppPath } from "./app-paths";
|
||||
import { copyMissingDirectoryContents } from "./storage-migration";
|
||||
import { APP_NAME, APP_STORAGE_NAME, LEGACY_CONFIGDIR, resolveRuntimeAppPath, resolveRuntimeConfigDir, resolveRuntimeDataDir } from "@ccr/core/runtime/app-paths";
|
||||
import { copyMissingDirectoryContents } from "@ccr/core/storage/migration";
|
||||
|
||||
export { IPC_CHANNELS } from "../shared/ipc-channels";
|
||||
export { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels";
|
||||
export const LEGACY_CONFIG_FILE = path.join(LEGACY_CONFIGDIR, "config.json");
|
||||
|
||||
export { APP_NAME, APP_STORAGE_NAME, LEGACY_CONFIGDIR };
|
||||
|
||||
export const CONFIGDIR = process.platform === "win32"
|
||||
? path.join(resolveRuntimeAppPath("appData"), APP_STORAGE_NAME)
|
||||
: LEGACY_CONFIGDIR;
|
||||
export const CONFIGDIR = resolveRuntimeConfigDir();
|
||||
export const LEGACY_WINDOWS_CONFIGDIR = path.join(resolveRuntimeAppPath("appData"), APP_NAME);
|
||||
export const LEGACY_WINDOWS_CONFIG_FILE = path.join(LEGACY_WINDOWS_CONFIGDIR, "config.json");
|
||||
export const CONFIG_FILE = path.join(CONFIGDIR, "config.json");
|
||||
export const ONBOARDING_FINISHED_FILE = path.join(CONFIGDIR, ".onboard_finished");
|
||||
export const DATADIR = resolveRuntimeAppPath("userData");
|
||||
export const DATADIR = resolveRuntimeDataDir();
|
||||
export const APP_CONFIG_DB_FILE = path.join(CONFIGDIR, "config.sqlite");
|
||||
export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite");
|
||||
export const LEGACY_APP_CONFIG_DB_FILES = process.platform === "win32" ? [path.join(LEGACY_WINDOWS_CONFIGDIR, "config.sqlite")] : [];
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
DEFAULT_TRAY_WINDOW_MODULES,
|
||||
type AppConfig,
|
||||
type ProxyRouteTarget
|
||||
} from "./app";
|
||||
} from "@ccr/core/contracts/app";
|
||||
|
||||
export const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [
|
||||
{ host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] },
|
||||
|
|
@ -90,6 +90,7 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
|
|||
host: "127.0.0.1",
|
||||
port: 3456
|
||||
},
|
||||
launchAtLogin: false,
|
||||
observability: {
|
||||
agentAnalysis: false,
|
||||
requestLogs: false
|
||||
|
|
@ -168,6 +169,18 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
|
|||
trayProgressTargetTokens: 100000,
|
||||
trayWidgets: DEFAULT_TRAY_WIDGETS,
|
||||
trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES,
|
||||
toolHub: {
|
||||
browserAutomation: false,
|
||||
enabled: false,
|
||||
llm: {
|
||||
apiKey: "",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: ""
|
||||
},
|
||||
mcpServers: [],
|
||||
maxTools: 10,
|
||||
requestTimeoutMs: 60000
|
||||
},
|
||||
virtualModelProfiles: []
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export type AppInfo = {
|
|||
configFile: string;
|
||||
dataDir: string;
|
||||
gatewayConfigFile: string;
|
||||
launchAtLoginSupported: boolean;
|
||||
requestLogsDbFile: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
|
|
@ -112,7 +113,8 @@ export type GatewayProviderProtocol =
|
|||
| "openai_responses"
|
||||
| "openai_chat_completions"
|
||||
| "anthropic_messages"
|
||||
| "gemini_generate_content";
|
||||
| "gemini_generate_content"
|
||||
| "gemini_interactions";
|
||||
|
||||
export type GatewayProviderConfig = {
|
||||
account?: ProviderAccountConfig;
|
||||
|
|
@ -158,6 +160,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 ProviderAccountConfig = {
|
||||
connectors?: ProviderAccountConnectorConfig[];
|
||||
|
|
@ -191,7 +194,7 @@ export type ProviderAccountHttpJsonConnectorConfig = ProviderAccountConnectorBas
|
|||
headers?: Record<string, string>;
|
||||
mapping: ProviderAccountMappingConfig;
|
||||
method?: "GET" | "POST";
|
||||
parser?: "kimi-code-usages";
|
||||
parser?: ProviderAccountHttpJsonParser;
|
||||
type: "http-json";
|
||||
};
|
||||
|
||||
|
|
@ -221,19 +224,33 @@ export type ProviderAccountMappingConfig = {
|
|||
status?: string;
|
||||
};
|
||||
|
||||
export type ProviderAccountMappedNumberExpression = number | string | Array<number | string>;
|
||||
export type ProviderAccountMappedStringExpression = string | string[];
|
||||
|
||||
export type ProviderAccountMappedMeterConfig = {
|
||||
id: string;
|
||||
kind?: ProviderAccountMeterKind;
|
||||
label: string;
|
||||
limit?: number | string;
|
||||
remaining?: number | string;
|
||||
resetAt?: string;
|
||||
limit?: ProviderAccountMappedNumberExpression;
|
||||
remaining?: ProviderAccountMappedNumberExpression;
|
||||
resetAt?: ProviderAccountMappedStringExpression;
|
||||
unit?: ProviderAccountMeterUnit;
|
||||
used?: number | string;
|
||||
used?: ProviderAccountMappedNumberExpression;
|
||||
window?: ProviderAccountMeterWindow;
|
||||
};
|
||||
|
||||
export type ProviderAccountMeterDetail = {
|
||||
description?: string;
|
||||
effectiveAt?: string;
|
||||
expiresAt?: string;
|
||||
id?: string;
|
||||
label?: string;
|
||||
redeemable?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type ProviderAccountMeter = {
|
||||
details?: ProviderAccountMeterDetail[];
|
||||
id: string;
|
||||
kind: ProviderAccountMeterKind;
|
||||
label: string;
|
||||
|
|
@ -361,6 +378,18 @@ export type ProviderAccountTestResult = {
|
|||
status?: ProviderAccountStatus;
|
||||
};
|
||||
|
||||
export type ProviderAccountResetRequest = {
|
||||
credentialId?: string;
|
||||
creditId: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export type ProviderAccountResetResult = {
|
||||
code?: string;
|
||||
creditId: string;
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
export type ProviderDeepLinkRequest = {
|
||||
error?: string;
|
||||
id: string;
|
||||
|
|
@ -377,6 +406,8 @@ export type GatewayProviderCapability = {
|
|||
type: GatewayProviderProtocol;
|
||||
};
|
||||
|
||||
export type GatewayProviderDetectedProvider = "new-api";
|
||||
|
||||
export type GatewayProviderProbeRequest = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
|
|
@ -389,6 +420,7 @@ export type GatewayProviderProbeRequest = {
|
|||
|
||||
export type GatewayProviderProbeCandidate = {
|
||||
baseUrl: string;
|
||||
declaredProtocols?: GatewayProviderProtocol[];
|
||||
label?: string;
|
||||
protocols: GatewayProviderProtocol[];
|
||||
source: "custom" | "preset";
|
||||
|
|
@ -417,6 +449,7 @@ export type ProviderIconDetectionResult = {
|
|||
|
||||
export type GatewayProviderProbeProtocolResult = {
|
||||
baseUrl?: string;
|
||||
detectedProvider?: GatewayProviderDetectedProvider;
|
||||
endpoint: string;
|
||||
message: string;
|
||||
protocol: GatewayProviderProtocol;
|
||||
|
|
@ -425,7 +458,9 @@ export type GatewayProviderProbeProtocolResult = {
|
|||
};
|
||||
|
||||
export type GatewayProviderProbeResult = {
|
||||
account?: ProviderAccountConfig;
|
||||
capabilities?: GatewayProviderCapability[];
|
||||
detectedProvider?: GatewayProviderDetectedProvider;
|
||||
detectedProtocol?: GatewayProviderProtocol;
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
modelSource?: "anthropic" | "gemini" | "openai";
|
||||
|
|
@ -607,6 +642,21 @@ export type GatewayAgentConfig = {
|
|||
mcpServers: GatewayMcpServerConfig[];
|
||||
};
|
||||
|
||||
export type ToolHubLlmConfig = {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ToolHubConfig = {
|
||||
browserAutomation: boolean;
|
||||
enabled: boolean;
|
||||
llm: ToolHubLlmConfig;
|
||||
mcpServers: GatewayMcpServerConfig[];
|
||||
maxTools: number;
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
|
||||
export const CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY";
|
||||
export const CLAUDE_CODE_DEFAULT_ENV: Record<string, string> = {
|
||||
[CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV]: "1"
|
||||
|
|
@ -675,6 +725,7 @@ export type VirtualModelFusionVisionConfig = {
|
|||
};
|
||||
|
||||
export type VirtualModelFusionWebSearchProvider =
|
||||
| "browser"
|
||||
| "brave"
|
||||
| "bing"
|
||||
| "google_cse"
|
||||
|
|
@ -1351,6 +1402,7 @@ export type AppConfig = {
|
|||
botConfigs: BotGatewaySavedConfig[];
|
||||
botGateway: BotGatewayRuntimeConfig;
|
||||
gateway: GatewayRuntimeConfig;
|
||||
launchAtLogin: boolean;
|
||||
observability: ObservabilityConfig;
|
||||
preferredProvider: string;
|
||||
plugins: GatewayPluginConfig[];
|
||||
|
|
@ -1366,6 +1418,7 @@ export type AppConfig = {
|
|||
trayIcon: TrayIconPreference;
|
||||
trayWidgets: TrayWidgetConfig[];
|
||||
trayWindowModules: TrayWindowModuleId[];
|
||||
toolHub: ToolHubConfig;
|
||||
virtualModelProfiles?: VirtualModelProfileConfig[];
|
||||
};
|
||||
|
||||
|
|
@ -1423,12 +1476,91 @@ export type BuiltInBrowserTabState = {
|
|||
url: string;
|
||||
};
|
||||
|
||||
export type BuiltInBrowserAutomationHandoffKind =
|
||||
| "blocked"
|
||||
| "human_verification"
|
||||
| "login_required"
|
||||
| "other"
|
||||
| "verification_code";
|
||||
|
||||
export type BuiltInBrowserAutomationHandoff = {
|
||||
id: string;
|
||||
kind: BuiltInBrowserAutomationHandoffKind;
|
||||
message: string;
|
||||
reason?: string;
|
||||
requestedAt: number;
|
||||
sessionId?: string;
|
||||
status: "pending";
|
||||
tabId?: string;
|
||||
};
|
||||
|
||||
export type BuiltInBrowserState = {
|
||||
activeTabId?: string;
|
||||
apps: InstalledBrowserApp[];
|
||||
automationHandoff?: BuiltInBrowserAutomationHandoff;
|
||||
tabs: BuiltInBrowserTabState[];
|
||||
};
|
||||
|
||||
export type ChromeLoginImportTarget = "browser" | "browser-and-web-search";
|
||||
|
||||
export type ChromeLoginImportStatus =
|
||||
| "completed"
|
||||
| "expired"
|
||||
| "failed"
|
||||
| "pending";
|
||||
|
||||
export type ChromeLoginImportRequest = {
|
||||
domains: string[];
|
||||
openConfirmationPage?: boolean;
|
||||
target?: ChromeLoginImportTarget;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportResult = {
|
||||
completedAt: number;
|
||||
cookieImported: number;
|
||||
cookieSkipped: number;
|
||||
domains: string[];
|
||||
errors?: string[];
|
||||
imported: number;
|
||||
localStorageImported: number;
|
||||
localStorageSkipped: number;
|
||||
partitions: string[];
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportJob = {
|
||||
confirmUrl: string;
|
||||
createdAt: number;
|
||||
domains: string[];
|
||||
endpointUrl: string;
|
||||
expiresAt: number;
|
||||
id: string;
|
||||
importUrl: string;
|
||||
result?: ChromeLoginImportResult;
|
||||
status: ChromeLoginImportStatus;
|
||||
target: ChromeLoginImportTarget;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportCookie = {
|
||||
domain: string;
|
||||
expirationDate?: number;
|
||||
hostOnly?: boolean;
|
||||
httpOnly?: boolean;
|
||||
name: string;
|
||||
partitionKey?: unknown;
|
||||
path?: string;
|
||||
sameSite?: "lax" | "no_restriction" | "strict" | "unspecified";
|
||||
secure?: boolean;
|
||||
session?: boolean;
|
||||
storeId?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ChromeLoginImportLocalStorage = {
|
||||
items: Record<string, string>;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
export type ProxyCertificateInstallResult = {
|
||||
caCertFile: string;
|
||||
manualCommand?: string;
|
||||
|
|
@ -6,8 +6,8 @@ import type {
|
|||
ProviderDeepLinkPayload,
|
||||
ProviderDeepLinkRequest,
|
||||
ProviderManifestDeepLinkPayload
|
||||
} from "./app";
|
||||
import { providerUrlWithDefaultScheme } from "./provider-url";
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
|
||||
|
||||
export const appDeepLinkProtocol = "ccr";
|
||||
export const providerDeepLinkHost = "provider";
|
||||
|
|
@ -26,6 +26,7 @@ const maxModels = 300;
|
|||
const providerProtocols = new Set<GatewayProviderProtocol>([
|
||||
"anthropic_messages",
|
||||
"gemini_generate_content",
|
||||
"gemini_interactions",
|
||||
"openai_chat_completions",
|
||||
"openai_responses"
|
||||
]);
|
||||
|
|
@ -53,6 +53,7 @@ export const IPC_CHANNELS = {
|
|||
appRenderHtmlPng: "ccr:app:render-html-png",
|
||||
appRestartProxy: "ccr:app:restart-proxy",
|
||||
appRestartGateway: "ccr:app:restart-gateway",
|
||||
appResetCodexRateLimitCredit: "ccr:app:reset-codex-rate-limit-credit",
|
||||
appSaveApiKeys: "ccr:app:save-api-keys",
|
||||
appClearProxyNetworkCaptures: "ccr:app:clear-proxy-network-captures",
|
||||
appStartGateway: "ccr:app:start-gateway",
|
||||
|
|
@ -72,10 +73,13 @@ export const IPC_CHANNELS = {
|
|||
browserBack: "ccr:browser:back",
|
||||
browserCloseTab: "ccr:browser:close-tab",
|
||||
browserForward: "ccr:browser:forward",
|
||||
browserGetChromeLoginImport: "ccr:browser:get-chrome-login-import",
|
||||
browserGetState: "ccr:browser:get-state",
|
||||
browserNavigate: "ccr:browser:navigate",
|
||||
browserNewTab: "ccr:browser:new-tab",
|
||||
browserReload: "ccr:browser:reload",
|
||||
browserResolveAutomationHandoff: "ccr:browser:resolve-automation-handoff",
|
||||
browserSelectTab: "ccr:browser:select-tab",
|
||||
browserStartChromeLoginImport: "ccr:browser:start-chrome-login-import",
|
||||
browserStateChanged: "ccr:browser:state-changed"
|
||||
} as const;
|
||||
129
packages/core/src/entrypoints/server.ts
Normal file
129
packages/core/src/entrypoints/server.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env node
|
||||
import { startWebManagementServer } from "@ccr/core/web/management-server";
|
||||
|
||||
type CoreServerOptions = {
|
||||
help: boolean;
|
||||
host?: string;
|
||||
open: boolean;
|
||||
port?: number;
|
||||
startGateway: boolean;
|
||||
};
|
||||
|
||||
export async function runCoreServer(args = process.argv.slice(2)): Promise<void> {
|
||||
const options = parseCoreServerArgs(args);
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = await startWebManagementServer({
|
||||
host: options.host,
|
||||
open: options.open,
|
||||
port: options.port,
|
||||
startGateway: options.startGateway
|
||||
});
|
||||
process.stdout.write(`CCR core server is running at ${runtime.url}\n`);
|
||||
|
||||
let closing = false;
|
||||
const shutdown = (signal: NodeJS.Signals) => {
|
||||
if (closing) {
|
||||
return;
|
||||
}
|
||||
closing = true;
|
||||
void runtime.close().finally(() => {
|
||||
process.exit(signal === "SIGINT" ? 130 : 143);
|
||||
});
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
await new Promise(() => undefined);
|
||||
}
|
||||
|
||||
function parseCoreServerArgs(args: string[]): CoreServerOptions {
|
||||
const options: CoreServerOptions = {
|
||||
help: false,
|
||||
open: false,
|
||||
startGateway: true
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--open") {
|
||||
options.open = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-open") {
|
||||
options.open = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--gateway") {
|
||||
options.startGateway = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-gateway") {
|
||||
options.startGateway = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--host") {
|
||||
index += 1;
|
||||
options.host = requiredArg(args[index], "--host");
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--host=")) {
|
||||
options.host = requiredArg(arg.slice("--host=".length), "--host");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--port") {
|
||||
index += 1;
|
||||
options.port = parsePort(requiredArg(args[index], "--port"));
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--port=")) {
|
||||
options.port = parsePort(requiredArg(arg.slice("--port=".length), "--port"));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown core server option: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
process.stdout.write([
|
||||
"Usage:",
|
||||
" ccr-core-server [--host <host>] [--port <port>] [--no-gateway]",
|
||||
"",
|
||||
"Options:",
|
||||
" --host <host> Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.",
|
||||
" --port <port> Management server port. Defaults to CCR_WEB_PORT or 3458.",
|
||||
" --no-gateway Start only the web management server.",
|
||||
"",
|
||||
"Environment:",
|
||||
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
|
||||
].join("\n") + "\n");
|
||||
}
|
||||
|
||||
function requiredArg(value: string | undefined, flag: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${flag}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePort(value: string): number {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid port: ${value}`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
runCoreServer().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -2,8 +2,8 @@ import { createRequire } from "node:module";
|
|||
import { EventEmitter } from "node:events";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, RouterBuiltInAgentRuleId, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
|
||||
import { CONFIGDIR } from "../../main/constants";
|
||||
import { availableGatewayModelIds, type AppConfig, type RouterBuiltInAgentRuleId, type RouterConfig, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "@ccr/core/contracts/app";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
|
||||
type HeaderValue = string | string[] | undefined;
|
||||
|
||||
|
|
@ -57,6 +57,7 @@ export class ClaudeCodeRouterPlugin {
|
|||
};
|
||||
if (builtInAgentRouteMatches(request, this.config, "claude-code")) {
|
||||
injectClaudeCodeAgentToolDescription(body, this.config);
|
||||
injectClaudeCodeToolHubInstructions(body, this.config);
|
||||
removeClaudeCodeBillingSystemHeader(body);
|
||||
request.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(body);
|
||||
}
|
||||
|
|
@ -198,21 +199,45 @@ function resolveConfiguredRouteDecision(
|
|||
|
||||
const router = config.Router;
|
||||
const builtInDecision = resolveBuiltInAgentRouteDecision(request, config);
|
||||
if (builtInDecision) {
|
||||
return builtInDecision;
|
||||
}
|
||||
|
||||
const rules = router.rules ?? [];
|
||||
for (const rule of rules) {
|
||||
const decision = resolveRouterRule(rule, request, router);
|
||||
if (decision) {
|
||||
return decision;
|
||||
return builtInDecision ? mergeConfiguredRouteDecisions(builtInDecision, decision) : decision;
|
||||
}
|
||||
}
|
||||
|
||||
if (builtInDecision) {
|
||||
return builtInDecision;
|
||||
}
|
||||
|
||||
return { fallback: router.fallback, model: explicitModel, reason: "default" };
|
||||
}
|
||||
|
||||
function mergeConfiguredRouteDecisions(
|
||||
base: ConfiguredRouteDecision,
|
||||
override: ConfiguredRouteDecision
|
||||
): ConfiguredRouteDecision {
|
||||
const rewrites = [
|
||||
...configuredRouteDecisionRewrites(base),
|
||||
...configuredRouteDecisionRewrites(override)
|
||||
];
|
||||
return {
|
||||
fallback: override.fallback ?? base.fallback,
|
||||
model: override.model ?? base.model,
|
||||
reason: override.reason,
|
||||
...(rewrites.length === 1 ? { rewrite: rewrites[0] } : {}),
|
||||
...(rewrites.length > 0 ? { rewrites } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function configuredRouteDecisionRewrites(decision: ConfiguredRouteDecision): RouterRuleRewrite[] {
|
||||
if (decision.rewrites?.length) {
|
||||
return decision.rewrites;
|
||||
}
|
||||
return decision.rewrite ? [decision.rewrite] : [];
|
||||
}
|
||||
|
||||
function resolveBuiltInClaudeCodeSubagentRouteDecision(
|
||||
request: MutableRequestLike,
|
||||
config: AppConfig
|
||||
|
|
@ -221,7 +246,7 @@ function resolveBuiltInClaudeCodeSubagentRouteDecision(
|
|||
return undefined;
|
||||
}
|
||||
const target = normalizeRouteSelector(request.builtInSubagentModel);
|
||||
if (!target) {
|
||||
if (!target || isSubagentModelPlaceholder(target) || !isKnownInlineRoute(target, config)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
|
|
@ -297,6 +322,7 @@ function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
|
|||
const ccrSubagentModelOpenTag = "<CCR-SUBAGENT-MODEL>";
|
||||
const ccrSubagentModelCloseTag = "</CCR-SUBAGENT-MODEL>";
|
||||
const ccrSubagentModelTagExample = `${ccrSubagentModelOpenTag}Provider/model${ccrSubagentModelCloseTag}`;
|
||||
const ccrSubagentModelPlaceholder = "provider/model";
|
||||
const claudeCodeBillingSystemHeaderPrefix = "x-anthropic-billing-header";
|
||||
const ccrSubagentToolModelInstruction =
|
||||
`CCR subagent routing is enabled. When calling this tool, the prompt parameter MUST start with ` +
|
||||
|
|
@ -318,6 +344,107 @@ const ccrSubagentPromptFieldInstruction =
|
|||
type ClaudeCodeSubagentToolKind = "subagent" | "workflow";
|
||||
const claudeCodeAgentToolNames = new Set(["agent", "task"]);
|
||||
const claudeCodeWorkflowToolNames = new Set(["workflow"]);
|
||||
const ccrToolHubSystemInstructionMarker = "CCR ToolHub tool resolution is enabled.";
|
||||
|
||||
function claudeCodeToolName(tool: Record<string, unknown>): string | undefined {
|
||||
const functionSpec = isRecord(tool.function) ? tool.function : undefined;
|
||||
return readString(tool.name) ?? readString(functionSpec?.name);
|
||||
}
|
||||
|
||||
function normalizeClaudeCodeToolName(toolName: string | undefined): string {
|
||||
return toolName?.toLowerCase().replace(/[-._]/g, "") ?? "";
|
||||
}
|
||||
|
||||
function injectClaudeCodeToolHubInstructions(body: Record<string, unknown>, config: AppConfig): void {
|
||||
if (!config.toolHub?.enabled || !Array.isArray(body.tools)) {
|
||||
return;
|
||||
}
|
||||
const toolNames = claudeCodeToolHubToolNames(body.tools);
|
||||
if (!toolNames.resolve) {
|
||||
return;
|
||||
}
|
||||
const invokeName = toolNames.invoke ?? "tool_hub.invoke";
|
||||
appendSystemInstruction(body, [
|
||||
ccrToolHubSystemInstructionMarker,
|
||||
`The ToolHub search/resolution tool is ${toolNames.resolve}; call this actual tool, do not merely mention its name in text.`,
|
||||
`You MUST call the ToolHub search/resolution tool ${toolNames.resolve} before answering any request that asks about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`,
|
||||
`Do this even if the user did not mention ToolHub or ${toolNames.resolve}. Only skip the ToolHub search/resolution tool when the request is clearly local code/file/shell work or simple conversation that does not need an external or MCP capability.`,
|
||||
`If ${toolNames.resolve} returns selected tools, call the ToolHub invocation tool ${invokeName} to run the selected tool instead of telling the user that no such capability is available.`,
|
||||
"When the ToolHub resolve result includes executionPlanJs or workflowSketch, treat that JavaScript as the invocation dependency plan: await means serial order, and only callTool calls grouped inside the same Promise.all may be issued in parallel.",
|
||||
"Use the user's request as the task and include concise context when resolving. Do not ask the user to name the MCP tool unless the task is genuinely ambiguous after resolution."
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
function claudeCodeToolHubToolNames(tools: unknown[]): { invoke?: string; resolve?: string } {
|
||||
const names: { invoke?: string; resolve?: string } = {};
|
||||
for (const tool of tools) {
|
||||
if (!isRecord(tool)) {
|
||||
continue;
|
||||
}
|
||||
const name = claudeCodeToolName(tool);
|
||||
const normalized = normalizeClaudeCodeToolName(name);
|
||||
if (normalized.endsWith("toolhubresolve") && shouldUseClaudeCodeToolHubName(names.resolve, name)) {
|
||||
names.resolve = name;
|
||||
}
|
||||
if (normalized.endsWith("toolhubinvoke") && shouldUseClaudeCodeToolHubName(names.invoke, name)) {
|
||||
names.invoke = name;
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function shouldUseClaudeCodeToolHubName(current: string | undefined, candidate: string | undefined): boolean {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
if (!current) {
|
||||
return true;
|
||||
}
|
||||
return claudeCodeToolHubNameScore(candidate) > claudeCodeToolHubNameScore(current);
|
||||
}
|
||||
|
||||
function claudeCodeToolHubNameScore(name: string): number {
|
||||
const normalized = name.toLowerCase();
|
||||
if (normalized.startsWith("mcp__ccr-toolhub__") || normalized.startsWith("mcp__ccr_toolhub__")) {
|
||||
return 3;
|
||||
}
|
||||
if (normalized.startsWith("mcp__") && normalized.includes("toolhub")) {
|
||||
return 2;
|
||||
}
|
||||
if (normalized.startsWith("mcp__")) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function appendSystemInstruction(body: Record<string, unknown>, instruction: string): void {
|
||||
if (systemContainsInstruction(body.system, ccrToolHubSystemInstructionMarker)) {
|
||||
return;
|
||||
}
|
||||
if (typeof body.system === "string") {
|
||||
body.system = body.system.trim() ? `${body.system}\n\n${instruction}` : instruction;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(body.system)) {
|
||||
body.system.push({ text: instruction, type: "text" });
|
||||
return;
|
||||
}
|
||||
if (body.system === undefined) {
|
||||
body.system = [{ text: instruction, type: "text" }];
|
||||
}
|
||||
}
|
||||
|
||||
function systemContainsInstruction(system: unknown, marker: string): boolean {
|
||||
if (typeof system === "string") {
|
||||
return system.includes(marker);
|
||||
}
|
||||
if (!Array.isArray(system)) {
|
||||
return false;
|
||||
}
|
||||
return system.some((block) => typeof block === "string"
|
||||
? block.includes(marker)
|
||||
: isRecord(block) && typeof block.text === "string" && block.text.includes(marker));
|
||||
}
|
||||
|
||||
function injectClaudeCodeAgentToolDescription(body: Record<string, unknown>, config: AppConfig): void {
|
||||
if (!Array.isArray(body.tools)) {
|
||||
|
|
@ -420,8 +547,7 @@ function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; t
|
|||
}
|
||||
|
||||
function configuredSubagentModelDescriptionRows(config: AppConfig): string[] {
|
||||
const rows: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const candidates: Array<{ key: string; row: string; selector: string }> = [];
|
||||
for (const provider of config.Providers) {
|
||||
const providerName = provider.name?.trim();
|
||||
if (!providerName || !Array.isArray(provider.models)) {
|
||||
|
|
@ -435,18 +561,42 @@ function configuredSubagentModelDescriptionRows(config: AppConfig): string[] {
|
|||
}
|
||||
const selector = `${providerName}/${model}`;
|
||||
const key = selector.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
const displayName = provider.modelDisplayNames?.[model]?.trim();
|
||||
const label = displayName && displayName !== model ? `${selector} (${displayName})` : selector;
|
||||
rows.push(`- ${label}: ${singleLineText(description, 320)}`);
|
||||
candidates.push({
|
||||
key,
|
||||
row: `- ${label}: ${singleLineText(description, 320)}`,
|
||||
selector
|
||||
});
|
||||
}
|
||||
}
|
||||
candidates.sort(compareSubagentModelDescriptionRows);
|
||||
|
||||
const rows: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (seen.has(candidate.key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate.key);
|
||||
rows.push(candidate.row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function compareSubagentModelDescriptionRows(
|
||||
left: { key: string; row: string; selector: string },
|
||||
right: { key: string; row: string; selector: string }
|
||||
): number {
|
||||
return compareCodeUnitStrings(left.key, right.key) ||
|
||||
compareCodeUnitStrings(left.selector, right.selector) ||
|
||||
compareCodeUnitStrings(left.row, right.row);
|
||||
}
|
||||
|
||||
function compareCodeUnitStrings(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function removeClaudeCodeBillingSystemHeader(body: Record<string, unknown>): void {
|
||||
const system = body.system;
|
||||
if (!Array.isArray(system) || system.length === 0) {
|
||||
|
|
@ -1032,19 +1182,29 @@ export function normalizeRouteSelector(value: string | undefined): string | unde
|
|||
}
|
||||
|
||||
function isKnownInlineRoute(model: string | undefined, config: AppConfig): boolean {
|
||||
if (!model) {
|
||||
const normalizedModel = normalizeRouteSelector(model);
|
||||
if (!normalizedModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const separator = model.indexOf("/");
|
||||
const normalizedModelLower = normalizedModel.toLowerCase();
|
||||
if (availableGatewayModelIds(config).some((id) => id.toLowerCase() === normalizedModelLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const separator = normalizedModel.indexOf("/");
|
||||
if (separator <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providerName = model.slice(0, separator).trim().toLowerCase();
|
||||
const providerName = normalizedModel.slice(0, separator).trim().toLowerCase();
|
||||
return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName);
|
||||
}
|
||||
|
||||
function isSubagentModelPlaceholder(model: string): boolean {
|
||||
return model.trim().toLowerCase() === ccrSubagentModelPlaceholder;
|
||||
}
|
||||
|
||||
function calculateTokenCount(messages: unknown, system: unknown, tools: unknown): number {
|
||||
return countMessageTokens(messages) + countSystemTokens(system) + countToolTokens(tools);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { loadModelCatalogPayload } from "../../main/model-catalog-file";
|
||||
import { loadModelCatalogPayload } from "@ccr/core/models/catalog-file";
|
||||
|
||||
const claudeCodeDefaultContextTokens = 200_000;
|
||||
let modelCatalogIndex: ModelCatalogIndex | undefined;
|
||||
23
packages/core/src/gateway/runtime-change.ts
Normal file
23
packages/core/src/gateway/runtime-change.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
export function shouldRestartGatewayForRuntimeConfigChange(previousConfig: AppConfig, nextConfig: AppConfig): boolean {
|
||||
return (
|
||||
previousConfig.gateway.enabled !== nextConfig.gateway.enabled ||
|
||||
previousConfig.gateway.host !== nextConfig.gateway.host ||
|
||||
previousConfig.gateway.port !== nextConfig.gateway.port ||
|
||||
previousConfig.gateway.coreHost !== nextConfig.gateway.coreHost ||
|
||||
previousConfig.gateway.corePort !== nextConfig.gateway.corePort ||
|
||||
previousConfig.proxy.enabled !== nextConfig.proxy.enabled ||
|
||||
previousConfig.proxy.host !== nextConfig.proxy.host ||
|
||||
previousConfig.proxy.mode !== nextConfig.proxy.mode ||
|
||||
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.agent) !== JSON.stringify(nextConfig.agent) ||
|
||||
JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) ||
|
||||
JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) ||
|
||||
JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) ||
|
||||
JSON.stringify(previousConfig.toolHub) !== JSON.stringify(nextConfig.toolHub) ||
|
||||
JSON.stringify(previousConfig.virtualModelProfiles) !== JSON.stringify(nextConfig.virtualModelProfiles)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
146
packages/core/src/mcp/browser-web-search-proxy-mcp.ts
Normal file
146
packages/core/src/mcp/browser-web-search-proxy-mcp.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
const targetUrl = process.env.BROWSER_WEB_SEARCH_MCP_URL?.trim();
|
||||
const requestTimeoutMs = clampInteger(Number(process.env.BROWSER_WEB_SEARCH_PROXY_TIMEOUT_MS), 1_000, 600_000, 120_000);
|
||||
|
||||
let stdinBuffer = Buffer.alloc(0);
|
||||
|
||||
if (!targetUrl) {
|
||||
process.stderr.write("BROWSER_WEB_SEARCH_MCP_URL is required.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdin.on("data", (chunk: Buffer | string) => {
|
||||
stdinBuffer = Buffer.concat([stdinBuffer, typeof chunk === "string" ? Buffer.from(chunk) : chunk]);
|
||||
void drainFrames();
|
||||
});
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
async function drainFrames(): Promise<void> {
|
||||
while (true) {
|
||||
const headerEnd = stdinBuffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerText = stdinBuffer.slice(0, headerEnd).toString("utf8");
|
||||
const contentLength = readContentLength(headerText);
|
||||
if (contentLength === undefined || contentLength < 0) {
|
||||
stdinBuffer = Buffer.alloc(0);
|
||||
writeFrame(jsonRpcError(null, -32600, "Invalid MCP frame header."));
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadStart = headerEnd + 4;
|
||||
const payloadEnd = payloadStart + contentLength;
|
||||
if (stdinBuffer.length < payloadEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = stdinBuffer.slice(payloadStart, payloadEnd).toString("utf8");
|
||||
stdinBuffer = stdinBuffer.slice(payloadEnd);
|
||||
await forwardPayload(body);
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardPayload(body: string): Promise<void> {
|
||||
const requestId = readJsonRpcId(body);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
try {
|
||||
const response = await fetch(targetUrl!, {
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST",
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
writeFrame(jsonRpcError(requestId, -32603, text || `In-app browser MCP returned HTTP ${response.status}.`));
|
||||
return;
|
||||
}
|
||||
if (text.trim()) {
|
||||
writeRawFrame(text);
|
||||
}
|
||||
} catch (error) {
|
||||
writeFrame(jsonRpcError(requestId, -32603, formatError(error)));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function readContentLength(headerText: string): number | undefined {
|
||||
for (const line of headerText.split(/\r?\n/)) {
|
||||
const match = /^content-length\s*:\s*(\d+)\s*$/i.exec(line);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readJsonRpcId(body: string): null | number | string {
|
||||
try {
|
||||
const payload = JSON.parse(body) as unknown;
|
||||
if (isRecord(payload)) {
|
||||
return isJsonRpcId(payload.id) ? payload.id : null;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
const first = payload.find((item) => isRecord(item) && isJsonRpcId(item.id));
|
||||
return isRecord(first) && isJsonRpcId(first.id) ? first.id : null;
|
||||
}
|
||||
} catch {
|
||||
// The upstream MCP endpoint will return the parse error for valid JSON-RPC framing.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isJsonRpcId(value: unknown): value is null | number | string {
|
||||
return value === null || typeof value === "number" || typeof value === "string";
|
||||
}
|
||||
|
||||
function jsonRpcError(id: null | number | string, code: number, message: string): Record<string, unknown> {
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
message
|
||||
},
|
||||
id,
|
||||
jsonrpc: "2.0"
|
||||
};
|
||||
}
|
||||
|
||||
function writeFrame(payload: Record<string, unknown>): void {
|
||||
writeRawFrame(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function writeRawFrame(body: string): void {
|
||||
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
|
||||
}
|
||||
|
||||
function clampInteger(value: number, min: number, max: number, fallback: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.name === "AbortError" ? "In-app browser MCP proxy request timed out." : error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ type McpTool = {
|
|||
description: string;
|
||||
inputSchema: JsonValue;
|
||||
name: string;
|
||||
unavailableMessage?: string;
|
||||
};
|
||||
|
||||
type ToolCallResult = {
|
||||
|
|
@ -134,10 +135,11 @@ async function handleJsonRpcRequest(payload: unknown): Promise<JsonRpcResponse |
|
|||
function callTool(params: unknown): ToolCallResult {
|
||||
const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : "";
|
||||
const toolLabel = name || "unknown";
|
||||
const tool = tools.find((item) => item.name === toolLabel);
|
||||
const knownSuffix = toolNames.has(toolLabel) ? "" : " The requested tool was not in the fallback catalog.";
|
||||
return {
|
||||
content: [{
|
||||
text:
|
||||
text: tool?.unavailableMessage ||
|
||||
`Fusion MCP tool "${toolLabel}" is temporarily unavailable. ` +
|
||||
"CCR registered a fallback definition because the real MCP server did not provide the tool during discovery. " +
|
||||
`Check the Fusion MCP server logs and retry.${knownSuffix}`,
|
||||
|
|
@ -179,7 +181,8 @@ function readFallbackTools(): McpTool[] {
|
|||
readString(item.description) ||
|
||||
`Fallback registration for Fusion MCP tool "${name}". The real MCP server should handle successful calls.`,
|
||||
inputSchema: isRecord(item.inputSchema) ? item.inputSchema as JsonValue : objectSchema({}),
|
||||
name
|
||||
name,
|
||||
unavailableMessage: readString(item.unavailableMessage)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
|
|
@ -90,6 +90,10 @@ const visionTool = {
|
|||
const webSearchTool = {
|
||||
description: "Search the web with this Fusion profile's configured search provider.",
|
||||
inputSchema: objectSchema({
|
||||
allowedDomains: { items: { type: "string" }, type: "array" },
|
||||
allowed_domains: { items: { type: "string" }, type: "array" },
|
||||
blockedDomains: { items: { type: "string" }, type: "array" },
|
||||
blocked_domains: { items: { type: "string" }, type: "array" },
|
||||
count: { maximum: 20, minimum: 1, type: "number" },
|
||||
country: { type: "string" },
|
||||
excludeDomains: { items: { type: "string" }, type: "array" },
|
||||
|
|
@ -280,9 +284,17 @@ async function analyzeWebSearch(args: Record<string, unknown>): Promise<string>
|
|||
const input = {
|
||||
count,
|
||||
country: readString(args.country),
|
||||
excludeDomains: readStringArray(args.excludeDomains),
|
||||
excludeDomains: uniqueStrings([
|
||||
...readStringArray(args.excludeDomains),
|
||||
...readStringArray(args.blockedDomains),
|
||||
...readStringArray(args.blocked_domains)
|
||||
]),
|
||||
freshness: readString(args.freshness),
|
||||
includeDomains: readStringArray(args.includeDomains),
|
||||
includeDomains: uniqueStrings([
|
||||
...readStringArray(args.includeDomains),
|
||||
...readStringArray(args.allowedDomains),
|
||||
...readStringArray(args.allowed_domains)
|
||||
]),
|
||||
includeRaw: args.includeRaw === true,
|
||||
language: readString(args.language),
|
||||
prompt,
|
||||
|
|
@ -651,12 +663,19 @@ function isSearchResult(value: SearchResult): value is Required<Pick<SearchResul
|
|||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map(readString).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function textResult(text: string): ToolCallResult {
|
||||
return {
|
||||
content: [{ text, type: "text" }]
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import packageJson from "../../../package.json";
|
||||
import packageJson from "../../package.json";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ProxyNetworkExchange } from "../../shared/app";
|
||||
import { proxyService } from "../proxy/service";
|
||||
import type { ProxyNetworkExchange } from "@ccr/core/contracts/app";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
|
||||
type JsonPrimitive = boolean | null | number | string;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { fetchWithSystemProxy } from "../../main/system-proxy-fetch";
|
||||
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
import type {
|
||||
GatewayMcpRemoteServerConfig,
|
||||
GatewayMcpServerConfig,
|
||||
GatewayMcpStdioServerConfig,
|
||||
GatewayMcpToolInfo
|
||||
} from "../../shared/app";
|
||||
} from "@ccr/core/contracts/app";
|
||||
|
||||
type JsonRpcMessage = {
|
||||
error?: unknown;
|
||||
226
packages/core/src/mcp/toolhub-config.ts
Normal file
226
packages/core/src/mcp/toolhub-config.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { join as pathJoin } from "node:path";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import type { AppConfig, GatewayMcpServerConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
export const TOOL_HUB_MCP_SERVER_NAME = "ccr-toolhub";
|
||||
export const TOOL_HUB_MCP_RUNTIME_FILE_NAME = "toolhub-mcp.js";
|
||||
export const BROWSER_AUTOMATION_MCP_SERVER_NAME = "ccr-browser-automation";
|
||||
export const BROWSER_AUTOMATION_MCP_PATH = "/__ccr/browser-automation/mcp";
|
||||
export const BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS = 600000;
|
||||
export const TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS = 60000;
|
||||
|
||||
export type ToolHubMcpRuntimeConfig = {
|
||||
args: string[];
|
||||
command: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ClaudeCodeMcpServerConfig = ToolHubMcpRuntimeConfig | {
|
||||
args?: string[];
|
||||
command: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
} | {
|
||||
headers?: Record<string, string>;
|
||||
type: "http" | "sse";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ToolHubClaudeCodeMcpConfig = {
|
||||
mcpServers: Record<string, ClaudeCodeMcpServerConfig>;
|
||||
};
|
||||
|
||||
export function toolHubBackendServers(
|
||||
config: AppConfig | undefined,
|
||||
extraServers: unknown[] = [],
|
||||
options: {
|
||||
apiKey?: string;
|
||||
includeBuiltIns?: boolean;
|
||||
} = {}
|
||||
): unknown[] {
|
||||
return [
|
||||
...(options.includeBuiltIns === false ? [] : toolHubBuiltInBackendServers(config, options)),
|
||||
...(Array.isArray(config?.agent?.mcpServers) ? config.agent.mcpServers : []),
|
||||
...(Array.isArray(config?.toolHub?.mcpServers) ? config.toolHub.mcpServers : []),
|
||||
...extraServers
|
||||
].filter(isToolHubBackendServer);
|
||||
}
|
||||
|
||||
export function toolHubBuiltInBackendServers(
|
||||
config: AppConfig | undefined,
|
||||
options: {
|
||||
apiKey?: string;
|
||||
} = {}
|
||||
): GatewayMcpServerConfig[] {
|
||||
if (!config || !browserAutomationMcpEnabled(config) || !hasGatewayEndpoint(config)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
apiKey: options.apiKey || firstConfiguredApiKey(config),
|
||||
headers: {},
|
||||
name: BROWSER_AUTOMATION_MCP_SERVER_NAME,
|
||||
protocolVersion: "2024-11-05",
|
||||
requestTimeoutMs: BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS,
|
||||
startupTimeoutMs: 60000,
|
||||
transport: "streamable-http",
|
||||
url: `${gatewayEndpoint(config)}${BROWSER_AUTOMATION_MCP_PATH}`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function browserAutomationMcpEnabled(config: AppConfig | undefined): boolean {
|
||||
return Boolean(config?.toolHub?.enabled && config.toolHub.browserAutomation);
|
||||
}
|
||||
|
||||
export function toolHubMcpRuntimeConfig(
|
||||
config: AppConfig | undefined,
|
||||
backendServers?: unknown[],
|
||||
options: {
|
||||
command?: string;
|
||||
entryPath?: string;
|
||||
resolver?: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
};
|
||||
} = {}
|
||||
): ToolHubMcpRuntimeConfig | undefined {
|
||||
const toolHub = config?.toolHub;
|
||||
if (!toolHub?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedBackendServers = backendServers ?? toolHubBackendServers(config, [], {
|
||||
apiKey: options.resolver?.apiKey
|
||||
});
|
||||
const normalizedBackendServers = resolvedBackendServers.filter(isToolHubBackendServer);
|
||||
if (normalizedBackendServers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const requestTimeoutMs = toolHubRequestTimeoutMs(config, normalizedBackendServers);
|
||||
|
||||
return {
|
||||
args: [options.entryPath ?? bundledToolHubMcpEntryPath()],
|
||||
command: options.command ?? process.execPath,
|
||||
env: {
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
TOOLHUB_CACHE_FILE: pathJoin(CONFIGDIR, "toolhub-cache.json"),
|
||||
TOOLHUB_MAX_TOOLS: String(toolHub.maxTools ?? 10),
|
||||
TOOLHUB_MCP_SERVERS_JSON: JSON.stringify(normalizedBackendServers),
|
||||
TOOLHUB_OPENAI_API_KEY: options.resolver?.apiKey ?? toolHub.llm?.apiKey ?? "",
|
||||
TOOLHUB_OPENAI_BASE_URL: options.resolver?.baseUrl ?? toolHub.llm?.baseUrl ?? "https://api.openai.com/v1",
|
||||
TOOLHUB_OPENAI_MODEL: options.resolver?.model ?? toolHub.llm?.model ?? "",
|
||||
TOOLHUB_REQUEST_TIMEOUT_MS: String(requestTimeoutMs)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function toolHubRequestTimeoutMs(config: AppConfig | undefined, backendServers?: unknown[]): number {
|
||||
const configuredTimeout = positiveInteger(config?.toolHub?.requestTimeoutMs, TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
const backendTimeouts = (backendServers ?? toolHubBackendServers(config))
|
||||
.map((server) => isRecord(server) ? positiveInteger(server.requestTimeoutMs, 0) : 0);
|
||||
return Math.max(configuredTimeout, ...backendTimeouts);
|
||||
}
|
||||
|
||||
export function toolHubClaudeCodeMcpConfig(
|
||||
config: AppConfig | undefined,
|
||||
options: Parameters<typeof toolHubMcpRuntimeConfig>[2] = {}
|
||||
): ToolHubClaudeCodeMcpConfig | undefined {
|
||||
const toolHub = config?.toolHub;
|
||||
if (!toolHub?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const backendServers = toolHubBackendServers(config, [], {
|
||||
apiKey: options.resolver?.apiKey
|
||||
});
|
||||
if (backendServers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const runtimeConfig = toolHubMcpRuntimeConfig(config, backendServers, options);
|
||||
return runtimeConfig
|
||||
? { mcpServers: { [TOOL_HUB_MCP_SERVER_NAME]: runtimeConfig } }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function bundledToolHubMcpEntryPath(): string {
|
||||
return pathJoin(__dirname, TOOL_HUB_MCP_RUNTIME_FILE_NAME);
|
||||
}
|
||||
|
||||
export function bundledToolHubMcpEntryPathCandidates(): string[] {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
return uniqueStrings([
|
||||
bundledToolHubMcpEntryPath(),
|
||||
...(resourcesPath
|
||||
? [
|
||||
pathJoin(resourcesPath, "app.asar", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME),
|
||||
pathJoin(resourcesPath, "app", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME)
|
||||
]
|
||||
: []),
|
||||
pathJoin(process.cwd(), "packages", "electron", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME),
|
||||
pathJoin(process.cwd(), "packages", "cli", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME),
|
||||
pathJoin(process.cwd(), "packages", "core", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME),
|
||||
pathJoin(process.cwd(), "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME)
|
||||
]);
|
||||
}
|
||||
|
||||
function isToolHubBackendServer(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && stringValue(value.name)?.toLowerCase() !== TOOL_HUB_MCP_SERVER_NAME;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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 firstConfiguredApiKey(config: AppConfig): string | undefined {
|
||||
return (Array.isArray(config.APIKEYS) ? config.APIKEYS : [])
|
||||
.find((apiKey) => apiKey.key.trim())?.key.trim() || stringValue(config.APIKEY);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
||||
}
|
||||
|
||||
function gatewayEndpoint(config: AppConfig): string {
|
||||
return `http://${formatHost(clientGatewayHost(config.gateway.host))}:${config.gateway.port}`;
|
||||
}
|
||||
|
||||
function hasGatewayEndpoint(config: AppConfig): boolean {
|
||||
const gateway = (config as Partial<AppConfig>).gateway;
|
||||
return Boolean(gateway && stringValue(gateway.host) && Number.isFinite(gateway.port));
|
||||
}
|
||||
|
||||
function formatHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function clientGatewayHost(host: string): string {
|
||||
const value = stringValue(host) ?? "127.0.0.1";
|
||||
if (value === "0.0.0.0") {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
if (value === "::" || value === "[::]") {
|
||||
return "::1";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
2944
packages/core/src/mcp/toolhub-mcp.ts
Normal file
2944
packages/core/src/mcp/toolhub-mcp.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -24,8 +24,11 @@ export function modelCatalogPathCandidates(): string[] {
|
|||
process.env.CCR_MODEL_CATALOG_PATH?.trim() || "",
|
||||
process.env.CCR_MODELS_JSON_PATH?.trim() || "",
|
||||
pathResolve(process.cwd(), "models.json"),
|
||||
pathResolve(process.cwd(), "packages", "core", "models.json"),
|
||||
pathResolve(process.cwd(), "packages", "cli", "models.json"),
|
||||
pathResolve(__dirname, "..", "models.json"),
|
||||
pathResolve(__dirname, "..", "assets", "models.json"),
|
||||
pathResolve(__dirname, "..", "..", "models.json"),
|
||||
pathResolve(__dirname, "..", "..", "..", "models.json")
|
||||
]);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { fetchWithSystemProxy } from "./system-proxy-fetch";
|
||||
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
|
||||
type ModelPricingSource = "litellm" | "models.dev" | "openrouter";
|
||||
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { REQUEST_LOGS_DB_FILE } from "./constants";
|
||||
import { estimateUsageCostUsd } from "./model-pricing-service";
|
||||
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native";
|
||||
import { normalizeUsageInputTokens } from "./usage-normalization";
|
||||
import { REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants";
|
||||
import { estimateUsageCostUsd } from "@ccr/core/models/pricing-service";
|
||||
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native";
|
||||
import { normalizeUsageInputTokens } from "@ccr/core/usage/normalization";
|
||||
import type {
|
||||
AgentAnalysisAgentRow,
|
||||
AgentAnalysisFilter,
|
||||
|
|
@ -38,7 +38,7 @@ import type {
|
|||
RequestLogRetryAttempt,
|
||||
RequestLogStatusFilter,
|
||||
UsageStatsRange
|
||||
} from "../shared/app";
|
||||
} from "@ccr/core/contracts/app";
|
||||
|
||||
type SqlDatabase = BetterSqliteDatabase;
|
||||
type SqlValue = bigint | Buffer | number | string | null;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue