Compare commits
166 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19973394d2 | ||
|
|
4156fdea27 | ||
|
|
9b3339e4e4 | ||
|
|
f021884c1d | ||
|
|
ded548da7e | ||
|
|
b27e147b16 | ||
|
|
555b31477d | ||
|
|
b1fb8e3331 | ||
|
|
6b4b22aa4b | ||
|
|
e33c856891 | ||
|
|
adadb1679f | ||
|
|
dae4a914ae | ||
|
|
30d1f35926 | ||
|
|
2b1720067b | ||
|
|
694437bf3f | ||
|
|
7acffa6c5d | ||
|
|
deff4859b5 | ||
|
|
5a3eda0912 | ||
|
|
2aa3529449 | ||
|
|
e4d4a08d5d | ||
|
|
ae265ec934 | ||
|
|
2c9404752c | ||
|
|
1d58c1c2bd | ||
|
|
f6dd3f5b4e | ||
|
|
692146cc2d | ||
|
|
9e56580334 | ||
|
|
2bf349fe03 | ||
|
|
84855c4dd4 | ||
|
|
9094081b7c | ||
|
|
7b0e203693 | ||
|
|
1308eb4510 | ||
|
|
8d91a4acd7 | ||
|
|
e3334ae9ae | ||
|
|
b8a56a1efa | ||
|
|
672520a78b | ||
|
|
245762710f | ||
|
|
f1930b249c | ||
|
|
e9f59c5425 | ||
|
|
58998becfa | ||
|
|
99cc7066fb | ||
|
|
21ab38dd47 | ||
|
|
f9525f469d | ||
|
|
b03728f816 | ||
|
|
8a49c51ebf | ||
|
|
a8bfa6363e | ||
|
|
6f3df2f331 | ||
|
|
96467abe5f | ||
|
|
1d8ff6742c | ||
|
|
645fde1878 | ||
|
|
f1dbfd5301 | ||
|
|
9d402f27b4 | ||
|
|
b893ce8058 | ||
|
|
71e46f9104 | ||
|
|
0196fdebeb | ||
|
|
1ff88a7a3e | ||
|
|
48746183cf | ||
|
|
5ddbacee18 | ||
|
|
8d549296c1 | ||
|
|
57b1169fff | ||
|
|
faa721fed8 | ||
|
|
1389ff5c75 | ||
|
|
9cd0aab309 | ||
|
|
9d1ff83708 | ||
|
|
2bb2611447 | ||
|
|
222ccfa208 | ||
|
|
ef90d57ee1 | ||
|
|
be9a50e585 | ||
|
|
f1b8fd1fa0 | ||
|
|
ca2609c727 | ||
|
|
ee243fbfe5 | ||
|
|
8cfd4bbd8b | ||
|
|
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 | ||
|
|
44e4ff6cd2 | ||
|
|
1faa217a40 | ||
|
|
513fa11ead | ||
|
|
22dad1afb5 | ||
|
|
cbf1a72507 | ||
|
|
9f99eb2d84 | ||
|
|
371520bb4c | ||
|
|
5ad41aba2b | ||
|
|
2cbaaded8f | ||
|
|
054c4fcd94 | ||
|
|
f6c112148d | ||
|
|
56c1b666c6 | ||
|
|
11392a5b09 |
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
|
||||
7
.gitignore
vendored
|
|
@ -13,4 +13,9 @@ release
|
|||
tmp
|
||||
release-local
|
||||
logs
|
||||
.opencat
|
||||
.opencat
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
.tmp
|
||||
.test-dist
|
||||
|
|
|
|||
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"]
|
||||
602
README.md
|
|
@ -1,65 +1,94 @@
|
|||
<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 control plane for coding agents. It gives Claude Code, Codex, Grok CLI, ZCode, and compatible API clients one stable local endpoint, then lets you decide which provider, model, routing policy, tool stack, and account should handle each request.
|
||||
|
||||
Instead of wiring every agent to every model service by hand, CCR centralizes the model layer on your own machine: provider presets, custom endpoints, credential pools, fallback chains, Fusion-enhanced models, MCP tools, request logs, account usage, and desktop launch profiles all live in one app.
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" alt="Claude Code Router Desktop screenshot" />
|
||||
<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.
|
||||
## What CCR Helps You Do
|
||||
|
||||
CCR runs on your machine, keeps provider configuration in your local config directory, and exposes a local gateway at `http://127.0.0.1:3456`.
|
||||
| Goal | CCR gives you |
|
||||
| --- | --- |
|
||||
| Keep the same agent workflow while switching models | Local profiles for Claude Code, Codex, Grok CLI, and ZCode, with CLI/app launch entries and per-profile model selection |
|
||||
| Try many providers without rebuilding config every time | Built-in provider presets, custom OpenAI/Anthropic/Gemini-compatible endpoints, protocol probing, model discovery, and connectivity checks |
|
||||
| Make routing a runtime policy | Built-in agent routing, conditional rules, request rewrites, model-prefix routing, retries, and fallback model chains |
|
||||
| Control cost and quota pressure | Credential pools, key rotation, local usage limits, account balance snapshots, token/cost dashboards, and tray status |
|
||||
| Upgrade a model without replacing it | Fusion models that add vision, web search, or selected MCP tools to an existing base model |
|
||||
| Keep large tool sets usable | ToolHub, a compact MCP entry point that lets agents resolve and invoke the tools needed for the current task |
|
||||
| Debug what actually happened | Request logs, resolved provider/model fields, latency, token usage, estimated cost, network capture, and agent observability |
|
||||
|
||||
## Why Use CCR
|
||||
|
||||
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
|
||||
- Route requests with explicit rules 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.
|
||||
- Manage everything from a desktop UI instead of editing JSON by hand.
|
||||
- Extend the gateway with plugins, proxy routes, local HTTP backends, and provider deeplinks.
|
||||
- **One gateway for your agent stack**: point clients at CCR once, then move routing, models, keys, and providers from scattered client configs into a single desktop UI.
|
||||
- **Provider freedom without workflow churn**: use OpenAI Chat/Responses, Anthropic Messages, Gemini Generate Content/Interactions, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom compatible providers.
|
||||
- **Reliability policies you can see and change**: define when a request should be rewritten, retried, or moved to another model, then verify the result in local logs.
|
||||
- **Operational visibility for AI work**: track requests, tokens, cost estimates, success rate, latency, model distribution, provider usage, and account balances from the dashboard or tray.
|
||||
- **Agent-native tools and extensions**: add Fusion capabilities, expose dynamic MCP tools through ToolHub, automate the built-in browser, relay agents through IM bots, or install local extensions.
|
||||
|
||||
## Features
|
||||
## Feature Highlights
|
||||
|
||||
- **Desktop dashboard**: start or stop the local gateway, inspect usage, configure the tray window, and manage runtime settings.
|
||||
- **Provider management**: add provider presets or custom endpoints, test connectivity, manage credentials, and monitor supported account balances where available.
|
||||
- **Routing rules**: configure conditional and model-prefix routing rules with fallback handling.
|
||||
- **Agent profiles**: configure Claude Code, Codex, and ZCode profiles that point to the CCR gateway.
|
||||
- **Gateway compatibility**: translate client requests through the local CCR wrapper and the core gateway runtime.
|
||||
- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture.
|
||||
- **Plugins**: install or load wrapper plugins, including routes for Claude Design and Cursor Proxy style integrations.
|
||||
- **Virtual models**: expose aliases or composed model profiles for clients that expect a specific model name.
|
||||
- **Provider deeplinks**: import provider configuration through `ccr://provider?...` links after user confirmation.
|
||||
- **Agent profiles**: create profiles for Claude Code, Codex, Grok CLI, and ZCode with model overrides, scopes, CLI/app launch surfaces, environment settings, and multi-instance app workflows.
|
||||
- **Provider management**: add preset providers or custom endpoints; probe supported protocols; detect model lists; run real connectivity checks; manage single keys or credential pools; import local agent login state where supported.
|
||||
- **Model catalog**: search all configured models, edit model descriptions, and use those descriptions to guide Claude Code subagent, Task, and Workflow model selection.
|
||||
- **Routing engine**: combine built-in agent routing, request-header/body conditions, model-prefix routing, request rewrites, retry policy, and ordered fallback targets.
|
||||
- **Fusion models**: publish reusable virtual models that keep a base model's behavior while adding vision, hosted web search, or selected MCP tools.
|
||||
- **ToolHub**: merge multiple MCP servers into one dynamic MCP server so agents can resolve tools only when a task needs them; desktop builds can also expose built-in browser automation and Chrome login-state import.
|
||||
- **API keys and quotas**: create CCR client keys with expiration and local request/token/image limits, separate from upstream provider credentials.
|
||||
- **Logs and observability**: inspect request/response details, resolved provider and model, credential, status, latency, token usage, estimated cost, tool calls, and agent execution traces.
|
||||
- **Proxy and networking**: run CCR as a local HTTP/HTTPS proxy, optionally install the CA certificate, route supported API traffic through CCR, and capture network exchanges for debugging.
|
||||
- **Bot relay**: connect agent profiles to supported IM platforms including Weixin iLink, WeCom, Slack, Discord, Telegram, LINE, Feishu, and DingTalk.
|
||||
- **Extensions**: install wrapper plugins and core gateway plugins that can register local routes, proxy routes, provider account connectors, apps, and virtual models.
|
||||
|
||||
## Documentation
|
||||
|
||||
Read the full documentation at [ccrdesk.top](https://ccrdesk.top/), including the [CLI reference](https://ccrdesk.top/en/guides/cli/) and [Docker deployment guide](https://ccrdesk.top/en/guides/docker/).
|
||||
|
||||
## Download And Install
|
||||
|
||||
### npm CLI
|
||||
|
||||
Install the CLI package when you want to run CCR without the desktop tray or `ccr://` protocol integration:
|
||||
|
||||
```bash
|
||||
npm install -g claude-code-router
|
||||
ccr start
|
||||
```
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
ccr start # start the background CCR service and web management UI
|
||||
ccr stop # stop the background CCR service
|
||||
ccr <profile-name> cli # launch the saved profile as a CLI
|
||||
ccr <profile-name> app # launch the saved profile as an app
|
||||
```
|
||||
|
||||
The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr start --host <host> --port <port>` to change it.
|
||||
|
||||
### Desktop App
|
||||
|
||||
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`
|
||||
|
|
@ -69,14 +98,30 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st
|
|||
3. Install and launch **Claude Code Router**.
|
||||
4. On first launch, CCR creates its local configuration database:
|
||||
- macOS/Linux: `~/.claude-code-router/config.sqlite`
|
||||
- Windows: `%APPDATA%\Claude Code Router\config.sqlite`
|
||||
- Windows: `%APPDATA%\claude-code-router\config.sqlite`
|
||||
|
||||
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
|
||||
|
||||
CCR starts two local services when the gateway is enabled:
|
||||
After the service is started from the **Server** page, CCR listens on `http://127.0.0.1:3456` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
|
||||
|
||||
- CCR wrapper gateway: `http://127.0.0.1:3456`
|
||||
- Core gateway runtime: `http://127.0.0.1:3457`
|
||||
## CLI And Docker
|
||||
|
||||
The npm CLI requires Node.js 22 or newer and provides the browser management UI, gateway, and Agent Config launch commands without Electron:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr ui
|
||||
```
|
||||
|
||||
The CLI management UI defaults to `http://127.0.0.1:3458`, while its model gateway defaults to `http://127.0.0.1:3456`. See the [complete CLI reference](https://ccrdesk.top/en/guides/cli/) for background/foreground service commands, options, profile launching, authentication, and data locations.
|
||||
|
||||
To run the browser UI and gateway behind one Nginx port with persistent Docker storage:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Docker exposes both management and gateway routes at `http://127.0.0.1:3458` by default. Read the [Docker deployment guide](https://ccrdesk.top/en/guides/docker/) before remote exposure; it covers the internal port topology, management and gateway authentication, `CCR_PUBLIC_BASE_URL`, volumes, backup/restore, upgrades, and health checks.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -84,229 +129,306 @@ CCR can be configured entirely from the desktop UI. Use this setup order for a c
|
|||
|
||||
### 1. Add a provider
|
||||
|
||||
Open **Providers**, click **Add Provider**, then choose a built-in preset or create a custom provider. Fill in the provider name, endpoint, protocol, API key, and model list in the form. Use the connectivity check when available, then save the provider.
|
||||
Open **Providers**, click **Add Provider**, then choose a built-in preset, import a supported local agent login state, or select **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
|
||||
|
||||
### 2. Configure routing
|
||||
|
||||
Open **Routing** to add explicit rules and configure failure handling.
|
||||
|
||||
Use **Add Routing Rule** for request conditions, model-prefix routing, or fallback behavior.
|
||||
Open **Routing** to enable built-in agent routes, add conditional rules, configure request rewrites, and set fallback behavior. Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets.
|
||||
|
||||
### 3. Start the gateway
|
||||
|
||||
Open **Server** and click **Start**. Enable auto start if you want CCR to start the local gateway whenever the desktop app opens.
|
||||
Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://127.0.0.1:3456` by default. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
|
||||
|
||||
### 4. Connect your agent tool
|
||||
|
||||
Open **Profiles** and choose the client you want to use. Configure the Claude Code, Codex, or ZCode profile from the form, select the target model, and apply the profile. For app-based profiles, use the profile action button to open the target app through CCR.
|
||||
Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, Grok CLI, or ZCode, select the target model and effect scope, then apply the config. For app entries, use **Open Agent** to launch the target app through CCR.
|
||||
|
||||
### 5. Monitor and adjust
|
||||
|
||||
Use **Dashboard** for usage and provider health, the tray window for quick token and account status, **Network Logs** for debugging provider behavior, and **Extensions** for plugin configuration.
|
||||
|
||||
## Provider Deeplink
|
||||
|
||||
Provider websites can open CCR and import a model provider with a custom protocol link:
|
||||
|
||||
```text
|
||||
ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions
|
||||
```
|
||||
|
||||
Supported query parameters:
|
||||
|
||||
- `name`: display name for the provider.
|
||||
- `base_url`: provider API base URL.
|
||||
- `api_key`: optional provider API key.
|
||||
- `models`: comma-separated or newline-separated model list. You can also repeat `models=...`.
|
||||
- `protocol`: one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, or `gemini_generate_content`.
|
||||
|
||||
For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields. CCR always opens a confirmation dialog before writing a provider imported from an external link.
|
||||
|
||||
## Plugins
|
||||
|
||||
CCR has two plugin layers:
|
||||
|
||||
- Core gateway plugins: use `providerPlugins` and `virtualModelProfiles`; these are passed through to the core gateway.
|
||||
- Wrapper plugins: use top-level `plugins` to extend the Electron wrapper, register local HTTP backends, add gateway routes, and route proxy-mode traffic to plugin backends.
|
||||
|
||||
Example wrapper plugin route:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "local-admin-api",
|
||||
"enabled": true,
|
||||
"proxy": {
|
||||
"routes": [
|
||||
{
|
||||
"id": "admin-api",
|
||||
"host": "api.example.com",
|
||||
"paths": ["/v1/admin"],
|
||||
"upstream": "http://127.0.0.1:4510",
|
||||
"stripPathPrefix": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Plugin modules export a function or object with `setup(ctx)`. The context supports:
|
||||
|
||||
- `ctx.registerGatewayRoute({ method, path, auth, handler })`
|
||||
- `ctx.registerHttpBackend({ id, host, port, handler })`
|
||||
- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
|
||||
- `ctx.openSqliteStore({ filename, migrate })`
|
||||
- `ctx.registerCoreGatewayProviderPlugin(plugin)`
|
||||
- `ctx.registerCoreGatewayVirtualModelProfile(profile)`
|
||||
|
||||
Local plugin examples are available in [examples/plugins](examples/plugins).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run typecheck
|
||||
npm run build:assets
|
||||
npm run build:app:mac
|
||||
npm run build:app:win
|
||||
```
|
||||
|
||||
`npm run build:assets` compiles the Electron main process and renderer assets into `dist/`.
|
||||
|
||||
`npm run build` packages the app for the current platform and writes installer artifacts to `release/`.
|
||||
|
||||
`npm run build:app:mac` and `npm run build:app:win` package platform-specific app artifacts. Linux AppImage packaging is configured in `electron-builder.json`.
|
||||
|
||||
`npm run build:app:mac` creates a local macOS test package in `release-local/` using ad-hoc signing. It is useful with a free Apple Account or Apple Development certificate, but it is not suitable for public distribution because downloaded copies will not pass Gatekeeper notarization checks.
|
||||
|
||||
macOS release builds are signed and notarized for distribution. Before running `npm run build:app:mac:release`, the build machine must have a `Developer ID Application` certificate available through the keychain or `CSC_LINK`/`CSC_KEY_PASSWORD`, full Xcode selected with `xcode-select`, and one notarization credential set:
|
||||
|
||||
- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`
|
||||
- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`
|
||||
- `APPLE_KEYCHAIN_PROFILE`, optionally with `APPLE_KEYCHAIN`
|
||||
|
||||
The macOS packaging hook validates codesigning, the stapled notarization ticket, and Gatekeeper assessment before writing distributable artifacts.
|
||||
|
||||
Packaged builds check GitHub Releases for updates through `electron-updater`. For local update feed testing, set `CCR_UPDATE_FEED_URL` to a generic electron-updater feed URL before starting the app. `CCR_UPDATE_ALLOW_PRERELEASE=1` enables prerelease updates.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Project Motivation and How It Works](blog/en/project-motivation-and-how-it-works.md)
|
||||
- [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md)
|
||||
Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors. Use the dashboard and tray window for token, cost, model distribution, and account status.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Codex support and Bot handoff are powered by [musistudio/codexl](https://github.com/musistudio/codexl).
|
||||
Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl).
|
||||
|
||||
## Support & Sponsoring
|
||||
|
||||
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.
|
||||
<div align="center">
|
||||
|
||||
[](https://ko-fi.com/F1F31GN2GM)
|
||||
|
||||
[Paypal](https://paypal.me/musistudio1999)
|
||||
<p>If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
|
||||
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
|
||||
<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
|
||||
|
||||
A huge thank you to all our sponsors for their generous support.
|
||||
<div align="center">
|
||||
|
||||
- [AIHubmix](https://aihubmix.com/)
|
||||
- [BurnCloud](https://ai.burncloud.com)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
- @\*o
|
||||
- [@ceilwoo](https://github.com/ceilwoo)
|
||||
- @\*说
|
||||
- @\*更
|
||||
- @K\*g
|
||||
- @R\*R
|
||||
- [@bobleer](https://github.com/bobleer)
|
||||
- @\*苗
|
||||
- @\*划
|
||||
- [@Clarence-pan](https://github.com/Clarence-pan)
|
||||
- [@carter003](https://github.com/carter003)
|
||||
- @S\*r
|
||||
- @\*晖
|
||||
- @\*敏
|
||||
- @Z\*z
|
||||
- @\*然
|
||||
- [@cluic](https://github.com/cluic)
|
||||
- @\*苗
|
||||
- [@PromptExpert](https://github.com/PromptExpert)
|
||||
- @\*应
|
||||
- [@yusnake](https://github.com/yusnake)
|
||||
- @\*飞
|
||||
- @董\*
|
||||
- @\*汀
|
||||
- @\*涯
|
||||
- @\*:-)
|
||||
- @\*\*磊
|
||||
- @\*琢
|
||||
- @\*成
|
||||
- @Z\*o
|
||||
- @\*琨
|
||||
- [@congzhangzh](https://github.com/congzhangzh)
|
||||
- @\*\_
|
||||
- @Z\*m
|
||||
- @\*鑫
|
||||
- @c\*y
|
||||
- @\*昕
|
||||
- [@witsice](https://github.com/witsice)
|
||||
- @b\*g
|
||||
- @\*亿
|
||||
- @\*辉
|
||||
- @JACK
|
||||
- @\*光
|
||||
- @W\*l
|
||||
- [@kesku](https://github.com/kesku)
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
- @\*咸
|
||||
- @\*明
|
||||
- @S\*y
|
||||
- @f\*o
|
||||
- @\*智
|
||||
- @F\*t
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
- @\*政
|
||||
- @\*铭
|
||||
- @\*叶
|
||||
- @七\*o
|
||||
- @\*青
|
||||
- @\*\*晨
|
||||
- @\*远
|
||||
- @\*霄
|
||||
- @\*\*吉
|
||||
- @\*\*飞
|
||||
- @\*\*驰
|
||||
- @x\*g
|
||||
<p>A huge thank you to all our sponsors for their generous support.</p>
|
||||
|
||||
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
|
||||
<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>
|
||||
<td align="center" width="330">
|
||||
<a href="https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default">
|
||||
<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://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi icon" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://s.qiniu.com/AVjMVf">
|
||||
<img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="Qiniu Cloud AI icon" />
|
||||
<br />
|
||||
<strong>Qiniu Cloud AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=9HHHAB5QLAES">
|
||||
<img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai icon" />
|
||||
<br />
|
||||
<strong>Fenno.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://unity2.ai/register?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/unity2.jpg" width="42" height="42" alt="Unity2.Ai icon" />
|
||||
<br />
|
||||
<strong>Unity2.Ai</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
|
||||
|
||||
|
|
|
|||
602
README_zh.md
|
|
@ -1,65 +1,94 @@
|
|||
<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 是给编程 Agent 用的本地控制平面。它为 Claude Code、Codex、Grok CLI、ZCode 以及兼容 API 客户端提供一个稳定的本地入口,然后由你在 CCR 中决定每个请求应该走哪个供应商、哪个模型、哪套路由策略、哪些工具能力和哪组账号凭据。
|
||||
|
||||
相比在每个 Agent、每个模型服务里反复改配置,CCR 把模型层收束到本机桌面应用里:供应商预设、自定义端点、凭据池、Fallback、Fusion 组合模型、MCP 工具、请求日志、账号用量和 Agent 启动配置都在一个地方管理。
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" alt="Claude Code Router Desktop 项目截图" />
|
||||
<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 能帮你做什么
|
||||
|
||||
CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://127.0.0.1:3456`。
|
||||
| 目标 | CCR 提供的能力 |
|
||||
| --- | --- |
|
||||
| 保持 Agent 工作流不变,同时自由切换模型 | 为 Claude Code、Codex、Grok CLI、ZCode 创建本地配置档案,支持 CLI / App 启动入口和按配置选择模型 |
|
||||
| 快速接入多个模型供应商 | 内置供应商预设、自定义 OpenAI / Anthropic / Gemini 兼容端点、协议探测、模型发现和连通性检测 |
|
||||
| 把路由变成可配置策略 | 内置 Agent 路由、条件规则、请求改写、模型前缀路由、自动重试和 Fallback 模型链 |
|
||||
| 控制成本和额度压力 | 凭据池、Key 轮换、本地限额、账号余额快照、Token / 成本仪表盘和托盘状态 |
|
||||
| 给稳定模型补能力 | 通过 Fusion 给基础模型叠加视觉、联网搜索或指定 MCP 工具 |
|
||||
| 让大量工具变得可用 | ToolHub 把多个 MCP server 收束成一个紧凑入口,让 Agent 按任务动态解析和调用工具 |
|
||||
| 排查每一次请求 | 请求日志、最终供应商 / 模型、耗时、Token、成本估算、网络捕获和 Agent 观测链路 |
|
||||
|
||||
## 为什么使用 CCR
|
||||
|
||||
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
|
||||
- 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。
|
||||
- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。
|
||||
- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。
|
||||
- 使用桌面 UI 管理配置,减少手写 JSON。
|
||||
- 通过插件、代理路由、本地 HTTP 后端和 Provider deeplink 扩展网关能力。
|
||||
- **一个本地网关,接管整套 Agent 模型层**:客户端只需要指向 CCR,模型、供应商、Key、路由和工具能力都可以在桌面 UI 中调整。
|
||||
- **换供应商,不换工作流**:支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Content / Interactions、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义兼容供应商。
|
||||
- **可见、可改、可验证的可靠性策略**:配置请求什么时候改写、重试或切到备用模型,并在本地日志里确认真实命中结果。
|
||||
- **面向 AI 工作流的运营视角**:从仪表盘或托盘查看请求量、Token、成本估算、成功率、延迟、模型分布、供应商用量和账号余额。
|
||||
- **Agent 原生工具与扩展**:使用 Fusion 扩展模型能力,通过 ToolHub 暴露动态 MCP 工具,让内置浏览器参与任务,通过 IM Bot 接力 Agent,或安装本地扩展。
|
||||
|
||||
## 功能和特性
|
||||
## 功能亮点
|
||||
|
||||
- **桌面控制台**:启动或停止本地网关,查看用量,配置托盘窗口和运行时设置。
|
||||
- **Provider 管理**:添加预设或自定义端点,检测连通性,管理凭据,并在可用时查看账号余额。
|
||||
- **路由规则**:配置默认、后台、thinking、长上下文、图片、Web Search、Subagent、模型前缀和条件路由。
|
||||
- **Agent Profiles**:为 Claude Code、Codex 和 ZCode 配置指向 CCR 网关的 Profile。
|
||||
- **网关兼容层**:通过本地 CCR wrapper 和 core gateway runtime 转换客户端请求。
|
||||
- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。
|
||||
- **插件系统**:安装或加载 wrapper 插件,包括 Claude Design、Cursor Proxy 这类集成路由。
|
||||
- **虚拟模型**:为客户端暴露模型别名或组合模型配置,适配固定模型名场景。
|
||||
- **Provider Deeplink**:通过 `ccr://provider?...` 链接导入 Provider 配置,写入前会弹出确认。
|
||||
- **Agent 配置档案**:为 Claude Code、Codex、Grok CLI 和 ZCode 创建配置档案,支持模型覆盖、作用范围、CLI / App 启动方式、环境变量和多开 App 工作流。
|
||||
- **供应商管理**:添加预设供应商或自定义端点;探测协议;发现模型列表;运行真实连通性检测;管理单 Key 或凭据池;在支持时导入本机 Agent 登录态。
|
||||
- **模型目录**:搜索全部已配置模型,编辑模型描述,并把这些描述用于 Claude Code Subagent、Task 和 Workflow 的模型选择提示。
|
||||
- **路由引擎**:组合内置 Agent 路由、请求 Header / Body 条件、模型前缀路由、请求改写、重试策略和有序 Fallback 目标。
|
||||
- **Fusion 组合模型**:发布可复用的虚拟模型,在保留基础模型手感的同时增加视觉、托管联网搜索或指定 MCP 工具。
|
||||
- **ToolHub**:把多个 MCP server 合并成一个动态 MCP server,让 Agent 只在任务需要时解析工具;桌面端还可暴露内置浏览器自动化和 Chrome 登录态导入。
|
||||
- **API Key 与限额**:创建访问 CCR 的客户端 Key,设置过期时间和本地请求 / Token / 图片限额,与上游供应商凭据分开管理。
|
||||
- **日志与观测**:查看请求 / 响应详情、最终供应商与模型、凭据、状态、耗时、Token、成本估算、工具调用和 Agent 执行链路。
|
||||
- **代理与网络捕获**:把 CCR 作为本地 HTTP / HTTPS 代理运行,可选安装 CA 证书,把支持的 API 流量接入 CCR,并保存网络请求用于排查。
|
||||
- **Bot 接力**:把 Agent 配置接入 Weixin iLink、企业微信、Slack、Discord、Telegram、LINE、飞书和钉钉等 IM 平台。
|
||||
- **扩展机制**:安装 wrapper plugin 和 core gateway plugin,注册本地路由、代理路由、供应商账号连接器、内置应用和虚拟模型。
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档见 [ccrdesk.top](https://ccrdesk.top/),其中包括 [CLI 命令参考](https://ccrdesk.top/guides/cli/) 和 [Docker 部署指南](https://ccrdesk.top/guides/docker/)。
|
||||
|
||||
## 下载和安装
|
||||
|
||||
### npm CLI
|
||||
|
||||
如果你只需要 CLI 版本,不需要桌面 Tray 或 `ccr://` 协议集成,可以安装 npm 包:
|
||||
|
||||
```bash
|
||||
npm install -g claude-code-router
|
||||
ccr start
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
ccr start # 后台启动 CCR 服务和 Web 管理端
|
||||
ccr stop # 停止后台 CCR 服务
|
||||
ccr <profile-name> cli # 以 CLI 方式启动保存的 profile
|
||||
ccr <profile-name> app # 以 App 方式启动保存的 profile
|
||||
```
|
||||
|
||||
Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host <host> --port <port>` 修改监听地址。
|
||||
|
||||
### 桌面应用
|
||||
|
||||
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
|
||||
2. 按系统下载对应安装包:
|
||||
- macOS Apple 芯片:`Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
|
||||
|
|
@ -69,14 +98,30 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host
|
|||
3. 安装并启动 **Claude Code Router**。
|
||||
4. 首次启动后,CCR 会创建本地配置数据库:
|
||||
- macOS/Linux:`~/.claude-code-router/config.sqlite`
|
||||
- Windows:`%APPDATA%\Claude Code Router\config.sqlite`
|
||||
- Windows:`%APPDATA%\claude-code-router\config.sqlite`
|
||||
|
||||
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
|
||||
|
||||
启用网关后,CCR 会启动两个本地服务:
|
||||
从 **服务** 页面启动后,CCR 默认监听 `http://127.0.0.1:3456`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
|
||||
|
||||
- CCR wrapper gateway:`http://127.0.0.1:3456`
|
||||
- Core gateway runtime:`http://127.0.0.1:3457`
|
||||
## CLI 与 Docker
|
||||
|
||||
npm CLI 要求 Node.js 22 或更高版本,不依赖 Electron,也能提供浏览器管理界面、模型网关和 Agent 配置启动命令:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr ui
|
||||
```
|
||||
|
||||
CLI 管理界面默认是 `http://127.0.0.1:3458`,模型网关默认是 `http://127.0.0.1:3456`。后台 / 前台服务、全部选项、Profile 启动、鉴权和数据位置见[完整 CLI 参考](https://ccrdesk.top/guides/cli/)。
|
||||
|
||||
如果要使用单一 Nginx 端口和持久化 Docker 数据卷运行管理 UI 与网关:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Docker 默认把管理和网关路径都发布在 `http://127.0.0.1:3458`。远程暴露前请先阅读 [Docker 部署指南](https://ccrdesk.top/guides/docker/),其中包含内部端口拓扑、管理与网关鉴权、`CCR_PUBLIC_BASE_URL`、数据卷、备份恢复、升级和健康检查。
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
|
@ -84,229 +129,306 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
|
|||
|
||||
### 1. 添加 Provider
|
||||
|
||||
打开 **Providers**,点击 **Add Provider**,选择内置预设或创建自定义 Provider。按表单填写 Provider 名称、端点、协议、API Key 和模型列表。可用时先运行连通性检测,然后保存 Provider。
|
||||
打开 **供应商**,点击 **添加供应商**,选择内置预设、导入支持的本机 Agent 登录态,或选择 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
|
||||
|
||||
### 2. 设置路由
|
||||
|
||||
打开 **Routing**,先选择默认路由要使用的 provider/model。然后根据需要设置后台任务、Thinking、长上下文、图片任务和 Web Search 等场景的专用模型。
|
||||
|
||||
如果需要更细粒度控制,使用 **Add Routing Rule** 添加模型前缀、Subagent、请求条件或 fallback 规则。
|
||||
打开 **路由**,启用内置 Agent 路由,添加条件规则,配置请求改写和失败降级。如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
|
||||
|
||||
### 3. 启动网关
|
||||
|
||||
打开 **Server**,点击 **Start** 启动本地网关。如果希望每次打开桌面应用时自动启动网关,可以启用 auto start。
|
||||
打开 **服务**,点击 **启动**。页面显示运行中后,CCR 默认会在本机监听 `http://127.0.0.1:3456`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
|
||||
|
||||
### 4. 连接 Agent 工具
|
||||
|
||||
打开 **Profiles**,选择要使用的客户端。通过表单配置 Claude Code、Codex 或 ZCode Profile,选择目标模型并应用配置。对于 App 类型的 Profile,可以使用页面里的操作按钮通过 CCR 打开目标应用。
|
||||
打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex、Grok CLI 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 通过 CCR 打开目标应用。
|
||||
|
||||
### 5. 日常查看和调整
|
||||
|
||||
使用 **Dashboard** 查看用量和 Provider 状态,使用托盘窗口快速查看 Token 和账号状态,使用 **Network Logs** 调试 Provider 行为,使用 **Extensions** 配置插件。
|
||||
|
||||
## Provider Deeplink
|
||||
|
||||
Provider 网站可以通过自定义协议打开 CCR 并导入模型服务配置:
|
||||
|
||||
```text
|
||||
ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions
|
||||
```
|
||||
|
||||
支持的 query 参数:
|
||||
|
||||
- `name`:Provider 展示名称。
|
||||
- `base_url`:Provider API Base URL。
|
||||
- `api_key`:可选 Provider API Key。
|
||||
- `models`:逗号或换行分隔的模型列表,也可以重复传入 `models=...`。
|
||||
- `protocol`:`openai_chat_completions`、`openai_responses`、`anthropic_messages` 或 `gemini_generate_content`。
|
||||
|
||||
更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段。CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。
|
||||
|
||||
## 插件
|
||||
|
||||
CCR 有两层插件:
|
||||
|
||||
- Core gateway plugins:使用 `providerPlugins` 和 `virtualModelProfiles`,会透传给 core gateway。
|
||||
- Wrapper plugins:使用顶层 `plugins` 扩展 Electron wrapper,注册本地 HTTP 后端、添加 gateway route,或把代理模式流量路由到插件后端。
|
||||
|
||||
Wrapper plugin route 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "local-admin-api",
|
||||
"enabled": true,
|
||||
"proxy": {
|
||||
"routes": [
|
||||
{
|
||||
"id": "admin-api",
|
||||
"host": "api.example.com",
|
||||
"paths": ["/v1/admin"],
|
||||
"upstream": "http://127.0.0.1:4510",
|
||||
"stripPathPrefix": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
插件模块需要导出函数或包含 `setup(ctx)` 的对象。上下文支持:
|
||||
|
||||
- `ctx.registerGatewayRoute({ method, path, auth, handler })`
|
||||
- `ctx.registerHttpBackend({ id, host, port, handler })`
|
||||
- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
|
||||
- `ctx.openSqliteStore({ filename, migrate })`
|
||||
- `ctx.registerCoreGatewayProviderPlugin(plugin)`
|
||||
- `ctx.registerCoreGatewayVirtualModelProfile(profile)`
|
||||
|
||||
本地插件示例见 [examples/plugins](examples/plugins)。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run typecheck
|
||||
npm run build:assets
|
||||
npm run build:app:mac
|
||||
npm run build:app:win
|
||||
```
|
||||
|
||||
`npm run build:assets` 会把 Electron main process 和 renderer assets 编译到 `dist/`。
|
||||
|
||||
`npm run build` 会为当前平台打包应用,并把安装包写入 `release/`。
|
||||
|
||||
`npm run build:app:mac` 和 `npm run build:app:win` 会分别打包对应平台的应用产物。Linux AppImage 打包配置在 `electron-builder.json` 中。
|
||||
|
||||
`npm run build:app:mac` 会在 `release-local/` 生成本地测试用 macOS 包,使用 ad-hoc 签名。它适合免费 Apple Account 或只有 Apple Development 证书的本机测试,但不适合公开分发,因为用户下载后仍无法通过 Gatekeeper 公证检查。
|
||||
|
||||
macOS 发布包会使用 Developer ID 签名并提交 Apple 公证。运行 `npm run build:app:mac:release` 前,打包机器必须具备:可用的 `Developer ID Application` 证书(在 keychain 中,或通过 `CSC_LINK`/`CSC_KEY_PASSWORD` 提供)、已通过 `xcode-select` 选择完整 Xcode,以及下面任意一组公证凭据:
|
||||
|
||||
- `APPLE_API_KEY`、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`
|
||||
- `APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID`
|
||||
- `APPLE_KEYCHAIN_PROFILE`,可选 `APPLE_KEYCHAIN`
|
||||
|
||||
macOS 打包 hook 会在产物生成前验证代码签名、公证票据 stapling 和 Gatekeeper 评估,避免发布未公证的安装包。
|
||||
|
||||
打包后的应用会通过 `electron-updater` 检查 GitHub Releases。测试本地更新源时,可以在启动应用前设置 `CCR_UPDATE_FEED_URL` 为 generic electron-updater feed URL。`CCR_UPDATE_ALLOW_PRERELEASE=1` 可以启用 prerelease 更新。
|
||||
|
||||
## 深入阅读
|
||||
|
||||
- [项目动机和工作原理](blog/zh/项目初衷及原理.md)
|
||||
- [也许我们可以用路由器做更多事情](blog/zh/或许我们能在Router中做更多事情.md)
|
||||
到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用概览仪表盘和托盘窗口查看 Token、成本、模型分布和账号状态。
|
||||
|
||||
## 致谢
|
||||
|
||||
对 Codex 的支持以及 Bot handoff 来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
|
||||
对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
|
||||
|
||||
## 支持与赞助
|
||||
|
||||
如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。
|
||||
<div align="center">
|
||||
|
||||
[](https://ko-fi.com/F1F31GN2GM)
|
||||
|
||||
[Paypal](https://paypal.me/musistudio1999)
|
||||
<p>如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
|
||||
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
|
||||
<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">
|
||||
|
||||
- [AIHubmix](https://aihubmix.com/)
|
||||
- [BurnCloud](https://ai.burncloud.com)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
- @\*o
|
||||
- [@ceilwoo](https://github.com/ceilwoo)
|
||||
- @\*说
|
||||
- @\*更
|
||||
- @K\*g
|
||||
- @R\*R
|
||||
- [@bobleer](https://github.com/bobleer)
|
||||
- @\*苗
|
||||
- @\*划
|
||||
- [@Clarence-pan](https://github.com/Clarence-pan)
|
||||
- [@carter003](https://github.com/carter003)
|
||||
- @S\*r
|
||||
- @\*晖
|
||||
- @\*敏
|
||||
- @Z\*z
|
||||
- @\*然
|
||||
- [@cluic](https://github.com/cluic)
|
||||
- @\*苗
|
||||
- [@PromptExpert](https://github.com/PromptExpert)
|
||||
- @\*应
|
||||
- [@yusnake](https://github.com/yusnake)
|
||||
- @\*飞
|
||||
- @董\*
|
||||
- @\*汀
|
||||
- @\*涯
|
||||
- @\*:-)
|
||||
- @\*\*磊
|
||||
- @\*琢
|
||||
- @\*成
|
||||
- @Z\*o
|
||||
- @\*琨
|
||||
- [@congzhangzh](https://github.com/congzhangzh)
|
||||
- @\*\_
|
||||
- @Z\*m
|
||||
- @\*鑫
|
||||
- @c\*y
|
||||
- @\*昕
|
||||
- [@witsice](https://github.com/witsice)
|
||||
- @b\*g
|
||||
- @\*亿
|
||||
- @\*辉
|
||||
- @JACK
|
||||
- @\*光
|
||||
- @W\*l
|
||||
- [@kesku](https://github.com/kesku)
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
- @\*咸
|
||||
- @\*明
|
||||
- @S\*y
|
||||
- @f\*o
|
||||
- @\*智
|
||||
- @F\*t
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
- @\*政
|
||||
- @\*铭
|
||||
- @\*叶
|
||||
- @七\*o
|
||||
- @\*青
|
||||
- @\*\*晨
|
||||
- @\*远
|
||||
- @\*霄
|
||||
- @\*\*吉
|
||||
- @\*\*飞
|
||||
- @\*\*驰
|
||||
- @x\*g
|
||||
<p>非常感谢所有赞助商的慷慨支持。</p>
|
||||
|
||||
(如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。)
|
||||
<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>
|
||||
<td align="center" width="330">
|
||||
<a href="https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default">
|
||||
<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://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&utm_medium=partner&utm_campaign=claudecoderouter_2026&utm_content=default">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi 图标" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://s.qiniu.com/AVjMVf">
|
||||
<img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="七牛云 AI 图标" />
|
||||
<br />
|
||||
<strong>七牛云 AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=9HHHAB5QLAES">
|
||||
<img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai 图标" />
|
||||
<br />
|
||||
<strong>Fenno.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://unity2.ai/register?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/unity2.jpg" width="42" height="42" alt="Unity2.Ai 图标" />
|
||||
<br />
|
||||
<strong>Unity2.Ai</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>
|
||||
|
||||
## 许可证
|
||||
|
||||
|
|
|
|||
32
benchmarks/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Request log benchmarks
|
||||
|
||||
Run the request-log benchmark with the same Electron/Node native runtime selection used by the test runner:
|
||||
|
||||
```sh
|
||||
npm run benchmark:request-logs -- --label local
|
||||
```
|
||||
|
||||
To persist a machine-readable result:
|
||||
|
||||
```sh
|
||||
npm run benchmark:request-logs -- --label local --output benchmarks/results/request-log-local.json
|
||||
```
|
||||
|
||||
The benchmark has two layers:
|
||||
|
||||
- A request-log microbenchmark covering small records without a route trace, small records with a 10-hop route trace, 2 MB request bodies with a 10-hop trace, and 8 MB Base64 image requests.
|
||||
- A real loopback HTTP service benchmark comparing no logging, synchronous main-thread SQLite logging, and the asynchronous worker logger.
|
||||
|
||||
The HTTP benchmark uses a separate load-generator process so client work does not share the server event loop. It reports QPS, durable QPS, p50/p95/p99/max latency, HTTP errors, rejected log events, event-loop p99/max delay, CPU, heap, and RSS changes.
|
||||
|
||||
The default web load is 10,000 requests with concurrency 64 and a 1 KB request body. It can be adjusted without editing code:
|
||||
|
||||
```sh
|
||||
npm run benchmark:request-logs -- \
|
||||
--web-requests 20000 \
|
||||
--web-concurrency 128 \
|
||||
--web-body-bytes 4096 \
|
||||
--label web-load
|
||||
```
|
||||
|
||||
Use `--skip-web` when only the storage microbenchmark is needed, or `--skip-storage` when iterating only on HTTP performance. Both layers report request-path time separately from durable flush time so moving work off the request thread cannot hide storage regressions.
|
||||
87
benchmarks/results/request-log-base64-image.json
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"architecture": "worker-runtime",
|
||||
"cpu": "arm64",
|
||||
"label": "base64-image-object-scoped",
|
||||
"node": "v24.16.0",
|
||||
"platform": "darwin",
|
||||
"results": [
|
||||
{
|
||||
"accepted": 2000,
|
||||
"batchP99Ms": 1.632,
|
||||
"degraded": 0,
|
||||
"dropped": 0,
|
||||
"durableMs": 64.936,
|
||||
"durableRecordsPerSecond": 30799.754,
|
||||
"eventLoopMaxMs": 2.365,
|
||||
"eventLoopP99Ms": 1.831,
|
||||
"flushMs": 47.56,
|
||||
"heapDeltaMb": -0.89,
|
||||
"name": "small-no-trace",
|
||||
"records": 2000,
|
||||
"requestPathMs": 17.375,
|
||||
"requestPathRecordsPerSecond": 115108.464,
|
||||
"rssDeltaMb": 21.203
|
||||
},
|
||||
{
|
||||
"accepted": 1000,
|
||||
"batchP99Ms": 1.15,
|
||||
"degraded": 0,
|
||||
"dropped": 0,
|
||||
"durableMs": 58.399,
|
||||
"durableRecordsPerSecond": 17123.581,
|
||||
"eventLoopMaxMs": 2.195,
|
||||
"eventLoopP99Ms": 2.195,
|
||||
"flushMs": 45.398,
|
||||
"heapDeltaMb": 4.493,
|
||||
"name": "small-10-hops",
|
||||
"records": 1000,
|
||||
"requestPathMs": 13.001,
|
||||
"requestPathRecordsPerSecond": 76915.681,
|
||||
"rssDeltaMb": 8.891
|
||||
},
|
||||
{
|
||||
"accepted": 50,
|
||||
"batchP99Ms": 43.127,
|
||||
"degraded": 50,
|
||||
"dropped": 0,
|
||||
"durableMs": 170.224,
|
||||
"durableRecordsPerSecond": 293.73,
|
||||
"eventLoopMaxMs": 1.916,
|
||||
"eventLoopP99Ms": 1.628,
|
||||
"flushMs": 91.361,
|
||||
"heapDeltaMb": 36.715,
|
||||
"name": "large-2mb-10-hops",
|
||||
"records": 50,
|
||||
"requestPathMs": 78.863,
|
||||
"requestPathRecordsPerSecond": 634.013,
|
||||
"rssDeltaMb": 135.344
|
||||
},
|
||||
{
|
||||
"accepted": 10,
|
||||
"batchP99Ms": 10.85,
|
||||
"degraded": 10,
|
||||
"dropped": 0,
|
||||
"durableMs": 16.432,
|
||||
"durableRecordsPerSecond": 608.572,
|
||||
"eventLoopMaxMs": 1.2,
|
||||
"eventLoopP99Ms": 1.2,
|
||||
"flushMs": 5.581,
|
||||
"heapDeltaMb": 0.062,
|
||||
"name": "base64-image-8mb",
|
||||
"records": 10,
|
||||
"requestPathMs": 10.851,
|
||||
"requestPathRecordsPerSecond": 921.595,
|
||||
"rssDeltaMb": 0
|
||||
}
|
||||
],
|
||||
"storageSkipped": false,
|
||||
"timestamp": "2026-07-15T01:25:30.154Z",
|
||||
"web": {
|
||||
"bodyBytes": 1024,
|
||||
"concurrency": 64,
|
||||
"requests": 10000,
|
||||
"results": [],
|
||||
"skipped": true
|
||||
},
|
||||
"runtime": "electron"
|
||||
}
|
||||
62
benchmarks/results/request-log-baseline.json
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"architecture": "main-thread-store",
|
||||
"cpu": "arm64",
|
||||
"label": "baseline",
|
||||
"node": "v24.16.0",
|
||||
"platform": "darwin",
|
||||
"results": [
|
||||
{
|
||||
"accepted": 2000,
|
||||
"batchP99Ms": 6.366,
|
||||
"degraded": 0,
|
||||
"dropped": 0,
|
||||
"durableMs": 201.032,
|
||||
"durableRecordsPerSecond": 9948.653,
|
||||
"eventLoopMaxMs": 6.365,
|
||||
"eventLoopP99Ms": 4.239,
|
||||
"flushMs": 0.002,
|
||||
"heapDeltaMb": 1.586,
|
||||
"name": "small-no-trace",
|
||||
"records": 2000,
|
||||
"requestPathMs": 201.03,
|
||||
"requestPathRecordsPerSecond": 9948.756,
|
||||
"rssDeltaMb": 18.484
|
||||
},
|
||||
{
|
||||
"accepted": 1000,
|
||||
"batchP99Ms": 7.765,
|
||||
"degraded": 0,
|
||||
"dropped": 0,
|
||||
"durableMs": 168.879,
|
||||
"durableRecordsPerSecond": 5921.412,
|
||||
"eventLoopMaxMs": 7.782,
|
||||
"eventLoopP99Ms": 7.782,
|
||||
"flushMs": 0.003,
|
||||
"heapDeltaMb": -1.761,
|
||||
"name": "small-10-hops",
|
||||
"records": 1000,
|
||||
"requestPathMs": 168.875,
|
||||
"requestPathRecordsPerSecond": 5921.534,
|
||||
"rssDeltaMb": 10.172
|
||||
},
|
||||
{
|
||||
"accepted": 50,
|
||||
"batchP99Ms": 203.52,
|
||||
"degraded": 0,
|
||||
"dropped": 0,
|
||||
"durableMs": 397.142,
|
||||
"durableRecordsPerSecond": 125.9,
|
||||
"eventLoopMaxMs": 204.079,
|
||||
"eventLoopP99Ms": 204.079,
|
||||
"flushMs": 0.003,
|
||||
"heapDeltaMb": 2.151,
|
||||
"name": "large-2mb-10-hops",
|
||||
"records": 50,
|
||||
"requestPathMs": 397.139,
|
||||
"requestPathRecordsPerSecond": 125.901,
|
||||
"rssDeltaMb": 130.203
|
||||
}
|
||||
],
|
||||
"timestamp": "2026-07-14T23:42:58.245Z",
|
||||
"runtime": "electron"
|
||||
}
|
||||
43
benchmarks/results/request-log-comparison.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Request log performance comparison
|
||||
|
||||
Environment: Apple arm64, Electron Node v24.16.0, SQLite WAL, identical benchmark scenarios and batch sizes.
|
||||
|
||||
| Scenario | Metric | Baseline | Optimized | Change |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Small, no trace | Request-path throughput | 9,949 records/s | 173,695 records/s | 17.5x |
|
||||
| Small, no trace | Durable throughput | 9,949 records/s | 34,267 records/s | 3.4x |
|
||||
| Small, no trace | Event-loop p99 | 4.24 ms | 1.61 ms | -62.1% |
|
||||
| Small, 10 hops | Request-path throughput | 5,922 records/s | 66,716 records/s | 11.3x |
|
||||
| Small, 10 hops | Durable throughput | 5,921 records/s | 17,640 records/s | 3.0x |
|
||||
| Small, 10 hops | Event-loop p99 | 7.78 ms | 1.30 ms | -83.3% |
|
||||
| 2 MB, 10 hops | Request-path throughput | 126 records/s | 553 records/s | 4.4x |
|
||||
| 2 MB, 10 hops | Durable throughput | 126 records/s | 336 records/s | 2.7x |
|
||||
| 2 MB, 10 hops | Event-loop max | 204.08 ms | 2.04 ms | -99.0% |
|
||||
| 2 MB, 10 hops | RSS delta | 130.20 MB | 66.58 MB | -48.9% |
|
||||
|
||||
The optimized 2 MB result above was measured with the then-current 512 KB default, so it is intentionally marked degraded. Production now retains up to the 50 MB hard safety ceiling for each request/response body by default while preserving original size and truncation metadata.
|
||||
|
||||
Raw measurements:
|
||||
|
||||
- `request-log-baseline.json`
|
||||
- `request-log-optimized.json`
|
||||
|
||||
## HTTP service benchmark
|
||||
|
||||
The HTTP benchmark sends 10,000 POST requests with concurrency 64 and a 1 KB body from a separate load-generator process.
|
||||
|
||||
| Mode | QPS | Durable QPS | p50 | p95 | p99 | Event-loop p99 | Errors | Rejected logs |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| No logging control | 32,878 | 32,877 | 1.93 ms | 2.34 ms | 3.02 ms | 1.96 ms | 0 | 0 |
|
||||
| Synchronous main-thread SQLite | 9,609 | 9,603 | 5.67 ms | 11.44 ms | 13.51 ms | 10.53 ms | 0 | 0 |
|
||||
| Asynchronous worker logger | 31,879 | 25,757 | 2.02 ms | 3.01 ms | 6.20 ms | 2.06 ms | 0 | 0 |
|
||||
|
||||
Compared with synchronous main-thread logging, the asynchronous worker delivers 3.3x request QPS, 2.7x durable QPS, 54.1% lower HTTP p99 latency, and 80.5% lower event-loop p99 delay. Its request-path QPS is 3.0% below the no-logging control, and all 10,000 log events were accepted.
|
||||
|
||||
The first unrestricted-network run also exposed a linear scan through the downloaded model-pricing catalog, which limited durable logging to roughly 109 records/s. Building a source-and-model price index once per catalog load removed that bottleneck before the final measurements above.
|
||||
|
||||
## Base64 image benchmark
|
||||
|
||||
The image scenario reuses an already-received 8 MB JSON request body containing an inline Base64 image, matching the point where request logging starts. Ten records were accepted and compacted. Request-path throughput was 922 records/s, durable throughput was 609 records/s, and no RSS growth was observed in the measured run. The stored JSON retains its structure, MIME information, original wire size, and an image-size descriptor without retaining the encoded pixels. MIME and encoding context are scoped to the same JSON object, so adjacent audio payloads are not compacted.
|
||||
|
||||
Raw measurement: `request-log-base64-image.json`.
|
||||
149
benchmarks/results/request-log-optimized.json
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"architecture": "worker-runtime",
|
||||
"cpu": "arm64",
|
||||
"label": "optimized-final",
|
||||
"node": "v24.16.0",
|
||||
"platform": "darwin",
|
||||
"results": [
|
||||
{
|
||||
"accepted": 2000,
|
||||
"batchP99Ms": 0.704,
|
||||
"degraded": 0,
|
||||
"dropped": 0,
|
||||
"durableMs": 58.366,
|
||||
"durableRecordsPerSecond": 34266.696,
|
||||
"eventLoopMaxMs": 2.052,
|
||||
"eventLoopP99Ms": 1.605,
|
||||
"flushMs": 46.851,
|
||||
"heapDeltaMb": 1.279,
|
||||
"name": "small-no-trace",
|
||||
"records": 2000,
|
||||
"requestPathMs": 11.514,
|
||||
"requestPathRecordsPerSecond": 173695.29,
|
||||
"rssDeltaMb": 16
|
||||
},
|
||||
{
|
||||
"accepted": 1000,
|
||||
"batchP99Ms": 2.092,
|
||||
"degraded": 0,
|
||||
"dropped": 0,
|
||||
"durableMs": 56.691,
|
||||
"durableRecordsPerSecond": 17639.614,
|
||||
"eventLoopMaxMs": 2.347,
|
||||
"eventLoopP99Ms": 1.302,
|
||||
"flushMs": 41.702,
|
||||
"heapDeltaMb": 3.953,
|
||||
"name": "small-10-hops",
|
||||
"records": 1000,
|
||||
"requestPathMs": 14.989,
|
||||
"requestPathRecordsPerSecond": 66716.335,
|
||||
"rssDeltaMb": 6.313
|
||||
},
|
||||
{
|
||||
"accepted": 50,
|
||||
"batchP99Ms": 46.632,
|
||||
"degraded": 50,
|
||||
"dropped": 0,
|
||||
"durableMs": 148.846,
|
||||
"durableRecordsPerSecond": 335.918,
|
||||
"eventLoopMaxMs": 2.036,
|
||||
"eventLoopP99Ms": 1.665,
|
||||
"flushMs": 58.449,
|
||||
"heapDeltaMb": -2.104,
|
||||
"name": "large-2mb-10-hops",
|
||||
"records": 50,
|
||||
"requestPathMs": 90.396,
|
||||
"requestPathRecordsPerSecond": 553.12,
|
||||
"rssDeltaMb": 66.578
|
||||
}
|
||||
],
|
||||
"storageSkipped": false,
|
||||
"timestamp": "2026-07-15T00:58:51.159Z",
|
||||
"web": {
|
||||
"bodyBytes": 1024,
|
||||
"concurrency": 64,
|
||||
"requests": 10000,
|
||||
"results": [
|
||||
{
|
||||
"bodyBytes": 1024,
|
||||
"concurrency": 64,
|
||||
"cpuSystemMs": 48.203,
|
||||
"cpuTotalPercent": 50.988,
|
||||
"cpuUserMs": 106.881,
|
||||
"durableMs": 304.161,
|
||||
"durableQps": 32877.361,
|
||||
"errors": 0,
|
||||
"eventLoopMaxMs": 2.017,
|
||||
"eventLoopP99Ms": 1.959,
|
||||
"flushMs": 0.002,
|
||||
"heapDeltaMb": 0.561,
|
||||
"latencyAvgMs": 1.915,
|
||||
"latencyMaxMs": 9.838,
|
||||
"latencyP50Ms": 1.927,
|
||||
"latencyP95Ms": 2.341,
|
||||
"latencyP99Ms": 3.021,
|
||||
"logAccepted": 0,
|
||||
"logRejected": 0,
|
||||
"mode": "control",
|
||||
"qps": 32877.509,
|
||||
"requestPathMs": 304.159,
|
||||
"requests": 10000,
|
||||
"rssDeltaMb": 3.531
|
||||
},
|
||||
{
|
||||
"bodyBytes": 1024,
|
||||
"concurrency": 64,
|
||||
"cpuSystemMs": 521.521,
|
||||
"cpuTotalPercent": 90.135,
|
||||
"cpuUserMs": 416.51,
|
||||
"durableMs": 1041.303,
|
||||
"durableQps": 9603.352,
|
||||
"errors": 0,
|
||||
"eventLoopMaxMs": 13.369,
|
||||
"eventLoopP99Ms": 10.527,
|
||||
"flushMs": 0.609,
|
||||
"heapDeltaMb": -2.815,
|
||||
"latencyAvgMs": 6.612,
|
||||
"latencyMaxMs": 24.691,
|
||||
"latencyP50Ms": 5.67,
|
||||
"latencyP95Ms": 11.436,
|
||||
"latencyP99Ms": 13.508,
|
||||
"logAccepted": 10000,
|
||||
"logRejected": 0,
|
||||
"mode": "sync-main-thread",
|
||||
"qps": 9608.974,
|
||||
"requestPathMs": 1040.694,
|
||||
"requests": 10000,
|
||||
"rssDeltaMb": -12.125
|
||||
},
|
||||
{
|
||||
"bodyBytes": 1024,
|
||||
"concurrency": 64,
|
||||
"cpuSystemMs": 126.52,
|
||||
"cpuTotalPercent": 146.645,
|
||||
"cpuUserMs": 333.485,
|
||||
"durableMs": 388.251,
|
||||
"durableQps": 25756.503,
|
||||
"errors": 0,
|
||||
"eventLoopMaxMs": 2.269,
|
||||
"eventLoopP99Ms": 2.055,
|
||||
"flushMs": 74.564,
|
||||
"heapDeltaMb": -5.156,
|
||||
"latencyAvgMs": 1.967,
|
||||
"latencyMaxMs": 11.159,
|
||||
"latencyP50Ms": 2.018,
|
||||
"latencyP95Ms": 3.007,
|
||||
"latencyP99Ms": 6.195,
|
||||
"logAccepted": 10000,
|
||||
"logRejected": 0,
|
||||
"mode": "async-worker",
|
||||
"qps": 31878.941,
|
||||
"requestPathMs": 313.687,
|
||||
"requests": 10000,
|
||||
"rssDeltaMb": 4.516
|
||||
}
|
||||
],
|
||||
"skipped": false
|
||||
},
|
||||
"runtime": "electron"
|
||||
}
|
||||
129
build/benchmark-request-logs.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import esbuild from "esbuild";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const buildDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(buildDir, "..");
|
||||
const outputDir = path.join(projectRoot, ".benchmark-dist", "request-logs");
|
||||
const outputFile = path.join(outputDir, "request-log-runtime.bench.js");
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
rmSync(outputDir, { force: true, recursive: true });
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await esbuild.build({
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryPoints: {
|
||||
"request-log-runtime.bench": path.join(projectRoot, "packages/core/benchmark/request-log-runtime.bench.mjs"),
|
||||
"request-log-web-load": path.join(projectRoot, "packages/core/benchmark/request-log-web-load.mjs"),
|
||||
"request-log-worker": path.join(projectRoot, "packages/core/src/observability/request-log-worker.ts")
|
||||
},
|
||||
external: ["better-sqlite3", "electron", "undici"],
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "warning",
|
||||
outdir: outputDir,
|
||||
platform: "node",
|
||||
plugins: [packageAliasPlugin()],
|
||||
target: "node22"
|
||||
});
|
||||
|
||||
const runtime = resolveRuntime();
|
||||
const executable = runtime === "electron"
|
||||
? (await import("electron")).default
|
||||
: process.execPath;
|
||||
const child = spawnSync(executable, [outputFile], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_REQUEST_LOG_BENCHMARK_LABEL: args.label,
|
||||
CCR_REQUEST_LOG_BENCHMARK_SKIP_STORAGE: args.skipStorage ? "1" : "0",
|
||||
CCR_REQUEST_LOG_BENCHMARK_SKIP_WEB: args.skipWeb ? "1" : "0",
|
||||
CCR_REQUEST_LOG_BENCHMARK_WEB_BODY_BYTES: args.webBodyBytes,
|
||||
CCR_REQUEST_LOG_BENCHMARK_WEB_CONCURRENCY: args.webConcurrency,
|
||||
CCR_REQUEST_LOG_BENCHMARK_WEB_REQUESTS: args.webRequests,
|
||||
...(runtime === "electron" ? { ELECTRON_RUN_AS_NODE: "1" } : {})
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "inherit"]
|
||||
});
|
||||
if (child.status !== 0) {
|
||||
process.stderr.write(child.stdout || `benchmark exited with ${child.status}\n`);
|
||||
process.exitCode = child.status ?? 1;
|
||||
} else {
|
||||
const result = JSON.parse(child.stdout);
|
||||
result.runtime = runtime;
|
||||
const json = `${JSON.stringify(result, null, 2)}\n`;
|
||||
process.stdout.write(json);
|
||||
if (args.output) {
|
||||
const destination = path.resolve(projectRoot, args.output);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
writeFileSync(destination, json, "utf8");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(outputDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
function parseArgs(values) {
|
||||
const parsed = {
|
||||
label: "benchmark",
|
||||
output: "",
|
||||
skipStorage: false,
|
||||
skipWeb: false,
|
||||
webBodyBytes: "1024",
|
||||
webConcurrency: "64",
|
||||
webRequests: "10000"
|
||||
};
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (values[index] === "--label") parsed.label = values[index + 1] || parsed.label;
|
||||
if (values[index] === "--output") parsed.output = values[index + 1] || "";
|
||||
if (values[index] === "--skip-web") parsed.skipWeb = true;
|
||||
if (values[index] === "--skip-storage") parsed.skipStorage = true;
|
||||
if (values[index] === "--web-body-bytes") parsed.webBodyBytes = values[index + 1] || parsed.webBodyBytes;
|
||||
if (values[index] === "--web-concurrency") parsed.webConcurrency = values[index + 1] || parsed.webConcurrency;
|
||||
if (values[index] === "--web-requests") parsed.webRequests = values[index + 1] || parsed.webRequests;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveRuntime() {
|
||||
const probe = spawnSync(process.execPath, [
|
||||
"-e",
|
||||
"const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.close();"
|
||||
], { stdio: "ignore" });
|
||||
return probe.status === 0 ? "node" : "electron";
|
||||
}
|
||||
|
||||
function packageAliasPlugin() {
|
||||
const roots = {
|
||||
cli: path.join(projectRoot, "packages/cli/src"),
|
||||
core: path.join(projectRoot, "packages/core/src"),
|
||||
electron: path.join(projectRoot, "packages/electron/src"),
|
||||
ui: path.join(projectRoot, "packages/ui/src")
|
||||
};
|
||||
return {
|
||||
name: "request-log-benchmark-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@ccr\/(cli|core|electron|ui)\// }, (resolveArgs) => {
|
||||
const match = resolveArgs.path.match(/^@ccr\/(cli|core|electron|ui)\/(.+)$/);
|
||||
if (!match) return undefined;
|
||||
return { path: resolvePackageImport(roots[match[1]], match[2]) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePackageImport(root, importPath) {
|
||||
const base = path.resolve(root, importPath);
|
||||
for (const candidate of [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.mjs`, path.join(base, "index.ts")]) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
@ -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.`);
|
||||
|
|
|
|||
462
build/dev.mjs
|
|
@ -2,15 +2,17 @@ import electron from "electron";
|
|||
import esbuild from "esbuild";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
binPath,
|
||||
buildStyles,
|
||||
cleanDist,
|
||||
browserRendererHtmlInput,
|
||||
cliSourceRoot,
|
||||
coreSourceRoot,
|
||||
copyAppAssets,
|
||||
copyBrowserRendererHtml,
|
||||
copyCliRuntimeToElectronDist,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
|
|
@ -21,23 +23,30 @@ import {
|
|||
createRendererBuildOptions,
|
||||
createTrayRendererBuildOptions,
|
||||
createWebClientBridgeBuildOptions,
|
||||
cssInput,
|
||||
cssOutput,
|
||||
appAssetsInput,
|
||||
modelCatalogInput,
|
||||
projectRoot,
|
||||
rendererRoot,
|
||||
rendererHtmlInput,
|
||||
syncUiRendererToRuntimeDists,
|
||||
trayRendererHtmlInput,
|
||||
watchPlugin
|
||||
} from "./esbuild.config.mjs";
|
||||
|
||||
let electronProcess = null;
|
||||
let restartTimer = null;
|
||||
let restartInFlight = false;
|
||||
let restartQueued = false;
|
||||
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 +55,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,17 +92,11 @@ function relativePath(file) {
|
|||
|
||||
function readyState() {
|
||||
return Object.entries(ready)
|
||||
.filter(([name]) => activeReadyNames.has(name))
|
||||
.map(([name, value]) => `${name}:${value ? "ready" : "pending"}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) {
|
||||
const changedPath = filename
|
||||
? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename))
|
||||
: watchedPath;
|
||||
return `${label} ${eventType} ${relativePath(changedPath)}`;
|
||||
}
|
||||
|
||||
function contentSignature(targetPath) {
|
||||
try {
|
||||
return readContentSignature(targetPath);
|
||||
|
|
@ -143,27 +172,124 @@ function listDirectoryFiles(targetPath, basePath = targetPath) {
|
|||
return files;
|
||||
}
|
||||
|
||||
function rememberWatchSignature(label, targetPath) {
|
||||
const signature = contentSignature(targetPath);
|
||||
function rememberWatchSignature(label, targetPath, options = {}) {
|
||||
const signature = options.metadataOnly
|
||||
? metadataSignature(targetPath)
|
||||
: contentSignature(targetPath);
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`);
|
||||
}
|
||||
|
||||
function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) {
|
||||
const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory);
|
||||
const signature = contentSignature(watchedPath);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
const changed = previousSignature !== signature.key;
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`);
|
||||
function scheduleStyleBuild(reason) {
|
||||
queuedStyleBuildReason = reason;
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
styleBuildTimer = setTimeout(() => {
|
||||
styleBuildTimer = null;
|
||||
void rebuildStyles(queuedStyleBuildReason ?? reason);
|
||||
}, styleBuildDelayMs);
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
logDev(`restart skipped: ${reason} (content unchanged)`);
|
||||
async function rebuildStyles(reason) {
|
||||
if (styleBuildInFlight) {
|
||||
queuedStyleBuildReason = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
onChange();
|
||||
scheduleRestart(reason);
|
||||
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 pollWatchedInput(label, targetPath, onChange, options = {}) {
|
||||
const signature = options.metadataOnly
|
||||
? metadataSignature(targetPath)
|
||||
: contentSignature(targetPath);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
if (previousSignature === signature.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`);
|
||||
try {
|
||||
onChange();
|
||||
if (enabled.electron && options.restart !== false) {
|
||||
scheduleRestart(label);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logDev(`watch action failed: ${label}; ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function metadataSignature(targetPath) {
|
||||
if (!existsSync(targetPath)) {
|
||||
return {
|
||||
key: "missing",
|
||||
summary: "missing"
|
||||
};
|
||||
}
|
||||
|
||||
const stats = statSync(targetPath);
|
||||
return {
|
||||
key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`,
|
||||
summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}`
|
||||
};
|
||||
}
|
||||
|
||||
function pollSourceWatchTargets() {
|
||||
pollWatchedInput("home html", rendererHtmlInput, () => {
|
||||
copyRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
pollWatchedInput("browser html", browserRendererHtmlInput, () => {
|
||||
copyBrowserRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
pollWatchedInput("tray html", trayRendererHtmlInput, () => {
|
||||
copyTrayRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
if (enabled.electron) {
|
||||
pollWatchedInput("app assets", appAssetsInput, copyAppAssets);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
function markReady(name, reason = `${name} esbuild completed`) {
|
||||
|
|
@ -171,7 +297,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -191,144 +317,200 @@ function scheduleRestart(reason = "unknown trigger") {
|
|||
restartTimer = setTimeout(restartElectron, restartDelayMs);
|
||||
}
|
||||
|
||||
function restartElectron() {
|
||||
async function restartElectron() {
|
||||
if (restartInFlight) {
|
||||
restartQueued = true;
|
||||
return;
|
||||
}
|
||||
restartInFlight = true;
|
||||
const reasons = Array.from(new Set(pendingRestartReasons));
|
||||
pendingRestartReasons = [];
|
||||
restartTimer = null;
|
||||
|
||||
if (electronProcess) {
|
||||
logDev(`stopping Electron pid=${electronProcess.pid ?? "unknown"}`);
|
||||
electronProcess.kill();
|
||||
electronProcess = null;
|
||||
}
|
||||
try {
|
||||
if (electronProcess) {
|
||||
await stopElectron(electronProcess);
|
||||
}
|
||||
|
||||
logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`);
|
||||
const child = spawn(electron, ["."], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "development"
|
||||
},
|
||||
stdio: "inherit"
|
||||
});
|
||||
electronProcess = child;
|
||||
logDev(`Electron started pid=${child.pid ?? "unknown"}`);
|
||||
child.on("exit", (code, signal) => {
|
||||
logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`);
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`);
|
||||
const child = spawn(electron, ["."], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "development"
|
||||
},
|
||||
stdio: "inherit"
|
||||
});
|
||||
electronProcess = child;
|
||||
logDev(`Electron started pid=${child.pid ?? "unknown"}`);
|
||||
child.on("exit", (code, signal) => {
|
||||
logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`);
|
||||
if (electronProcess === child) {
|
||||
electronProcess = null;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
restartInFlight = false;
|
||||
if (restartQueued || pendingRestartReasons.length > 0) {
|
||||
restartQueued = false;
|
||||
scheduleRestart("changes queued during Electron restart");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopElectron(child) {
|
||||
logDev(`stopping Electron pid=${child.pid ?? "unknown"}`);
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
if (electronProcess === child) {
|
||||
electronProcess = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
let forceTimer = null;
|
||||
let giveUpTimer = null;
|
||||
const finish = () => {
|
||||
if (forceTimer) clearTimeout(forceTimer);
|
||||
if (giveUpTimer) clearTimeout(giveUpTimer);
|
||||
child.off("exit", finish);
|
||||
resolve();
|
||||
};
|
||||
child.once("exit", finish);
|
||||
child.kill();
|
||||
forceTimer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
logDev(`force stopping Electron pid=${child.pid ?? "unknown"}`);
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 2_000);
|
||||
giveUpTimer = setTimeout(finish, 5_000);
|
||||
});
|
||||
if (electronProcess === child) {
|
||||
electronProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
logDev("starting dev build");
|
||||
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)) {
|
||||
rememberWatchSignature("model catalog", 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, { metadataOnly: true });
|
||||
}
|
||||
|
||||
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml);
|
||||
});
|
||||
const sourcePoller = setInterval(() => {
|
||||
pollStyleWatchRoots();
|
||||
pollSourceWatchTargets();
|
||||
}, stylePollIntervalMs);
|
||||
|
||||
const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, copyBrowserRendererHtml);
|
||||
});
|
||||
const contexts = [];
|
||||
|
||||
const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, copyTrayRendererHtml);
|
||||
});
|
||||
|
||||
const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
|
||||
});
|
||||
|
||||
const modelCatalogWatcher = 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 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() {
|
||||
|
|
@ -338,15 +520,13 @@ async function shutdown() {
|
|||
clearTimeout(restartTimer);
|
||||
}
|
||||
if (electronProcess) {
|
||||
electronProcess.kill();
|
||||
await stopElectron(electronProcess);
|
||||
}
|
||||
tailwindProcess.kill();
|
||||
htmlWatcher.close();
|
||||
browserHtmlWatcher.close();
|
||||
trayHtmlWatcher.close();
|
||||
appAssetsWatcher.close();
|
||||
modelCatalogWatcher.close();
|
||||
await Promise.all([mainContext.dispose(), cliContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose(), webClientBridgeContext.dispose()]);
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
clearInterval(sourcePoller);
|
||||
await Promise.all(contexts.map((context) => context.dispose()));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
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,15 @@ 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");
|
||||
export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.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 +89,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 +126,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 +150,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 +177,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 +190,40 @@ 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"),
|
||||
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
|
||||
upstreamHeaderSanitizerInput,
|
||||
electronUndiciProxyAgentInput,
|
||||
path.join(electronSourceRoot, "main", "preload.ts")
|
||||
],
|
||||
external: nodeExternals,
|
||||
format: "cjs",
|
||||
|
|
@ -130,9 +231,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 +244,48 @@ 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"),
|
||||
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
|
||||
upstreamHeaderSanitizerInput
|
||||
],
|
||||
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"),
|
||||
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
|
||||
upstreamHeaderSanitizerInput
|
||||
],
|
||||
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 +316,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 +343,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 +393,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 +441,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 +494,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 +580,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 +605,5 @@ function resolveRendererImport(importPath) {
|
|||
}
|
||||
}
|
||||
|
||||
return basePath;
|
||||
return packageBasePath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +1,98 @@
|
|||
import electron from "electron";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-"));
|
||||
const testSuites = ["main", "renderer"];
|
||||
const requestedSuites = process.argv.slice(2);
|
||||
const suites = requestedSuites.length === 0 ? testSuites : requestedSuites;
|
||||
const testsOutDir = path.join(projectRoot, ".test-dist");
|
||||
const testProjects = {
|
||||
architecture: { runtime: "node" },
|
||||
cli: { runtime: "node" },
|
||||
core: { runtime: "node-with-electron-fallback" },
|
||||
electron: { runtime: "electron" },
|
||||
ui: { runtime: "node" }
|
||||
};
|
||||
const requestedProjects = process.argv.slice(2);
|
||||
const projects = requestedProjects.length === 0 ? Object.keys(testProjects) : requestedProjects;
|
||||
|
||||
for (const suite of suites) {
|
||||
if (!testSuites.includes(suite)) {
|
||||
cleanup();
|
||||
throw new Error(`Unknown test suite: ${suite}`);
|
||||
for (const project of projects) {
|
||||
if (!testProjects[project]) {
|
||||
throw new Error(`Unknown test project: ${project}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const suite of suites) {
|
||||
await runSuite(suite);
|
||||
for (const project of projects) {
|
||||
await runProject(project);
|
||||
}
|
||||
|
||||
async function runProject(project) {
|
||||
const testFiles = findCompiledTests(path.join(testsOutDir, project, "test"));
|
||||
if (testFiles.length === 0) {
|
||||
console.log(`No ${project} tests found.`);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function runSuite(suite) {
|
||||
console.log(`\nRunning ${suite} tests...`);
|
||||
const testHome = mkdtempSync(path.join(os.tmpdir(), `ccr-${project}-test-home-`));
|
||||
const runtime = resolveRuntime(testProjects[project].runtime);
|
||||
const executable = runtime === "electron" ? electron : process.execPath;
|
||||
console.log(`\nRunning ${project} tests with ${runtime}...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"),
|
||||
CCR_INTERNAL_HOME_DIR: testHome,
|
||||
CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"),
|
||||
HOME: testHome,
|
||||
ELECTRON_RUN_AS_NODE: "1"
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
stdio: "inherit"
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, ["--test", ...testFiles], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"),
|
||||
CCR_INTERNAL_HOME_DIR: testHome,
|
||||
CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"),
|
||||
HOME: testHome,
|
||||
...(runtime === "electron" ? { ELECTRON_RUN_AS_NODE: "1" } : {})
|
||||
},
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`${project} tests exited from signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${project} tests exited with code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${suite} tests exited with code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
rmSync(testHome, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
rmSync(testHome, { force: true, recursive: true });
|
||||
function resolveRuntime(runtime) {
|
||||
if (runtime !== "node-with-electron-fallback") {
|
||||
return runtime;
|
||||
}
|
||||
const probe = spawnSync(process.execPath, [
|
||||
"-e",
|
||||
"const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.close();"
|
||||
], { stdio: "ignore" });
|
||||
return probe.status === 0 ? "node" : "electron";
|
||||
}
|
||||
|
||||
function findCompiledTests(dir) {
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return findCompiledTests(file);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".test.js") ? [file] : [];
|
||||
}).sort();
|
||||
}
|
||||
|
|
|
|||
112
build/test.mjs
|
|
@ -5,41 +5,82 @@ 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 testSuites = [
|
||||
{ name: "main", testDir: path.join(projectRoot, "tests", "main") },
|
||||
{ name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") }
|
||||
];
|
||||
const requestedSuites = new Set(process.argv.slice(2));
|
||||
const suiteNames = new Set(testSuites.map((suite) => suite.name));
|
||||
const unknownSuites = [...requestedSuites].filter((suite) => !suiteNames.has(suite));
|
||||
const selectedSuites = requestedSuites.size === 0
|
||||
? testSuites
|
||||
: testSuites.filter((suite) => requestedSuites.has(suite.name));
|
||||
const testsOutDir = path.join(projectRoot, ".test-dist");
|
||||
const packageRoots = {
|
||||
cli: path.join(projectRoot, "packages", "cli", "src"),
|
||||
core: path.join(projectRoot, "packages", "core", "src"),
|
||||
electron: path.join(projectRoot, "packages", "electron", "src"),
|
||||
ui: path.join(projectRoot, "packages", "ui", "src")
|
||||
};
|
||||
const testProjects = {
|
||||
architecture: {
|
||||
testDir: path.join(projectRoot, "tests", "architecture")
|
||||
},
|
||||
cli: {
|
||||
runtimeEntryPoints: {
|
||||
"runtime/cli": path.join(packageRoots.cli, "cli.ts")
|
||||
},
|
||||
testDir: path.join(projectRoot, "packages", "cli", "test")
|
||||
},
|
||||
core: {
|
||||
runtimeEntryPoints: {
|
||||
"runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"),
|
||||
"runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"),
|
||||
"runtime/upstream-header-sanitizer": path.join(packageRoots.core, "gateway", "core-runtime", "upstream-header-sanitizer.ts"),
|
||||
"runtime/toolhub-mcp": path.join(packageRoots.core, "mcp", "toolhub-mcp.ts")
|
||||
},
|
||||
testDir: path.join(projectRoot, "packages", "core", "test")
|
||||
},
|
||||
electron: {
|
||||
testDir: path.join(projectRoot, "packages", "electron", "test")
|
||||
},
|
||||
ui: {
|
||||
testDir: path.join(projectRoot, "packages", "ui", "test")
|
||||
}
|
||||
};
|
||||
|
||||
if (unknownSuites.length > 0) {
|
||||
throw new Error(`Unknown test suite: ${unknownSuites.join(", ")}`);
|
||||
const args = process.argv.slice(2);
|
||||
const scopeIndex = args.indexOf("--scope");
|
||||
const scope = scopeIndex >= 0 ? args[scopeIndex + 1] : undefined;
|
||||
if (scopeIndex >= 0 && !scope) {
|
||||
throw new Error("--scope requires a test directory name such as unit or integration");
|
||||
}
|
||||
const requestedProjects = new Set(args.filter((arg, index) =>
|
||||
arg !== "--scope" && (scopeIndex < 0 || index !== scopeIndex + 1)
|
||||
));
|
||||
const projectNames = new Set(Object.keys(testProjects));
|
||||
const unknownProjects = [...requestedProjects].filter((project) => !projectNames.has(project));
|
||||
const selectedProjects = requestedProjects.size === 0
|
||||
? Object.entries(testProjects)
|
||||
: Object.entries(testProjects).filter(([name]) => requestedProjects.has(name));
|
||||
|
||||
if (unknownProjects.length > 0) {
|
||||
throw new Error(`Unknown test project: ${unknownProjects.join(", ")}`);
|
||||
}
|
||||
|
||||
rmSync(testsOutDir, { force: true, recursive: true });
|
||||
for (const [name, project] of selectedProjects) {
|
||||
const projectOutDir = path.join(testsOutDir, name);
|
||||
const scopedTestDir = scope ? path.join(project.testDir, scope) : project.testDir;
|
||||
const entryPoints = testEntryPoints(scopedTestDir);
|
||||
if (!scope || scope === "integration") {
|
||||
Object.assign(entryPoints, project.runtimeEntryPoints ?? {});
|
||||
}
|
||||
|
||||
for (const suite of selectedSuites) {
|
||||
const entryPoints = findTestFiles(suite.testDir);
|
||||
if (entryPoints.length === 0) {
|
||||
rmSync(projectOutDir, { force: true, recursive: true });
|
||||
if (Object.keys(entryPoints).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints,
|
||||
external: [
|
||||
"better-sqlite3",
|
||||
"electron"
|
||||
],
|
||||
format: "cjs",
|
||||
jsx: "automatic",
|
||||
legalComments: "none",
|
||||
loader: {
|
||||
".gif": "file",
|
||||
|
|
@ -51,13 +92,21 @@ for (const suite of selectedSuites) {
|
|||
".webp": "file"
|
||||
},
|
||||
logLevel: "info",
|
||||
outdir: path.join(testsOutDir, suite.name),
|
||||
outdir: projectOutDir,
|
||||
platform: "node",
|
||||
plugins: [rendererAliasPlugin()],
|
||||
plugins: [rendererAliasPlugin(), packageAliasPlugin()],
|
||||
target: "node22"
|
||||
});
|
||||
}
|
||||
|
||||
function testEntryPoints(testDir) {
|
||||
return Object.fromEntries(findTestFiles(testDir).map((file) => {
|
||||
const relative = path.relative(testDir, file).replace(/\\/g, "/");
|
||||
const outputName = `test/${relative.replace(/\.(mjs|ts|tsx)$/, "")}`;
|
||||
return [outputName, file];
|
||||
}));
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
|
|
@ -81,14 +130,29 @@ function rendererAliasPlugin() {
|
|||
name: "renderer-test-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, (args) => {
|
||||
return { path: resolveRendererImport(args.path.slice(2)) };
|
||||
return { path: resolvePackageImport(packageRoots.ui, args.path.slice(2)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRendererImport(importPath) {
|
||||
const basePath = path.resolve(rendererRoot, importPath);
|
||||
function packageAliasPlugin() {
|
||||
return {
|
||||
name: "test-package-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@ccr\/(cli|core|electron|ui)\// }, (args) => {
|
||||
const match = args.path.match(/^@ccr\/(cli|core|electron|ui)\/(.+)$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return { path: resolvePackageImport(packageRoots[match[1]], match[2]) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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
|
|
@ -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:
|
||||
339
docker/README.md
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
# Docker Deployment
|
||||
|
||||
[中文说明](#中文说明) · [Project documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router)
|
||||
|
||||
The Docker image runs the CCR core server under PM2 and serves the built management UI through Nginx. Nginx is the only public container entrypoint: the browser UI, management RPC, gateway API, and health route all share one published port.
|
||||
|
||||
The image is intended for a persistent gateway and browser-based administration. It does not include Electron, the npm `ccr` command, system tray features, desktop Agent/App launching, automatic desktop updates, or desktop-only browser integrations.
|
||||
|
||||
## Architecture And Ports
|
||||
|
||||
```text
|
||||
host:3458 -> container Nginx:8080
|
||||
|-> static management UI
|
||||
|-> management RPC: 127.0.0.1:3459
|
||||
|-> gateway: 127.0.0.1:3456
|
||||
`-> gateway core: 127.0.0.1:3457
|
||||
```
|
||||
|
||||
Only Nginx port `8080` should be published. The three internal ports are container implementation details and should not be exposed individually.
|
||||
|
||||
Nginx routes:
|
||||
|
||||
| Public route | Purpose |
|
||||
| --- | --- |
|
||||
| `/` and `/pages/home/index.html` | Browser management UI. `/` redirects to a URL containing the management token. |
|
||||
| `/api/ccr/rpc` | Authenticated management RPC. |
|
||||
| `/health` | Gateway health, not container/UI health. It can return `502` until a provider and model are configured and the gateway starts. |
|
||||
| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Supported model and MCP gateway requests. |
|
||||
|
||||
## Quick Start With Compose
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose logs -f ccr
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:3458>. On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**.
|
||||
|
||||
The repository Compose file publishes `3458:8080`, stores data in the `ccr-data` named volume, and restarts the service unless explicitly stopped. A mapping without a host IP binds on every host interface. For local-only access, change it to:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "127.0.0.1:3458:8080"
|
||||
```
|
||||
|
||||
Stop or remove the container without deleting its named volume:
|
||||
|
||||
```sh
|
||||
docker compose stop
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Do not add `--volumes` to `docker compose down` unless you intentionally want to delete all persisted CCR data.
|
||||
|
||||
## `docker run`
|
||||
|
||||
Build and run without Compose:
|
||||
|
||||
```sh
|
||||
docker build -t claude-code-router:local .
|
||||
docker run -d \
|
||||
--name claude-code-router \
|
||||
--restart unless-stopped \
|
||||
-p 127.0.0.1:3458:8080 \
|
||||
-e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \
|
||||
-v ccr-data:/data \
|
||||
claude-code-router:local
|
||||
```
|
||||
|
||||
Equivalent repository scripts are available:
|
||||
|
||||
```sh
|
||||
npm run docker:build
|
||||
npm run docker:run
|
||||
```
|
||||
|
||||
`npm run docker:run` uses port `3458` and the `ccr-data` volume, but runs an ephemeral container without a fixed name or restart policy.
|
||||
|
||||
## Authentication And Network Security
|
||||
|
||||
There are two independent authentication layers:
|
||||
|
||||
1. `CCR_WEB_AUTH_TOKEN` protects management RPC. Nginx puts it into the management-page URL, and the browser sends it to RPC as `x-ccr-web-auth`.
|
||||
2. CCR client API keys created in the **API Keys** page protect model gateway requests. These are separate from upstream provider credentials.
|
||||
|
||||
If `CCR_WEB_AUTH_TOKEN` is unset, the entrypoint generates a new random token on each container start. Opening `/` still works because Nginx redirects to a tokenized URL, but a stable token is recommended for persistent or remote deployments.
|
||||
|
||||
Avoid putting the token directly in shell history. Create a protected environment file instead:
|
||||
|
||||
```dotenv
|
||||
CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value
|
||||
CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458
|
||||
```
|
||||
|
||||
Then use it with `docker run --env-file` or map the same variables under the Compose service's `environment` section. Keep this file out of version control.
|
||||
|
||||
Security guidance:
|
||||
|
||||
- Bind the published port to `127.0.0.1` unless LAN or remote access is intentional.
|
||||
- Never expose the management UI over untrusted networks without TLS, a firewall/private network, and a fixed strong management token.
|
||||
- Treat tokenized management URLs as secrets; URLs may be recorded in browser history, proxy logs, screenshots, and support tickets.
|
||||
- Create scoped CCR client API keys before exposing gateway routes. Do not reuse upstream provider credentials as client keys.
|
||||
- Protect `/data` and its backups because they contain configuration, provider credentials, CCR client keys, request data, and generated certificates.
|
||||
|
||||
## Changing The Public Address
|
||||
|
||||
The host-facing URL is separate from the container's internal ports. Whenever the host port, hostname, or scheme changes, set `CCR_PUBLIC_BASE_URL` to the exact URL clients should use:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "127.0.0.1:8088:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
|
||||
CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
`CCR_PUBLIC_BASE_URL` is written to CCR's public router endpoint. It does not publish a Docker port by itself.
|
||||
|
||||
For a reverse proxy or ingress that terminates HTTPS:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "127.0.0.1:3458:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: https://ccr.example.com
|
||||
CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
Proxy all paths to Nginx and preserve streaming. The external proxy should allow long-lived responses and should not buffer SSE/model streams. Keep the host port private when the reverse proxy is the public entrypoint.
|
||||
|
||||
## Persistent Data
|
||||
|
||||
The entrypoint sets `HOME=/data`, so CCR stores files under:
|
||||
|
||||
```text
|
||||
/data/.claude-code-router/
|
||||
├── config.sqlite
|
||||
├── gateway.config.json
|
||||
├── app-data/
|
||||
│ ├── api-keys.sqlite
|
||||
│ ├── request-logs.sqlite
|
||||
│ ├── usage.sqlite
|
||||
│ └── certs/
|
||||
├── profiles/
|
||||
└── bin/
|
||||
```
|
||||
|
||||
Use a named volume unless a bind mount is operationally required. Bind mounts must be writable by the container and should not be shared by two running CCR containers.
|
||||
|
||||
The first-run bootstrap writes a minimal legacy `config.json` only when neither `config.json` nor `config.sqlite` exists. When the UI saves current settings, SQLite becomes authoritative. By default, every container start also synchronizes the stored gateway listener and `routerEndpoint` to the Docker public endpoint.
|
||||
|
||||
## Backup And Restore
|
||||
|
||||
The safest application-level backup is **Settings → Export data**. For a full volume backup, stop writes before copying the data directory:
|
||||
|
||||
```sh
|
||||
docker compose stop ccr
|
||||
docker compose cp ccr:/data/. ./ccr-data-backup/
|
||||
docker compose start ccr
|
||||
```
|
||||
|
||||
Keep the backup private. It contains secrets and may include request/response data.
|
||||
|
||||
For a full restore, use a new empty volume or empty `/data` directory, copy the backup contents into it while the CCR container is stopped, then start the container. Do not overlay an old backup onto a populated live volume: stale SQLite WAL/SHM files and newer runtime files can produce an inconsistent result. Make a second backup before replacing existing data.
|
||||
|
||||
## Upgrade And Rollback
|
||||
|
||||
Back up `/data`, update the source revision, rebuild with fresh base layers, and recreate the service:
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose build --pull
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs --tail=200 ccr
|
||||
```
|
||||
|
||||
Configuration migrations run against the persistent data. To roll back, use the previous image/source revision together with a backup created before the upgrade; do not assume a newer database can always be read by an older build.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Most deployments should set only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping. Internal listener values normally should remain unchanged.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `CCR_WEB_AUTH_TOKEN` | Random per container start | Management UI/RPC token. Set a stable strong value for persistent or remote use. |
|
||||
| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public gateway/UI base URL written into CCR configuration. Overrides `CCR_PUBLIC_HOST` and `CCR_PUBLIC_PORT`. |
|
||||
| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset; it does not change Docker port publishing. |
|
||||
| `CCR_PUBLIC_PORT` | `3458` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset. |
|
||||
| `CCR_DATA_DIR` | `/data` | Container data root and process `HOME`. Mount persistent storage here. |
|
||||
| `CCR_NGINX_PORT` | `8080` | Container-private Nginx listen port. Match the container side of the published mapping if changed. |
|
||||
| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management server host. |
|
||||
| `CCR_WEB_PORT` | `3459` | Container-private management server port. |
|
||||
| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private gateway listener host. |
|
||||
| `CCR_GATEWAY_PORT` | `3456` | Container-private gateway listener port used by Nginx. |
|
||||
| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core gateway runtime port. |
|
||||
| `CCR_NO_GATEWAY` | `0` | Set to `1`, `true`, or `yes` to run the management UI without starting the gateway at boot. |
|
||||
| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable minimal first-run `config.json` bootstrap. |
|
||||
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Set to `0` to stop startup from syncing existing JSON/SQLite listener and public endpoint fields to Docker values. |
|
||||
|
||||
Changing internal ports requires corresponding Nginx/PM2 variables and offers no benefit in normal deployments. Publish only `CCR_NGINX_PORT`.
|
||||
|
||||
## Build Options And Smoke Test
|
||||
|
||||
The Dockerfile builds native dependencies with `node:22-bookworm`, then copies production dependencies and built assets into `node:22-bookworm-slim`. Override the base images when required:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE=node:22-bookworm \
|
||||
--build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
|
||||
-t claude-code-router:local .
|
||||
```
|
||||
|
||||
Run the isolated Docker smoke test:
|
||||
|
||||
```sh
|
||||
npm run test:docker
|
||||
```
|
||||
|
||||
The test builds the image, starts a temporary container and volume, verifies that only Nginx is published, checks UI/RPC authentication, tests public-endpoint migration, starts a configured gateway, checks `/health`, and removes its resources. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an existing image or `CCR_DOCKER_TEST_IMAGE` to test a different local tag.
|
||||
|
||||
## Operations And Troubleshooting
|
||||
|
||||
Useful commands:
|
||||
|
||||
```sh
|
||||
docker compose ps
|
||||
docker compose logs -f ccr
|
||||
docker compose restart ccr
|
||||
docker compose config
|
||||
```
|
||||
|
||||
### `/` returns `302`
|
||||
|
||||
This is expected. Nginx redirects the root URL to the management page and URL-encodes the management token.
|
||||
|
||||
### `/health` returns `502`
|
||||
|
||||
`/health` checks the model gateway, not Nginx or the management UI. On a fresh volume it returns `502` until a provider/model exists and the gateway has started. Use `docker compose ps` for container health and open the UI to configure/start the gateway.
|
||||
|
||||
### The UI returns `401` after a token change
|
||||
|
||||
Open the bare root URL again so Nginx creates a URL with the current token. Close stale tabs and avoid bookmarks that contain an old `ccr_web_token`.
|
||||
|
||||
### Clients still use the old port or hostname
|
||||
|
||||
Update `CCR_PUBLIC_BASE_URL` and recreate the container. Leave `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1` so existing SQLite configuration is synchronized at startup.
|
||||
|
||||
### Configuration disappears after recreation
|
||||
|
||||
Confirm that `/data` is mounted and that the same named volume or bind-mount path is being reused. `docker compose down` keeps named volumes; `docker compose down --volumes` deletes them.
|
||||
|
||||
### A bind mount fails with permission errors
|
||||
|
||||
Verify that the host directory exists, is writable by the container, and is not mounted read-only. Named volumes avoid most host ownership and labeling issues.
|
||||
|
||||
### The container is healthy but model requests fail
|
||||
|
||||
Container health only verifies Nginx/UI reachability. Check **Server** status, provider connectivity, CCR client-key authentication, routing, and request logs. Then inspect `docker compose logs --tail=200 ccr` for startup or runtime errors.
|
||||
|
||||
---
|
||||
|
||||
## 中文说明
|
||||
|
||||
Docker 镜像通过 PM2 运行 CCR Core,并由 Nginx 同时提供管理 UI、管理 RPC、模型网关和健康检查。对外只应发布 Nginx 的容器端口 `8080`;`3459`、`3456`、`3457` 都是容器内部实现端口,不应单独暴露。
|
||||
|
||||
这个镜像面向常驻网关和浏览器管理,不包含 Electron、npm 的 `ccr` 命令、系统托盘、桌面 Agent/App 启动、桌面自动更新和桌面专属浏览器集成。
|
||||
|
||||
### 快速启动
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose logs -f ccr
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:3458>。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。
|
||||
|
||||
仓库默认映射是 `3458:8080`,会监听宿主机所有网卡。如果只允许本机访问,请改为:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "127.0.0.1:3458:8080"
|
||||
```
|
||||
|
||||
### 鉴权与远程访问
|
||||
|
||||
- `CCR_WEB_AUTH_TOKEN` 用于管理 UI / RPC;不设置时,每次容器启动都会生成新的随机 Token。
|
||||
- **API 密钥** 页面创建的 CCR 客户端 Key 用于模型网关请求。
|
||||
- 上游供应商凭据是第三类凭据,不应拿来代替 CCR 客户端 Key。
|
||||
|
||||
根路径会重定向到包含 `ccr_web_token` 的管理 URL。请把该 URL 当作密码。远程部署至少应使用固定强 Token、TLS、主机防火墙或私网,并让反向代理把全部路径转发到 Nginx。流式响应和 SSE 不应被代理缓冲。
|
||||
|
||||
外部端口、域名或协议变化时,必须同步设置公开地址:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: https://ccr.example.com
|
||||
CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
`CCR_PUBLIC_BASE_URL` 只负责写入客户端应使用的公开地址,不会自动发布 Docker 端口。
|
||||
|
||||
### 数据、备份与升级
|
||||
|
||||
数据实际位于 `/data/.claude-code-router/`,其中包括 `config.sqlite`、`app-data/`、Agent 配置和生成文件。优先使用命名卷,不要让两个运行中的 CCR 容器共享同一个数据目录。
|
||||
|
||||
完整文件备份前先停止写入:
|
||||
|
||||
```sh
|
||||
docker compose stop ccr
|
||||
docker compose cp ccr:/data/. ./ccr-data-backup/
|
||||
docker compose start ccr
|
||||
```
|
||||
|
||||
备份包含密钥和请求数据,必须按敏感数据保存。恢复时应复制到新的空卷或空 `/data`,不要把旧备份覆盖到仍有新数据的目录。升级前先备份,然后执行:
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose build --pull
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs --tail=200 ccr
|
||||
```
|
||||
|
||||
### 常见排查
|
||||
|
||||
- `/` 返回 `302`:正常,Nginx 正在跳转到带管理 Token 的页面。
|
||||
- `/health` 返回 `502`:它检查的是模型网关;首次启动尚未配置模型时属于预期行为。
|
||||
- 修改 Token 后 UI 返回 `401`:重新打开不带参数的根地址,关闭仍使用旧 Token 的标签页。
|
||||
- 重建后配置消失:检查是否仍挂载同一个 `/data` 卷;`docker compose down --volumes` 会删除数据卷。
|
||||
- 容器健康但模型请求失败:继续检查服务状态、供应商连通性、CCR 客户端 Key、路由和请求日志;容器健康只表示 Nginx / UI 可访问。
|
||||
|
||||
完整的环境变量、端口拓扑、远程部署、构建参数和烟雾测试说明见本页英文主体,对应变量名和命令在中英文环境中完全相同。
|
||||
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
|
|
@ -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
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/public/provider-icons/code0.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/public/provider-icons/fenno.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
docs/public/provider-icons/qiniu-ai.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/public/provider-icons/runapi.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/public/provider-icons/teamorouter.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
docs/public/provider-icons/unity2.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -51,7 +51,7 @@ const isSectionDoc = !docProp || docProp === sectionDoc;
|
|||
const doc = docProp ?? sectionDoc;
|
||||
const { Content, frontmatter } = doc;
|
||||
const allHeadings = doc.getHeadings();
|
||||
const headings = allHeadings.filter((heading) => heading.depth === 2);
|
||||
const tocHeadings = allHeadings.filter((heading) => heading.depth >= 2 && heading.depth <= 5);
|
||||
const isExplicitHref = (value: string) =>
|
||||
value.startsWith("#") ||
|
||||
value.startsWith("/") ||
|
||||
|
|
@ -155,9 +155,10 @@ const sidebarTree = sidebarNavItems.map((navItem, index) => {
|
|||
})),
|
||||
};
|
||||
});
|
||||
const tocItems = headings.map((heading) => ({
|
||||
const tocItems = tocHeadings.map((heading) => ({
|
||||
label: heading.text,
|
||||
href: `#${heading.slug}`,
|
||||
depth: heading.depth,
|
||||
}));
|
||||
const pageMarkdown = doc.rawContent();
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ eyebrow: Detailed Configuration
|
|||
lead: Manage API keys that clients use to access the CCR gateway, with expiration and local limits.
|
||||
---
|
||||
|
||||
## Basic Concept
|
||||
|
||||
The API Keys page manages client access keys for CCR. These keys are different from provider `API key` values and provider credential pools: API Keys control who can call CCR; provider keys control how CCR calls upstream providers.
|
||||
|
||||
When API keys are configured, clients should send `Authorization: Bearer <key>` or `x-api-key: <key>`.
|
||||
|
||||
## List Fields
|
||||
|
||||
| Field | Capability |
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ lead: Add a Bot, bind it to Agent Config, and choose message forwarding or hando
|
|||
2. Select a platform and fill in the required token, secret, signing secret, robot code, or OAuth fields.
|
||||
3. Save the Bot.
|
||||
4. Open the target **Agent Config** and enable **Bot**.
|
||||
5. Choose **Forward agent messages** or **Handoff**, then reopen the agent from CCR.
|
||||
5. Configure forwarding, handoff, language, timeout, attachments, streaming, and **Allow Agent shell tools** as needed.
|
||||
6. Reopen Claude, Codex, ZCode, or OpenCode App from CCR. The Bot is online only while the App is alive.
|
||||
|
||||
## Verification
|
||||
|
||||
Open the agent from CCR and send a test message. Confirm that Logs include Bot records and the IM side receives the message.
|
||||
Open the Agent App from CCR, then send `/project current`, `/session list`, and one plain message from IM. The Profile card shows connection state, the last event and delivery, pending outbox count, and redacted errors; `/session doctor` provides the same runtime diagnostics. Closing the App should move the Bot to offline state.
|
||||
|
|
|
|||
|
|
@ -5,15 +5,61 @@ eyebrow: Detailed Configuration
|
|||
lead: Forward agent messages to instant-messaging platforms or hand off active work after desktop idle.
|
||||
---
|
||||
|
||||
## What Bots Do
|
||||
|
||||
Bots forward agent messages to instant-messaging platforms and can hand off active work after the desktop has been idle.
|
||||
CCR App Relay shares the lifecycle of an Agent App opened by CCR. Opening Claude, Codex, ZCode, or OpenCode App starts its companion worker; closing the App stops both the worker and Bot connection.
|
||||
|
||||
## Common Modes
|
||||
|
||||
- **Forward agent messages**: mirror agent messages into IM.
|
||||
- **Handoff**: relay the interaction into IM after desktop idle.
|
||||
- **Reply only**: with forwarding and handoff disabled, reply only to turns initiated from IM.
|
||||
|
||||
Natural-language turns are serialized per IM conversation. `/project`, `/session status`, and `/session cancel` remain immediately responsive; queueing, timeout, cancellation, and worker-restart recovery have explicit states.
|
||||
|
||||
## Projects And Sessions
|
||||
|
||||
A Project is an Agent-native project or working directory, and a Session is an Agent-native conversation inside that Project.
|
||||
|
||||
### Project Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `/project` | Show Project help and the App-online boundary. |
|
||||
| `/project list [page]` | List known Agent projects with pagination. |
|
||||
| `/project find <text>` | Search project names and paths. |
|
||||
| `/project current` | Show the current Project. |
|
||||
| `/project use <n>` | Change Project and clear the previous Session selection. |
|
||||
| `/project name <label>` | Set the Bot display label for this Project. |
|
||||
|
||||
### Session Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `/session` | Show all Session commands. |
|
||||
| `/session list [page]`, `/session find <text>` | Browse Sessions only in the current Project. |
|
||||
| `/session current`, `new [title]`, `use <n>`, `reset` | Inspect, create, continue, or clear a Session selection. |
|
||||
| `/session status`, `cancel` | Inspect the active turn/queue, or cancel it and clear the queue. |
|
||||
| `/session approve [session]`, `deny`, `answer <text>` | Answer an Agent-generated permission or input request; every platform has text commands, and card-capable platforms also show action buttons. |
|
||||
| `/session name <label>` | Rename the current Session. |
|
||||
| `/session archive <n>`, `restore <n>`, `delete <n> confirm` | Archive, restore, or permanently delete with confirmation. |
|
||||
| `/session history [count]`, `usage` | Show recent history and token/cache/cost summaries. |
|
||||
| `/session models`, `model`, `effort`, `mode` | Inspect or change this conversation's Session runtime settings. |
|
||||
| `/session memory ...`, `skills`, `skill`, `shortcut ...` | Manage persistent context, Agent skills, and shortcuts. |
|
||||
| `/session doctor`, `deliveries` | Show connection, outbox, recent-delivery, and redacted-error diagnostics. |
|
||||
|
||||
The public Bot command domains are `/project` and `/session`. Other slash commands return the unknown-command response, while plain natural language such as `help` or `list` enters the Agent as a prompt.
|
||||
|
||||
## Bot Settings
|
||||
|
||||
- **Bot language**: automatic, English, or Simplified Chinese.
|
||||
- **Maximum turn time**: interrupt timed-out turns and return a final state.
|
||||
- **Session idle reset**: prepare a new Session in the same Project after inactivity; `0` disables it.
|
||||
- **Message chunk and attachment limits**: adapt to platform limits and bound inbound files.
|
||||
- **Streaming replies and progress**: forward visible text and tool stages.
|
||||
- **Send and receive attachments**: accept inbound images/files and return artifacts from the current workspace.
|
||||
- **Allow Agent shell tools**: controls Agent shell-tool permission; the Bot command surface remains `/project` and `/session`.
|
||||
|
||||
The local state keeps bounded deduplication records, pending turns, a durable outbox, and recent delivery results. Event idempotency gives each Agent turn one execution, and pending delivery resumes while the App is online again.
|
||||
|
||||
## Platform Pages
|
||||
|
||||
Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, and DingTalk each have a dedicated page.
|
||||
Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, and DingTalk each have a dedicated page; iMessage uses a local integration. The SDK selects Markdown, cards, streaming updates, file messages, or text according to platform capabilities.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ lead: Locate the SQLite configuration database maintained by the CCR desktop app
|
|||
## Default Locations
|
||||
|
||||
- **macOS/Linux**: `~/.claude-code-router/config.sqlite`
|
||||
- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite`
|
||||
- **Windows**: `%APPDATA%\claude-code-router\config.sqlite`
|
||||
|
||||
Docker sets `HOME=/data`, so its configuration database is `/data/.claude-code-router/config.sqlite`. Persist the complete `/data` directory rather than mounting only one database file.
|
||||
|
||||
## Applying Changes
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ eyebrow: Detailed Configuration
|
|||
lead: Learn how CCR extensions are loaded, what they can register, and how to create, install, and debug your own extension.
|
||||
---
|
||||
|
||||
## What Extensions Are
|
||||
## Extension Types
|
||||
|
||||
CCR has two extension layers:
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ The recommended flow is through the desktop UI:
|
|||
4. Save the config.
|
||||
5. Open **Server** and restart the gateway.
|
||||
|
||||
CCR stores runtime configuration in SQLite, so add extensions through the UI instead of editing the legacy JSON config file. The extension entry has this shape:
|
||||
CCR stores runtime configuration in SQLite. Add extensions through the UI; the legacy JSON config file is kept here only as a reference. The extension entry has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -270,7 +270,7 @@ Proxy route matching rules:
|
|||
| Response is 401 | Routes require gateway API key by default; set `auth: "none"` for debug routes |
|
||||
| Code changes do not apply | Wrapper plugins are not hot reloaded; restart the gateway or CCR |
|
||||
| Port is already in use | Omit `port` in `registerHttpBackend` so CCR can allocate one automatically |
|
||||
| Proxy route is not hit | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname |
|
||||
| Proxy route misses requests | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname |
|
||||
|
||||
## Security Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -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,58 +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.
|
||||
---
|
||||
|
||||
## Basic Concept
|
||||
## When To Use It
|
||||
|
||||
Overview is the CCR dashboard. It turns gateway status, provider accounts, request usage, and analysis results into draggable widgets. You can switch the time range, edit widgets, resize them, change styles, and generate shareable cards from selected data.
|
||||
|
||||
Overview depends on request logs, usage stats, and provider account usage. Some widgets require Request logs or Agent observability to be enabled in Settings. Account balance widgets require `Fetch usage` on providers.
|
||||
|
||||
## Top Controls
|
||||
|
||||
| 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. |
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
title: Agent Config
|
||||
pageTitle: Agent Config
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Create reusable launch configurations for Claude Code, Codex, and ZCode, and open separate agent instances from different configs.
|
||||
lead: Create reusable launch configurations for Claude Code, Codex, Grok CLI, and ZCode, and open separate agent instances from different configs.
|
||||
---
|
||||
|
||||
## What Agent Config Is
|
||||
## Configuration Flow
|
||||
|
||||
Agent Config is the desktop app capability for managing Claude Code, Codex, and ZCode launch entries. It is not a provider or a routing rule; it is the full entry point for one agent launch: agent type, entry mode, model, effect scope, config file location, and optional Bot binding.
|
||||
1. Add at least one usable provider and model in **Provider Config**, or create the Fusion model you want to use.
|
||||
2. Open **Agent Config** and click **Add profile**.
|
||||
3. Choose the agent type, name the config, then choose the effect scope and entry mode.
|
||||
4. Select a model. The value is usually `Provider name/model name`, and Fusion models can be selected too.
|
||||
5. If the entry mode includes App, optionally bind a Bot and choose whether to forward agent messages or enable handoff.
|
||||
6. Save the config, then open it from the Agent Config card: the terminal button copies the CLI command, and the play button starts the App instance.
|
||||
|
||||
This page exists in Detailed Configuration to explain which config opens which agent instance, rather than provider, routing, or Fusion fields.
|
||||
During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, Grok CLI, or ZCode setup you open directly from the system.
|
||||
|
||||
## Multi-Instance Mechanism
|
||||
|
||||
|
|
@ -18,41 +23,140 @@ Every Agent Config has its own `id` and name. When CCR opens an agent, it finds
|
|||
| Mechanism | Actual behavior |
|
||||
| --- | --- |
|
||||
| Separate config files | With **Only opened from CCR**, Claude Code and Codex write CCR-managed config files in directories separated by config `id` |
|
||||
| Separate launchers | Claude Code uses a separate launch wrapper; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name |
|
||||
| Separate app data directories | When opening App mode, Claude App, Codex App, and ZCode App use user-data directories separated by config `id` |
|
||||
| Separate launchers | Claude Code and Grok CLI use separate launch wrappers; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name |
|
||||
| Separate app data directories | When opening App mode, Claude App, ChatGPT (the renamed Codex desktop app), and ZCode App use user-data directories separated by config `id` |
|
||||
| Runtime state | CCR tracks running app instances by entry mode and config `id`; reopening the same config activates the existing window, while a different config can open a separate instance |
|
||||
|
||||
This lets you create multiple configs for the same agent, such as "Claude Code - Work Project", "Claude Code - Test Model", or "Codex - Fusion Vision". They can use different models, scopes, and Bots, then open as separate agent instances.
|
||||
|
||||
## Common Options
|
||||
|
||||
| Option | Description |
|
||||
| Option | Applies to | Description |
|
||||
| --- | --- | --- |
|
||||
| Agent | All | Claude Code, Codex, OpenCode, Grok CLI, or ZCode. Grok CLI supports CLI only; ZCode supports App only. |
|
||||
| Config name | All | Identifies the config in CCR and can be used as the `ccr-app <config-name>` launch target. Names can contain spaces; copied commands are quoted automatically. |
|
||||
| Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. |
|
||||
| Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. |
|
||||
| Entry mode | Claude Code, Codex, OpenCode, Grok CLI | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI is fixed to `CLI only`. |
|
||||
| Model | All | Default model for the opened agent, either a provider model or Fusion model. For Claude Code, leaving it empty keeps the Claude Code default. |
|
||||
| Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. |
|
||||
| Environment variables | All | Extra environment variables injected into this config. Claude Code includes `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` by default so gateway model discovery is enabled. |
|
||||
|
||||
## Per-Agent Options
|
||||
|
||||
### Claude Code
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| Agent | Claude Code, Codex, or ZCode |
|
||||
| Config name | Identifies the config in CCR and can be used as the `ccr <config-name>` launch target |
|
||||
| Effect scope | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config |
|
||||
| Entry mode | `CLI & APP`, `CLI only`, or `App only`; ZCode supports App only |
|
||||
| Model | Default model for the opened agent, either a provider model or Fusion model |
|
||||
| Bot | App entry can bind a Bot for IM forwarding or handoff |
|
||||
| Model override | Writes `ANTHROPIC_MODEL` for Claude Code. Leave it empty to keep Claude Code's own default model. |
|
||||
| Small fast model | Writes `ANTHROPIC_SMALL_FAST_MODEL` for Claude Code lightweight tasks. Leave it empty to keep the Claude Code default. |
|
||||
| Settings file | System-default mode uses the Claude Code default settings file; Only opened from CCR creates an isolated settings file under CCR's config directory, separated by Agent Config `id`. |
|
||||
| Environment variables | Merged into the Claude Code settings `env`. CCR also writes the gateway endpoint, API key helper, and launch wrapper. |
|
||||
| Bot | Applies only to the Claude App entry. Select a saved Bot, then choose message forwarding or handoff. |
|
||||
|
||||
After Claude Code CLI is opened from CCR, it uses CCR gateway model discovery. In Claude Code CLI, enter `/model` to view and switch the models exposed by CCR, including normal provider models and visible Fusion models.
|
||||
|
||||
Claude App is **zero-config**: when CCR opens Claude App from the desktop app, CCR automatically writes the Claude App gateway config, API key, model discovery list, and isolated user-data directory. No extra user action is required; opening Claude App from CCR automatically completes all necessary configuration. If Claude App is already running, restart it or reopen it from CCR when prompted.
|
||||
|
||||
Claude App and Claude Code CLI use different model-list adapters:
|
||||
|
||||
| Entry | Model list source | Notes |
|
||||
| --- | --- | --- |
|
||||
| Claude Code CLI | CCR gateway model discovery | Use `/model` in the CLI to view the list; selected requests still go through CCR providers, routing, and Fusion. |
|
||||
| Claude App | CCR-generated Claude App inference models | Claude App needs Claude-compatible model names. CCR maps `Provider/model` and Fusion models into model entries Claude App can recognize, while display labels keep the real model meaning visible. |
|
||||
|
||||
### OpenCode
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| Provider ID | Writes the OpenCode provider reference, defaulting to `claude-code-router`. |
|
||||
| Provider name | Display name shown in OpenCode, defaulting to `Claude Code Router`. |
|
||||
| OpenCode model | Default model for OpenCode CLI and App. It can be a provider model or Fusion model. |
|
||||
| Config file | System-default mode uses OpenCode's default config; Only opened from CCR writes a profile-specific config under CCR's config directory. |
|
||||
| Environment variables | Injected into OpenCode CLI, OpenCode App, and its Bot worker. |
|
||||
| Bot | Applies to the OpenCode App entry opened from CCR. Incoming Bot messages run through OpenCode CLI and replies are sent back to the same Bot conversation. |
|
||||
|
||||
CCR keeps one OpenCode Bot worker next to the OpenCode App process. The worker stores a project and optional session for each Bot conversation. Send `/project list|current|use` to select an Agent project, then use `/session list|current|new|use|reset` to manage sessions inside that project. Selecting another project clears the previous session, and sessions from another project cannot be selected. Only these slash-command domains are intercepted; removed `/task` and legacy flat commands are not supported.
|
||||
|
||||
The OpenCode CLI must be available as `opencode` in the CCR Desktop process environment. If it is installed elsewhere, set `CCR_OPENCODE_BIN` in the Agent Config environment variables. Bot sessions default to the filesystem root used by a fresh OpenCode Desktop workspace; set `CCR_OPENCODE_BOT_CWD` to the same project directory currently opened in OpenCode App when using another workspace. CCR passes that directory explicitly through `opencode run --dir`, so the resulting session appears under the matching App project. Permissions are not auto-approved by default; `CCR_OPENCODE_BOT_AUTO_APPROVE=true` enables OpenCode's dangerous `--auto` mode and should be used only in a trusted environment.
|
||||
|
||||
### Codex
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| Provider ID | Writes Codex `model_provider`, defaulting to `claude-code-router`. Keep it stable and use only letters, numbers, dots, underscores, or hyphens. |
|
||||
| Provider name | Display name shown in Codex, defaulting to `Claude Code Router`. |
|
||||
| Codex model | Default Codex model. It can be a provider model or Fusion model; if left empty, CCR uses the first available default model. |
|
||||
| Show all sessions | Lets Codex show all sessions. ZCode does not expose this option. |
|
||||
| Config file | Defaults to `~/.codex/config.toml`. Only opened from CCR writes into CCR-managed isolated config directories. |
|
||||
| Environment variables | Injected into Codex CLI or ChatGPT. Claude Code-specific model discovery variables are not passed to Codex. |
|
||||
| Bot | Applies only to the ChatGPT app entry. |
|
||||
|
||||
After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr-app "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display: an existing valid ChatGPT token is shown as the real ChatGPT account, while a profile without credentials uses a tokenless ChatGPT-shaped workspace identity so the desktop renderer keeps model selection available without storing a real user login. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported.
|
||||
|
||||
Model and public plugin listings are not synthesized by the middleware. The native Codex app-server reads the generated `model_catalog_json` and handles `model/list` plus public `plugin/list` requests unchanged. This lets Codex refresh the official public [`openai/plugins`](https://github.com/openai/plugins) Git marketplace over the network. In a virtual workspace, only account-private marketplace requests are answered with an explicit empty result because the native service requires real ChatGPT authentication for those sections; they are never replaced with local plugins. Any downloaded Git checkout is owned only by Codex as its normal last-known-good data, not used by CCR as a replacement catalog.
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI profiles are fixed to **Only opened from CCR** and **CLI only**. After saving, copy and run the card command, for example `ccr-app "Grok - Work"`.
|
||||
|
||||
The generated wrapper sets Grok's model base URL and model-list URL to CCR's `/v1` gateway, supplies the profile-specific CCR API key, and sets the selected CCR model as the default. If the CCR Desktop gateway is not running, `ccr-app` starts a shared temporary service for Grok sessions and cleans it up after the last session exits. Grok CLI does not expose a separate user-config-file option, so CCR points `GROK_HOME` at a profile-specific directory. Its `config.toml` starts as a private copy of the user's config and can change independently, while `auth.json` is excluded to prevent a local xAI OAuth token from overriding the CCR key. Plugins, skills, and sessions remain shared with the original Grok home. Inside Grok CLI, use `/model` to switch among the provider and Fusion models returned by CCR; switched requests continue through CCR.
|
||||
|
||||
### ZCode
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| Provider ID | Writes the ZCode provider reference, defaulting to `claude-code-router`. |
|
||||
| Provider name | Display name shown in ZCode, defaulting to `Claude Code Router`. |
|
||||
| ZCode model | Default model when ZCode App opens. It can be a provider model or Fusion model. |
|
||||
| Config file | Defaults to `~/.zcode/cli/config.json`; CCR also writes ZCode v2 config and model cache. |
|
||||
| Environment variables | Injected into ZCode App and the middleware launcher. |
|
||||
| Bot | Applies only to the ZCode App entry. |
|
||||
|
||||
ZCode supports App only, so its entry mode is fixed to `App only`. The `Show all sessions` option is hidden for ZCode.
|
||||
|
||||
## CLI And App Modes
|
||||
|
||||
| Mode | How to open | Best for | Key differences |
|
||||
| --- | --- | --- | --- |
|
||||
| CLI | Click the terminal button to copy the command, then run `ccr-app <config-name>` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. |
|
||||
| App | Click the play button in the CCR desktop app | Desktop windows, Bot forwarding, handoff | Reopening the same config activates the existing window. Multi-instance behavior depends on the Agent; OpenCode Desktop is single-instance, so CCR stops the managed instance before switching OpenCode profiles. |
|
||||
| CLI & APP | One config exposes both CLI and App entry points | Reusing the same model config in both terminal and desktop App workflows | Both entries share the config name, model, effect scope, and environment variables, but launch differently. |
|
||||
|
||||
## Agent Differences
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code config writes a settings file. With **Only opened from CCR**, CCR creates an isolated settings file under its own config directory and opens Claude Code through a separate launch wrapper.
|
||||
Claude Code CLI config writes a settings file. With **Only opened from CCR**, CCR creates an isolated settings file under its own config directory and opens Claude Code through a separate launch wrapper.
|
||||
|
||||
When opening Claude App from the desktop app, CCR also prepares a separate user-data directory for that config. Different Agent Config entries use different directories, so multiple Claude App instances can run at the same time.
|
||||
|
||||
With a Bot bound, Claude App's companion worker exposes Projects/Sessions, streaming replies, attachments, Session usage, and native permission/Ask User requests to IM. The worker stops with the App.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex config writes `config.toml` and a model catalog file. With **Only opened from CCR**, CCR stores those files in a directory separated by config `id`.
|
||||
|
||||
Codex supports CLI and App. CLI opens through the launcher for the selected config; App uses a separate user-data directory and passes the selected model and provider into Codex App.
|
||||
Codex supports CLI and App. CLI opens through the launcher for the selected config; App launches ChatGPT, uses a separate user-data directory, and passes the selected model and provider into the app.
|
||||
|
||||
With a Bot bound, the Codex App companion worker uses native Codex rollout Sessions for Project/Session browsing and continuation, queueing, cancellation, model settings, usage, attachments, and diagnostics. It exists only alongside the managed App.
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode config writes a JSON/JSONC config that routes the selected provider and model through CCR. CLI opens through a profile-specific wrapper; App launches the installed OpenCode Desktop executable with the same effective config.
|
||||
|
||||
When a Bot is selected and the App is opened from CCR, CCR starts a companion worker using OpenCode-native Sessions and the same Project/Session, queue, media, settings, and diagnostics contract as the other Apps. The worker stops when the managed OpenCode App exits or the profile is switched.
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI supports CLI only. CCR opens it through a profile-specific wrapper that injects the CCR model gateway, model discovery endpoint, API key, and default model. A profile-specific Grok home excludes xAI OAuth credentials so inference reliably uses the CCR key without rewriting the user's original Grok home.
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode supports App only. CCR writes ZCode CLI config, v2 config, and model cache based on ZCode home or a custom config file, then starts the App with the current Agent Config's model, provider, and separate user-data directory.
|
||||
|
||||
With a Bot bound, ZCode uses the Codex-compatible companion worker and native Session discovery. Closing ZCode App immediately takes the relay offline.
|
||||
|
||||
## Multi-Instance Suggestions
|
||||
|
||||
1. Create one Agent Config for each agent instance that should run independently.
|
||||
|
|
|
|||
|
|
@ -70,6 +70,34 @@ 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/siliconflow.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">SiliconFlow</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-runapi" href="ccr://provider?name=RunAPI&base_url=https%3A%2F%2Frunapi.co%2Fv1&protocol=openai_responses" aria-label="Import RunAPI provider">
|
||||
<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-unity2" href="ccr://provider?name=Unity2.Ai&base_url=https%3A%2F%2Funity2.ai%2Fv1&protocol=openai_chat_completions&source=https%3A%2F%2Funity2.ai%2Fregister%3Fsource%3Dclaudecoderouter" aria-label="Import Unity2.Ai provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/unity2.jpg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Unity2.Ai</span><span class="provider-import-meta">OpenAI compatible gateway</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%2Fagent%2Fregister%2F9n9jOsSnYQoemIVL%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" 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%2Fconsole.claudeapi.com%2Fagent%2Fregister%2FLbmB7Y9kPloyzhwF%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" 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>
|
||||
<a class="provider-import-button provider-qiniu-ai" href="ccr://provider?name=%E4%B8%83%E7%89%9B%E4%BA%91+AI&base_url=https%3A%2F%2Fapi.qnaigc.com&protocol=openai_chat_completions&source=https%3A%2F%2Fs.qiniu.com%2FAVjMVf" aria-label="Import Qiniu Cloud AI provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/qiniu-ai.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Qiniu Cloud AI</span><span class="provider-import-meta">Chat / Responses / Anthropic / Gemini Generate</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-fenno" href="ccr://provider?name=Fenno.ai&base_url=https%3A%2F%2Fapi.fenno.ai&protocol=openai_chat_completions&source=https%3A%2F%2Fapi.fenno.ai%2Fregister%3Fredirect%3D%2Fpurchase%3Ftab%3Dsubscription%2526group%3D16%26aff%3D9HHHAB5QLAES" aria-label="Import Fenno.ai provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/fenno.jpg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Fenno.ai</span><span class="provider-import-meta">Chat / Responses / Anthropic</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Embeddable Button Component
|
||||
|
|
@ -129,7 +157,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 |
|
||||
|
|
@ -257,7 +285,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 |
|
||||
|
|
|
|||
|
|
@ -5,20 +5,54 @@ eyebrow: Detailed Configuration
|
|||
lead: Configure upstream model services, credentials, protocols, base URLs, and model lists.
|
||||
---
|
||||
|
||||
## Basic Concept
|
||||
## Import Local Agent Login
|
||||
|
||||
A provider is an upstream model service. One provider config describes the upstream address, protocol capabilities, model list, authentication, optional multi-key rotation, and optional account usage fetching.
|
||||
When you add a provider, CCR scans for reusable local agent login state. If usable credentials are found, the add dialog shows the matching import entry. Importing creates a normal provider plus provider plugins, so CCR can reuse the local agent authorization without requiring a pasted API key.
|
||||
|
||||
Each provider needs at least a name, API endpoint, usable protocol, model list, and one valid credential. The add/edit dialog changes which fields are visible depending on preset providers, custom endpoints, local agent login import, and advanced settings.
|
||||
### Claude Code
|
||||
|
||||
Preset providers fill common endpoints, protocols, models, and usage-fetching settings, so they are the recommended starting point. For custom providers, enter the endpoint manually. CCR uses the endpoint and API key to probe protocol compatibility and help identify whether the upstream works with OpenAI Chat, OpenAI Responses, Anthropic Messages, or Gemini Generate.
|
||||
Claude Code import reads local Claude Code OAuth credentials. When a usable access token is available, CCR can import it as a `Claude Code API` provider.
|
||||
|
||||
After import:
|
||||
|
||||
1. The protocol is `anthropic_messages`.
|
||||
2. The default model list includes `claude-sonnet-4-20250514`; you can later add or remove models in the provider model list.
|
||||
3. CCR creates OAuth provider plugins that convert requests to use the Claude Code login state.
|
||||
4. Account usage uses the Anthropic OAuth usage endpoint, so quota state can appear in the provider list, tray, and account panels.
|
||||
|
||||
If CCR only detects login traces but no usable access token, the import entry shows why it cannot be imported. Re-authenticate in Claude Code, then return to CCR and add the provider again.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex import reads the local Codex auth file and model cache. When a Codex access token or refresh token is available, CCR can import it as a `Codex API` provider.
|
||||
|
||||
After import:
|
||||
|
||||
1. The protocol is `openai_responses`.
|
||||
2. The API endpoint points to the Codex backend. The model list always includes at least `gpt-5-codex` and also merges models and display names from the local model cache.
|
||||
3. CCR creates Codex OAuth provider plugins and refreshes access credentials when needed.
|
||||
4. Account usage reads Codex quota, balance, and token-stat endpoints.
|
||||
|
||||
After import, select `Codex API/model-name` in routing or Agent Config. If the model cache is stale, open Codex first so it refreshes the model list, then return to CCR to import again or edit the models.
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode import reads provider API keys, API endpoints, and model lists from local ZCode config. It can be imported as a `ZCode API` provider only when CCR finds a usable provider key and Base URL.
|
||||
|
||||
After import:
|
||||
|
||||
1. The protocol is `anthropic_messages`.
|
||||
2. Models come from local ZCode config first; if none are configured, CCR uses the ZCode runtime cache or default models.
|
||||
3. CCR creates API-key provider plugins that use the key from local ZCode config for request authentication.
|
||||
4. If the API endpoint matches a built-in CCR preset, account usage settings are reused from that preset.
|
||||
|
||||
If CCR detects ZCode login state but no usable provider API key, the import entry remains unavailable. Configure a usable model provider in ZCode first, then return to CCR and add the provider.
|
||||
|
||||
## Main Fields
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Select preset provider | Applies a built-in provider template, including default endpoint, supported protocols, default models, icon, provider website, and sometimes account usage settings. Choose `Other / custom API endpoint` for any OpenAI, Anthropic, or Gemini compatible upstream. |
|
||||
| Import local agent login | Appears while adding a provider if CCR finds usable Claude Code, Codex, or ZCode login state on this computer. Importing creates a provider and provider plugin that reuse the local login credential instead of a normal pasted API key. |
|
||||
| Name | Internal CCR display name. It is also used by routing, model selectors, logs, and config references. Names must be unique. |
|
||||
| API endpoint | Upstream API base URL. It controls where requests are sent, and is also used for protocol probing, model discovery, icon detection, and safety checks. Preset providers hide it by default while adding, but it can be overridden in Advanced settings. Custom providers must provide it. |
|
||||
| API key | Default provider credential. When the credential pool is empty, model requests use this key. Protocol probing, model discovery, connection checks, and default usage fetching also use it. Only use a key issued for the selected endpoint. |
|
||||
|
|
@ -27,13 +61,13 @@ Preset providers fill common endpoints, protocols, models, and usage-fetching se
|
|||
| Custom models | Manually adds model IDs that discovery did not return. Use this when the provider lacks a `/models` endpoint or a new model is not in the catalog yet. |
|
||||
| Check Connection | Sends real test requests with the current endpoint, API key, protocol, and selected models. It verifies key, model name, and protocol usability. |
|
||||
| Models to check | Model selection inside the connection-check confirmation dialog. Use it to test only some models. |
|
||||
| Check results | Shows whether each model is available, which protocol matched, and the upstream diagnostic message. Passing results do not automatically add models; the main model selection remains authoritative. |
|
||||
| Check results | Shows whether each model is available, which protocol matched, and the upstream diagnostic message. Results are diagnostic. Add models through the main model selection when you want them saved. |
|
||||
|
||||
## Connectivity Checks
|
||||
|
||||
`Check Connection` sends real model requests for the models you select. It verifies whether the endpoint, API key, protocol, and model IDs are usable. The check limits generated output, but it can still create extra token usage or count against provider-side request limits.
|
||||
|
||||
If the provider bills by request, input tokens, or output tokens, select only the models you need to verify instead of checking every model at once. Check results are diagnostic only; they do not automatically change the model list or usage-fetching settings.
|
||||
If the provider bills by request, input tokens, or output tokens, select only the models you need to verify. Checking every model at once can create unnecessary usage. Review the diagnostics, then adjust the model list or usage-fetching settings manually when needed.
|
||||
|
||||
## Credentials
|
||||
|
||||
|
|
@ -90,7 +124,7 @@ This mode tries provider-hosted CCR account endpoints such as `/.well-known/ccr/
|
|||
|
||||
### HTTP JSON Request
|
||||
|
||||
Use this mode when the provider has a balance or quota JSON endpoint that does not match CCR's standard account format.
|
||||
Use this mode when the provider has a balance or quota endpoint that returns a custom JSON shape.
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
|
|
|
|||
|
|
@ -5,20 +5,141 @@ eyebrow: Detailed Configuration
|
|||
lead: Choose the model for a request, then automatically retry or switch to fallback models when the request fails.
|
||||
---
|
||||
|
||||
## How Routing Works
|
||||
## Built-In Routing
|
||||
|
||||
CCR first decides which model the request should use, then forwards the request upstream. The current implementation follows this order:
|
||||
### Claude Code
|
||||
|
||||
1. If the incoming `model` is already a known `provider/model` selector, CCR uses it directly.
|
||||
2. If a custom router script is configured, the model returned by that script takes priority over UI routing rules.
|
||||
3. Routing rules are evaluated from top to bottom. The first matching rule applies its request rewrite.
|
||||
4. If no rule matches, CCR uses the default route. If no default is configured, it keeps the original request model.
|
||||
The built-in Claude Code route detects requests from Claude Code and routes main requests to the Claude Code Agent Config model.
|
||||
|
||||
The core shape of a rule is **Condition + Request action**. The condition decides whether the rule matches; the request action changes request fields. The most common action is setting `request.body.model` to a provider model or Fusion model.
|
||||
Claude Code **main requests** use the Claude Code Agent Config model. If that model is unset, the built-in route remains inactive. CCR also automatically removes the first `x-anthropic-billing-header` system message injected by Claude Code so that billing helper messages do not affect later routing decisions. Claude Code Subagent, Task, and Workflow-created agents can still choose different models through the tag mechanism below.
|
||||
|
||||
## What Fallback Does
|
||||
#### Subagent / Workflow Auto-Routing
|
||||
|
||||
Fallback is the failure strategy after a model or upstream request fails. It does not pick the first model; it decides whether CCR should keep trying after the current target fails.
|
||||
Claude Code Agent / Task / Workflow can spawn additional model requests. CCR uses tag injection to let those spawned requests choose a more appropriate CCR model:
|
||||
|
||||
```text
|
||||
<CCR-SUBAGENT-MODEL>provider/model</CCR-SUBAGENT-MODEL>
|
||||
```
|
||||
|
||||
The full flow is:
|
||||
|
||||
1. A Claude Code main request matches the built-in route, so CCR inspects the current tool list.
|
||||
2. If at least one model has a **Description**, CCR injects the available models and descriptions into the `Agent` / `Task` tool description and `prompt` field description.
|
||||
3. If the tool list includes `Workflow`, CCR appends a Workflow-specific instruction: whenever the workflow creates an `Agent` / `Task`, each spawned agent prompt must start with the same model tag.
|
||||
4. When Claude Code calls `Agent` / `Task`, or when a Workflow creates an agent, the prompt starts with `<CCR-SUBAGENT-MODEL>provider/model</CCR-SUBAGENT-MODEL>`.
|
||||
5. When the spawned request reaches CCR, CCR extracts and removes the tag from the system prompt or the first two user messages, then routes that request to the tagged model.
|
||||
|
||||
Subagent / Workflow auto-routing therefore does not use headers such as `x-claude-code-agent-id` as the model selector. Those headers can help with observation, but the actual model selection comes from the prompt tag.
|
||||
|
||||
##### Pairing It With The Models Page
|
||||
|
||||
The **Description** field on the Models page is both the enablement switch and the selection guide for this mechanism. If no model has a Description, CCR does not inject Agent / Task / Workflow routing instructions, so it does not write an empty model list into tool descriptions.
|
||||
|
||||
Recommended setup:
|
||||
|
||||
1. Add usable models under **Providers**, and verify that the model IDs can be requested.
|
||||
2. Open **Models** and fill Description for the models you want Subagents to choose automatically. Describe task fit, speed, cost, and limits.
|
||||
3. Enable a Claude Code config under **Agent Config**, and choose the main model. This model handles the main Claude Code conversation.
|
||||
4. Confirm that the built-in **Claude Code** route is enabled on the **Routing** page.
|
||||
5. Use Agent, Task, or Workflow in Claude Code. When Claude Code spawns an agent, it can choose a CCR model from the descriptions and write the tag.
|
||||
|
||||
Write descriptions around tasks instead of only naming the provider. For example:
|
||||
|
||||
| Model purpose | Description example |
|
||||
| --- | --- |
|
||||
| Fast low-cost model | Good for code search, file triage, summaries, small edits, and low-cost parallel Subagents. |
|
||||
| Strong reasoning model | Good for complex architecture analysis, large refactor planning, cross-file reasoning, and high-risk code review. |
|
||||
| Long-context model | Good for reading large logs, long documents, repository-scale context gathering, and Workflow summaries. |
|
||||
|
||||
After saving, CCR formats those descriptions as “Configured CCR gateway models” in the injected Claude Code instructions. When Claude Code picks a model, request logs should show `builtin:claude-code-subagent`, and the tagged model becomes the final `resolved model`.
|
||||
|
||||
### Codex
|
||||
|
||||
The built-in Codex route adapts Codex's `apply_patch` file-editing tool for third-party or non-GPT models. The goal is for those models to edit files through the patch tool instead of generating commands or scripts such as `cat >`, `sed -i`, `python`, or `node`.
|
||||
|
||||
Technically, this is a tool protocol bridge. Native Codex `apply_patch` is a custom/freeform tool whose input is raw patch text, while many OpenAI-compatible third-party models handle ordinary function tools more reliably. CCR rewrites `apply_patch` into an upstream-visible `virtual_apply_patch` function tool and injects the full `apply_patch.lark` grammar into the tool description, requiring the model to put the patch in the `patch` field.
|
||||
|
||||
When the model returns `virtual_apply_patch`, CCR rewrites it back to Codex's expected shape: `custom_tool_call` with `name = apply_patch` and `input = raw patch text`. CCR does not edit files directly; Codex still executes the resulting patch. This adaptation follows the built-in **Codex** route and has no separate switch. GPT-named models keep using Codex's native freeform `apply_patch` path.
|
||||
|
||||
## Custom Routing
|
||||
|
||||
Custom routes are configured in the Routing page rule list. The top **Search routing rules** field filters by rule name, condition, request action, and related row text; the top-right **Add** button opens the **Add Routing Rule** dialog. The table shows each rule under **Name**, **Condition**, **Request action**, **Status**, and **Action**.
|
||||
|
||||
Custom rules match in list order, and the first enabled matching rule rewrites the request. Use the move up and move down buttons to adjust priority. Use the edit button to open **Edit Routing Rule**, and the delete button to open a confirmation dialog. Turning off the **Status** toggle keeps the rule in the list but removes it from matching.
|
||||
|
||||
### Add Or Edit A Rule
|
||||
|
||||
The dialog fields map directly to the saved rule:
|
||||
|
||||
| UI field | How to fill it | Saved meaning |
|
||||
| --- | --- | --- |
|
||||
| **Name** | Enter a recognizable rule name. This field is required. | Shown in the **Name** column and included in search. |
|
||||
| **Condition** | Choose `request.header` or `request.body`, then fill in field, operator, and value. | Builds `condition.left`, `condition.operator`, and `condition.right`. |
|
||||
| **Rewrite request parameters** | Keep at least one rewrite row. Each row chooses an operation, target key, and required value fields. | Builds `rewrites`, applied when the rule matches. |
|
||||
| **Enabled** | Turn the rule on or off. | Controls `enabled`; disabled rules do not match. |
|
||||
| **On failure** | Configure fallback behavior for this rule. | Overrides **Default on failure** when this rule matches. |
|
||||
|
||||
The **Add** or **Save** button is enabled only when the form is valid: name, condition field, and condition value are required; every rewrite row must have a key. **Delete** only requires a key. **Replace in array** requires both **Match value** and **Value**. Other operations require **Value**.
|
||||
|
||||
### Condition
|
||||
|
||||
The **Condition** area has four controls: source, field, operator, and value.
|
||||
|
||||
| Source | Field examples | Matched path |
|
||||
| --- | --- | --- |
|
||||
| `request.header` | `user-agent`, `x-api-key`, `x-client-name` | `request.header.user-agent` |
|
||||
| `request.body` | `model`, `messages`, `messages.0.role`, `tools` | `request.body.model` |
|
||||
|
||||
Header names are case-insensitive. Body fields use dot-path lookup, and numeric segments address array indexes; for example, `messages.0.role` reads the first message role. For nested arrays such as `messages` or `tools`, `contains deep` is usually more robust than a fixed index.
|
||||
|
||||
The value field is parsed as a common literal when possible: `true`, `false`, `null`, numbers, JSON objects, and JSON arrays compare as their corresponding types. Other input is treated as a string. To force a value to stay string-like, wrap it as `"123"` or `'123'`.
|
||||
|
||||
| Operator | Use |
|
||||
| --- | --- |
|
||||
| `==` / `!=` | Compare actual and expected values. Numbers compare numerically; other values compare by comparable text. |
|
||||
| `>` / `>=` / `<` / `<=` | Compare numerically when both sides are numbers; otherwise compare text order. |
|
||||
| `starts with` | Check whether the actual value starts with the input value. Useful for model-prefix routing. |
|
||||
| `contains` | Check substring containment for strings; for arrays, check direct array elements. |
|
||||
| `contains deep` | Recursively checks objects and arrays. Useful for searching `messages` and `tools`. |
|
||||
| `not contains` | The inverse of `contains`. |
|
||||
|
||||
### Rewrite Request Parameters
|
||||
|
||||
The **Rewrite request parameters** area starts with one `request.body.model` row. This is the common model-routing path: choose **Set**, use key `request.body.model`, and set the value to a target `provider/model` or Fusion model.
|
||||
|
||||
Click **Add parameter** to add more rewrite rows. The trash button removes a row, but the last row cannot be removed. When the rule matches, CCR applies the rewrite rows in order.
|
||||
|
||||
| Operation | Required fields | Behavior |
|
||||
| --- | --- | --- |
|
||||
| **Set** | key, value | Sets a request field, such as `request.body.model = provider/model` or `request.body.temperature = 0.2`. |
|
||||
| **Delete** | key | Deletes a request field. Deleting `request.header.x-test` removes that header; deleting `request.body.foo` removes that body field. |
|
||||
| **Append to array** | key, value | Appends the value to the target array. If the target is not an array, CCR starts from an empty array. |
|
||||
| **Prepend to array** | key, value | Prepends the value to the target array. |
|
||||
| **Remove from array** | key, value | Removes array elements equal to the value. |
|
||||
| **Replace in array** | key, match value, value | Replaces array elements matching **Match value** with the new value. |
|
||||
|
||||
Rewrite values are also parsed as literals, so `0.2` becomes a number, `true` becomes a boolean, and `{"type":"web_search"}` becomes an object. Only `request.body.model` receives additional CCR model-selector normalization.
|
||||
|
||||
### On Failure
|
||||
|
||||
The dialog **On failure** control is the same control used by the page-level **Default on failure** setting. Choose **Off** to avoid fallback. Choose **Retry** to reveal **Retries**. Choose **Fallback targets** to reveal the **Fallback target** input and **Add** button. Added targets appear as tags with move up, move down, and remove buttons for ordering the fallback chain.
|
||||
|
||||
When a rule matches, its **On failure** setting is used. Requests that do not match a rule continue to use the page-level default.
|
||||
|
||||
### Examples
|
||||
|
||||
| Goal | Condition source | Field | Operator | Value | Rewrite request parameters |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Route by client header | `request.header` | `x-client-name` | `==` | `claude-code` | **Set** `request.body.model = provider/model` |
|
||||
| Route by original model prefix | `request.body` | `model` | `starts with` | `claude-` | **Set** `request.body.model = provider/model` |
|
||||
| Route message content to a vision model | `request.body` | `messages` | `contains deep` | `image` | **Set** `request.body.model = vision-provider/model` |
|
||||
| Remove a debug header | `request.header` | `x-debug-route` | `==` | `1` | **Delete** `request.header.x-debug-route` |
|
||||
|
||||
After saving, the rule appears in the list. Use request logs, especially `request model`, `resolved provider`, `resolved model`, and route reason, to verify that it matched.
|
||||
|
||||
## Fallback Handling
|
||||
|
||||
Fallback is the failure strategy after a model or upstream request fails. Routing picks the first model; Fallback decides whether CCR should keep trying after the current target fails.
|
||||
|
||||
The **Default on failure** control at the top of the Routing page is the global Fallback. Each rule also has **On failure**. When a rule matches, its rule-level Fallback overrides the global Fallback.
|
||||
|
||||
|
|
@ -41,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.
|
||||
|
||||
|
|
@ -55,7 +176,7 @@ Configure **Default on failure** at the top of the Routing page:
|
|||
2. If you choose **Retry**, set `Retries`.
|
||||
3. If you choose **Fallback targets**, add backup models in priority order.
|
||||
|
||||
Global Fallback applies to the default route and to rules that do not define their own Fallback.
|
||||
Global Fallback applies to routing rules that do not define their own Fallback.
|
||||
|
||||
### Rule-Level Fallback
|
||||
|
||||
|
|
@ -63,24 +184,6 @@ When adding or editing a routing rule, configure **On failure** for that rule.
|
|||
|
||||
Rule-level Fallback is useful for high-risk or expensive targets. For example, route image tasks to a Fusion vision model first, then fall back to another multimodal model; or route complex tasks to a strong model first, then fall back to a stable model.
|
||||
|
||||
### Conditional Routing
|
||||
|
||||
The current UI primarily creates conditional rules. Conditions can read request headers or the request body:
|
||||
|
||||
| Source | Example |
|
||||
| --- | --- |
|
||||
| `request.header` | `x-client-name == claude-code` |
|
||||
| `request.body` | `model starts-with claude-` |
|
||||
| `request.body` | `messages contains-deep image` |
|
||||
|
||||
After a match, the request action can set, delete, or modify request fields. The most common action is:
|
||||
|
||||
```text
|
||||
set request.body.model = provider/model
|
||||
```
|
||||
|
||||
The model can also be a Fusion model, so routing can send selected requests to vision, search, or tool-augmented models.
|
||||
|
||||
## Verification
|
||||
|
||||
After saving, send a request and inspect Logs:
|
||||
|
|
|
|||
|
|
@ -5,9 +5,17 @@ eyebrow: Detailed Configuration
|
|||
lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception and proxying into CCR.
|
||||
---
|
||||
|
||||
## Basic Concept
|
||||
## Management And Gateway Addresses Are Separate
|
||||
|
||||
The Server page controls how the local CCR gateway listens for requests. Normal model requests usually need only `Host` and `Port`.
|
||||
The Host/Port fields under **Server** configure the model gateway, not the browser management page:
|
||||
|
||||
| Distribution | Management entry | Model gateway |
|
||||
| --- | --- | --- |
|
||||
| Desktop | App window | `http://127.0.0.1:3456` by default |
|
||||
| npm CLI | `http://127.0.0.1:3458` by default | `http://127.0.0.1:3456` by default |
|
||||
| Docker | Public `http://127.0.0.1:3458` by default | Combined into the same public Nginx endpoint |
|
||||
|
||||
CLI `--host`/`--port` options configure management; this page configures the gateway. Docker internal listeners should not be published separately. See [Docker Deployment](../../guides/docker/).
|
||||
|
||||
## Main Fields
|
||||
|
||||
|
|
@ -16,6 +24,20 @@ The Server page controls how the local CCR gateway listens for requests. Normal
|
|||
| Host | Host address the CCR gateway listens on. Common values are `127.0.0.1` and `0.0.0.0`. |
|
||||
| Port | Gateway listening port. Clients should point their API base URL to this port. |
|
||||
|
||||
`127.0.0.1` allows local access only; `0.0.0.0` listens on every IPv4 interface. Use a wildcard only for intentional LAN/remote access, together with CCR client API keys, firewall/private-network controls, and TLS at a reverse proxy.
|
||||
|
||||
Management tokens, CCR client API keys, and upstream credentials are separate. Gateway clients use keys created under **API Keys** and should never receive upstream provider credentials.
|
||||
|
||||
## Start And Verify
|
||||
|
||||
1. Add at least one provider and model.
|
||||
2. Create a client key under **API Keys**.
|
||||
3. Click **Start** or **Restart**.
|
||||
4. Confirm Running status and request the gateway `/health` route.
|
||||
5. Send a minimal model request and inspect the resolved provider/model under Logs.
|
||||
|
||||
A reachable management UI does not prove the gateway is running. Docker returns `502` from `/health` until the gateway starts, and desktop/CLI can keep management available without usable models.
|
||||
|
||||
## Proxy Mode
|
||||
|
||||
Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HTTPS traffic to CCR. CCR uses MITM interception to identify and decrypt HTTPS requests, then proxies supported model requests into the CCR gateway path.
|
||||
|
|
@ -30,3 +52,5 @@ Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HT
|
|||
| Check Trust | Checks again whether the proxy CA is trusted by the system. |
|
||||
| Proxy status | Shows whether the proxy service is running. |
|
||||
| Restart Proxy | Restarts the proxy service when proxy mode is enabled. |
|
||||
|
||||
Proxy mode changes local networking and certificate trust and is primarily a desktop feature. Container deployments should normally point clients directly at the public CCR Nginx gateway instead of trying to change the host system proxy or install a host CA from inside the container.
|
||||
|
|
|
|||
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.
|
||||
|
|
@ -5,10 +5,6 @@ eyebrow: Detailed Configuration
|
|||
lead: Configure the CCR system tray icon, balance progress, and tray window widgets.
|
||||
---
|
||||
|
||||
## Basic Concept
|
||||
|
||||
Tray settings live under Settings. They control the system tray icon and the tray window. The tray window can show providers, account balance, token trends, activity, metrics, and model share so you can check CCR status without opening the main window.
|
||||
|
||||
## Top Fields
|
||||
|
||||
| Field | Capability |
|
||||
|
|
|
|||
|
|
@ -7,17 +7,15 @@ lead: Start from installation, connect a provider, let agents send requests thro
|
|||
|
||||
## Install And Start CCR
|
||||
|
||||
### Download And Install
|
||||
CCR is available as a desktop app, a Node.js 22+ npm CLI, and a single-entrypoint Docker deployment.
|
||||
|
||||
1. Open the [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) page.
|
||||
2. Download the package for your system: `.dmg` or `.zip` for macOS, `.exe` for Windows, and `.AppImage` for Linux.
|
||||
3. Install and open **Claude Code Router** like a normal desktop app.
|
||||
| Distribution | Start entry | Default management | Default model gateway |
|
||||
| --- | --- | --- | --- |
|
||||
| Desktop | App UI / `ccr-app` | In-app window | `http://127.0.0.1:3456` |
|
||||
| npm CLI | `ccr ui` / `ccr serve` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
|
||||
| Docker | `docker compose up -d --build` | Shared `http://127.0.0.1:3458` | Shared Nginx endpoint |
|
||||
|
||||
### Start The Service
|
||||
|
||||
Open the **Server** page and click **Start**. After the page shows Running, CCR listens on the default local address `http://localhost:8080`.
|
||||
|
||||
If you want the service to start when the app opens, enable **Auto start** on the Server page.
|
||||
Use the [installation page](install/) to choose a distribution. See the [CLI reference](cli/) for terminal commands and [Docker Deployment](docker/) for container ports, authentication, persistence, and upgrades.
|
||||
|
||||
## Add A Provider
|
||||
|
||||
|
|
@ -57,7 +55,7 @@ If you want the overview to show balance or remaining quota, open the provider's
|
|||
|
||||
## Connect Agent Config
|
||||
|
||||
Agent Config lets Claude Code, Codex, ZCode, and other agents use CCR's providers, routing, and model selection.
|
||||
Agent Config lets Claude Code, Codex, Grok CLI, ZCode, and other agents use CCR's providers, routing, and model selection.
|
||||
|
||||
General guidance:
|
||||
|
||||
|
|
@ -73,13 +71,17 @@ In **Agent Config**, choose Claude Code, set the model, small fast model, and se
|
|||
|
||||
In **Agent Config**, choose Codex and confirm Provider ID, Provider Name, model, and config file. Only fill Codex CLI path and Codex home when you need a specific CLI or home directory.
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Choose Grok CLI and select a default model, then run the copied `ccr-app <profile-name>` command. The command starts a shared temporary gateway service when CCR Desktop is not already serving one; concurrent Grok sessions keep it alive until the last session exits. CCR points Grok model discovery and inference at the local gateway; use `/model` inside Grok to switch CCR models.
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields.
|
||||
|
||||
### Reuse A Locally Logged-In Agent
|
||||
|
||||
If Claude Code, Codex, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
|
||||
If Claude Code, Codex, Grok CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
|
||||
|
||||
## Logs & Observability
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: Connect Agent Config
|
||||
pageTitle: Connect Agent Config
|
||||
eyebrow: Quick Start
|
||||
lead: Let Claude Code, Codex, ZCode, and other agents use CCR's providers, routing, and model selection.
|
||||
lead: Let Claude Code, Codex, Grok CLI, ZCode, and other agents use CCR's providers, routing, and model selection.
|
||||
---
|
||||
|
||||
## General Guidance
|
||||
|
|
@ -23,10 +23,14 @@ In **Agent Config**, choose Codex and confirm Provider ID, Provider Name, model,
|
|||
|
||||
Only fill Codex CLI path and Codex home when you need a specific CLI or home directory.
|
||||
|
||||
## Grok CLI
|
||||
|
||||
Choose Grok CLI, select a model, and run the copied `ccr-app <profile-name>` command. When the CCR Desktop gateway is not running, the command starts a shared temporary gateway service that remains available until the last concurrent Grok session exits. Use `/model` inside Grok to switch among models exposed by CCR.
|
||||
|
||||
## ZCode
|
||||
|
||||
ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields.
|
||||
|
||||
## Reuse A Locally Logged-In Agent
|
||||
|
||||
If Claude Code, Codex, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
|
||||
If Claude Code, Codex, Grok CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key.
|
||||
|
|
|
|||
161
docs/src/content/docs/en/guides/cli.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
---
|
||||
title: CLI Installation And Reference
|
||||
pageTitle: CLI Installation And Reference
|
||||
eyebrow: Quick Start
|
||||
lead: Run the browser management UI and model gateway from npm, and launch locally installed agents through CCR profiles without Electron.
|
||||
---
|
||||
|
||||
## `ccr` And `ccr-app`
|
||||
|
||||
CCR has two related commands:
|
||||
|
||||
| Command | Source | Primary use |
|
||||
| --- | --- | --- |
|
||||
| `ccr` | npm package `@musistudio/claude-code-router` | Electron-free management UI, gateway service, and profile launches. |
|
||||
| `ccr-app` | CCR desktop application | Desktop-managed profile launcher used by commands copied from Agent Config cards. |
|
||||
|
||||
Both distributions use the same local configuration directory, but their command names are not interchangeable. Use the desktop app for tray features, notifications, automatic app updates, and desktop-only browser integrations. Use the npm CLI for headless hosts or external process supervision.
|
||||
|
||||
## Install, Upgrade, Or Remove
|
||||
|
||||
Node.js 22 or newer is required:
|
||||
|
||||
```sh
|
||||
node --version
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr --help
|
||||
```
|
||||
|
||||
Upgrade or uninstall with npm:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router@latest
|
||||
npm uninstall -g @musistudio/claude-code-router
|
||||
```
|
||||
|
||||
Uninstalling the package does not delete local configuration or databases. If `ccr` is not found, run `npm prefix -g`, add npm's global binary directory to `PATH`, and open a new shell.
|
||||
|
||||
## First Start
|
||||
|
||||
Start the background service and open the UI:
|
||||
|
||||
```sh
|
||||
ccr ui
|
||||
```
|
||||
|
||||
For SSH or headless sessions:
|
||||
|
||||
```sh
|
||||
ccr ui --no-open
|
||||
```
|
||||
|
||||
Then add a provider/model, create a CCR client key under **API Keys**, configure routing if needed, and confirm the gateway is running under **Server**. The management UI defaults to `http://127.0.0.1:3458`; the model gateway defaults to `http://127.0.0.1:3456`.
|
||||
|
||||
The management token and CCR client keys are separate credentials. The first protects UI/RPC access; the second authenticates model gateway requests.
|
||||
|
||||
## Service Command Summary
|
||||
|
||||
| Command | Mode | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ccr start` | Background | Starts management and the gateway, then prints the authenticated management URL. |
|
||||
| `ccr ui` | Background | Reuses or starts the background service and opens a browser. |
|
||||
| `ccr stop` | One-shot | Stops the service created by `start` or `ui`. |
|
||||
| `ccr serve` | Foreground | Runs in the current terminal for logs or process supervision. |
|
||||
| `ccr web` | Foreground | Alias of `serve`. |
|
||||
| `ccr <profile>` | Foreground | Launches an enabled Agent Config profile. |
|
||||
|
||||
## Service Options
|
||||
|
||||
```text
|
||||
ccr start [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
ccr ui [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
ccr serve [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
ccr stop
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--host <host>` | Management listener, default `127.0.0.1`. `--host=value` is also accepted. |
|
||||
| `--port <port>` | Preferred management port, default `3458`. `--port=value` is also accepted. |
|
||||
| `--open` / `--no-open` | Enables or disables browser opening. `ui` opens by default. |
|
||||
| `--gateway` | Explicitly requests model gateway startup; this is the default. |
|
||||
| `--no-gateway` | Starts management without starting the model gateway during service startup. |
|
||||
|
||||
When the preferred port is occupied, CCR tries following ports and prints the actual URL. `serve` handles `SIGINT` and `SIGTERM`; `ccr stop` manages only detached services.
|
||||
|
||||
## Background Service Reuse
|
||||
|
||||
`start` and `ui` store the process ID, URL, and a private service token in `service.json`. A later invocation verifies both the process and RPC identity before reuse.
|
||||
|
||||
- A valid service is reused rather than duplicated.
|
||||
- New host, port, or `--no-gateway` options do not reconfigure an already running service.
|
||||
- A command that requires the gateway can ask the existing management process to start it.
|
||||
- Stale state is removed before a replacement service starts.
|
||||
|
||||
Stop first when changing listener settings:
|
||||
|
||||
```sh
|
||||
ccr stop
|
||||
ccr start --host 127.0.0.1 --port 3458
|
||||
```
|
||||
|
||||
## Launch Agent Config Profiles
|
||||
|
||||
Create and enable a profile under **Agent Config**, then use:
|
||||
|
||||
```text
|
||||
ccr <profile-name-or-id> [cli|app] [-- <agent arguments>]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
ccr "Codex - Work"
|
||||
ccr "Codex - Work" app
|
||||
ccr "Claude - Review" cli -- --model sonnet
|
||||
ccr profile-id -- --help
|
||||
```
|
||||
|
||||
- `--cli` and `--app` are alternatives to the positional surface.
|
||||
- Put agent arguments after `--` to avoid ambiguity.
|
||||
- Claude Code, Codex, and Grok default to CLI; ZCode defaults to App.
|
||||
- Grok supports CLI only; ZCode supports App only.
|
||||
- Claude App and ZCode App reject trailing agent arguments.
|
||||
- App launches require a locally installed application and graphical session.
|
||||
- Only enabled profiles are launchable. Use the profile ID when names are ambiguous.
|
||||
|
||||
Most profiles require the CCR gateway to be running. Grok CLI can create a managed temporary shared service and stops it after the final managed Grok session exits.
|
||||
|
||||
## Configuration And Data
|
||||
|
||||
| Platform | Configuration directory |
|
||||
| --- | --- |
|
||||
| macOS / Linux | `~/.claude-code-router` |
|
||||
| Windows | `%APPDATA%\claude-code-router` |
|
||||
|
||||
Important paths include `config.sqlite`, `app-data/`, `service.json`, `gateway.config.json`, `profiles/`, and generated launch wrappers under `bin/`. Do not edit or copy live SQLite files. Use **Settings → Export data**, or stop CCR before taking a filesystem backup.
|
||||
|
||||
## Authentication And Remote Access
|
||||
|
||||
`CCR_WEB_HOST` and `CCR_WEB_PORT` provide defaults when command-line listener options are omitted. Set `CCR_WEB_AUTH_TOKEN` to keep a stable management UI/RPC token; otherwise a random token is generated for the process. The authenticated management URL contains `ccr_web_token`; treat the full URL as a password.
|
||||
|
||||
Keep the listener on `127.0.0.1` unless remote access is intentional. A remote deployment should use a strong fixed token, firewall/private network controls, and TLS at a trusted reverse proxy. Create separate CCR client API keys for gateway access and protect the data directory because it contains upstream credentials.
|
||||
|
||||
## Process Supervisors
|
||||
|
||||
Use `ccr serve --no-open` with an external supervisor. Fix the service user, `HOME`, listener, and `CCR_WEB_AUTH_TOKEN`. Do not also run a detached `ccr start` service, which can create a second management listener or make both processes compete for the same configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **UI works but gateway requests fail:** add a provider/model and CCR client key, start the gateway under **Server**, and use `ccr serve` to inspect startup errors.
|
||||
- **The UI is not on port 3458:** the preferred port was occupied; use the printed URL or stop the conflict.
|
||||
- **Profile not found:** confirm it is enabled, use its ID when names are ambiguous, and re-save it if generated launchers are missing.
|
||||
- **Old background options remain active:** run `ccr stop`, then start again with the new options.
|
||||
- **A foreground service does not stop through `ccr stop`:** stop `ccr serve` from its terminal or supervisor.
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Install And Start CCR](../install/)
|
||||
- [Agent Config](../../configuration/profiles/)
|
||||
- [Server](../../configuration/server/)
|
||||
- [Docker Deployment](../docker/)
|
||||
198
docs/src/content/docs/en/guides/docker.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
---
|
||||
title: Docker Deployment
|
||||
pageTitle: Docker Deployment
|
||||
eyebrow: Quick Start
|
||||
lead: Run CCR Core and the browser UI behind a single Nginx entrypoint with documented ports, authentication, persistence, upgrades, and troubleshooting.
|
||||
---
|
||||
|
||||
## Scope And Limitations
|
||||
|
||||
The image contains CCR Core, the built management UI, PM2, and Nginx. It is intended for a persistent model gateway and browser administration. It does not include Electron, the npm `ccr` command, tray features, host desktop Agent/App launching, desktop automatic updates, or desktop-only browser integrations.
|
||||
|
||||
Use the desktop distribution for local app profiles and tray workflows, or the [CLI](../cli/) for an Electron-free host command.
|
||||
|
||||
## Process And Port Topology
|
||||
|
||||
```text
|
||||
host 3458 -> container Nginx 8080
|
||||
|-> static management UI
|
||||
|-> management RPC: 127.0.0.1:3459
|
||||
|-> model gateway: 127.0.0.1:3456
|
||||
`-> core runtime: 127.0.0.1:3457
|
||||
```
|
||||
|
||||
Publish only container port `8080`. The other listeners are implementation details and should remain private.
|
||||
|
||||
| Public route | Purpose |
|
||||
| --- | --- |
|
||||
| `/`, `/pages/home/index.html` | Management UI. The root redirects to a tokenized page URL. |
|
||||
| `/api/ccr/rpc` | Authenticated management RPC. |
|
||||
| `/health` | Model gateway health, not UI/container health. |
|
||||
| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Model and MCP gateway routes. |
|
||||
|
||||
## Start With Compose
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose logs -f ccr
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:3458>. Add a provider/model, create a CCR client key under **API Keys**, and start the gateway under **Server**. A fresh UI is available immediately, but `/health` can return `502` until the gateway has usable models.
|
||||
|
||||
Stop or remove the container without deleting its volume:
|
||||
|
||||
```sh
|
||||
docker compose stop
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Do not add `--volumes` unless all persisted CCR data should be deleted.
|
||||
|
||||
The repository mapping `3458:8080` binds every host interface. For local-only access, use:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "127.0.0.1:3458:8080"
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
CCR uses three distinct credential types:
|
||||
|
||||
| Credential | Purpose | Location |
|
||||
| --- | --- | --- |
|
||||
| `CCR_WEB_AUTH_TOKEN` | Management UI/RPC | Container environment |
|
||||
| CCR client API key | Model gateway requests | **API Keys** page |
|
||||
| Upstream credential | Requests from CCR to a provider | **Providers** page |
|
||||
|
||||
Without `CCR_WEB_AUTH_TOKEN`, the entrypoint generates a new random token for every container start. Opening `/` still works because Nginx redirects to a URL containing the current token. Use a fixed strong token for persistent or remote deployments.
|
||||
|
||||
Keep secrets out of shell history by using an ignored environment file:
|
||||
|
||||
```dotenv
|
||||
CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value
|
||||
CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458
|
||||
```
|
||||
|
||||
Pass it through `docker run --env-file` or the Compose service `environment`. Treat the complete management URL as a secret because `ccr_web_token` can be captured in browser history, proxy logs, screenshots, and tickets.
|
||||
|
||||
## Change The Public Address
|
||||
|
||||
Changing the host-facing port, hostname, or scheme also requires the exact client URL in `CCR_PUBLIC_BASE_URL`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "127.0.0.1:8088:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
|
||||
CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
`CCR_PUBLIC_BASE_URL` updates CCR's public router endpoint. It does not publish a Docker port.
|
||||
|
||||
For TLS at a reverse proxy or ingress, set the HTTPS URL and keep the host port private:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: https://ccr.example.com
|
||||
CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
Proxy every path, allow long-lived requests and adequate body sizes, and disable buffering for SSE/model streams. Add firewall, private-network, or equivalent access controls before exposing management to an untrusted network.
|
||||
|
||||
## Persistent Data
|
||||
|
||||
The entrypoint sets `HOME=/data`; CCR data is under `/data/.claude-code-router/`, including `config.sqlite`, `gateway.config.json`, `app-data/`, `profiles/`, and generated files under `bin/`.
|
||||
|
||||
Prefer a named volume. A bind mount must be writable by the container, and two running CCR containers must not share the same data directory.
|
||||
|
||||
On a completely empty volume, the entrypoint writes minimal bootstrap `config.json`. Once the UI saves configuration, SQLite is authoritative. Startup also synchronizes persisted listener/router endpoint fields to the Docker public address unless disabled.
|
||||
|
||||
## Backup, Restore, And Upgrade
|
||||
|
||||
Use **Settings → Export data** for an application-level backup. For a complete copy, stop writes first:
|
||||
|
||||
```sh
|
||||
docker compose stop ccr
|
||||
docker compose cp ccr:/data/. ./ccr-data-backup/
|
||||
docker compose start ccr
|
||||
```
|
||||
|
||||
The backup contains secrets and may contain request/response data. Restore into a new empty volume or empty `/data` while the container is stopped. Do not overlay an old copy onto populated live data because SQLite WAL/SHM and newer runtime files can be mixed.
|
||||
|
||||
Upgrade after backing up:
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose build --pull
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs --tail=200 ccr
|
||||
```
|
||||
|
||||
Rollback should pair the previous image/source revision with a pre-upgrade backup; an older build may not understand a newer database.
|
||||
|
||||
## Environment Reference
|
||||
|
||||
Most installations should change only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `CCR_WEB_AUTH_TOKEN` | Random per start | Management UI/RPC token. |
|
||||
| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public URL written to CCR configuration. |
|
||||
| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive the public URL when the full URL is unset. |
|
||||
| `CCR_PUBLIC_PORT` | `3458` | Used only to derive the public URL when the full URL is unset. |
|
||||
| `CCR_DATA_DIR` | `/data` | Data root and process `HOME`. |
|
||||
| `CCR_NGINX_PORT` | `8080` | Container-private Nginx port. |
|
||||
| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management host. |
|
||||
| `CCR_WEB_PORT` | `3459` | Container-private management port. |
|
||||
| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private model gateway host. |
|
||||
| `CCR_GATEWAY_PORT` | `3456` | Container-private model gateway port used by Nginx. |
|
||||
| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core runtime port. |
|
||||
| `CCR_NO_GATEWAY` | `0` | `1`, `true`, or `yes` starts management without the gateway at boot. |
|
||||
| `CCR_DOCKER_INIT_CONFIG` | `1` | `0` disables empty-volume bootstrap `config.json`. |
|
||||
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | `0` disables startup synchronization of persisted listener/public endpoint fields. |
|
||||
|
||||
Internal ports normally should not change. Publish only `CCR_NGINX_PORT`.
|
||||
|
||||
## Build And Smoke Test
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE=node:22-bookworm \
|
||||
--build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
|
||||
-t claude-code-router:local .
|
||||
|
||||
npm run test:docker
|
||||
```
|
||||
|
||||
The smoke test creates temporary resources and verifies the single Nginx entrypoint, UI/RPC auth, public endpoint migration, gateway startup, and `/health`. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an image or `CCR_DOCKER_TEST_IMAGE` to select another local tag.
|
||||
|
||||
## Operations And Troubleshooting
|
||||
|
||||
```sh
|
||||
docker compose ps
|
||||
docker compose logs -f ccr
|
||||
docker compose restart ccr
|
||||
docker compose config
|
||||
```
|
||||
|
||||
- **`/` returns `302`:** expected tokenized management-page redirect.
|
||||
- **`/health` returns `502`:** the model gateway is not yet configured/running; this is separate from container health.
|
||||
- **UI returns `401` after a token change:** reopen the bare root URL and close tabs/bookmarks containing the old token.
|
||||
- **Clients use an old host/port:** update `CCR_PUBLIC_BASE_URL` and recreate the container with endpoint synchronization enabled.
|
||||
- **Data disappears after recreation:** verify the same `/data` volume is mounted; `docker compose down --volumes` deletes it.
|
||||
- **Bind mount permission errors:** ensure the host directory exists and is writable, or use a named volume.
|
||||
- **Container is healthy but requests fail:** inspect Server status, provider connectivity, CCR client-key auth, routing, request logs, and `docker compose logs --tail=200 ccr`.
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Install And Start CCR](../install/)
|
||||
- [CLI Installation And Reference](../cli/)
|
||||
- [Server](../../configuration/server/)
|
||||
- [API Keys](../../configuration/api-keys/)
|
||||
|
||||
|
|
@ -2,17 +2,66 @@
|
|||
title: Install And Start CCR
|
||||
pageTitle: Install And Start CCR
|
||||
eyebrow: Quick Start
|
||||
lead: Download the desktop app, install it, and start the local CCR service.
|
||||
lead: Choose the desktop app, npm CLI, or Docker for the deployment, and distinguish the management address from the model gateway address.
|
||||
---
|
||||
|
||||
## Download And Install
|
||||
## Choose A Distribution
|
||||
|
||||
1. Open the [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) page.
|
||||
2. Download the package for your system: `.dmg` or `.zip` for macOS, `.exe` for Windows, and `.AppImage` for Linux.
|
||||
3. Install and open **Claude Code Router** like a normal desktop app.
|
||||
| Distribution | Best for | Entry | Default management address | Default gateway address |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Desktop app | Daily local use, tray, multi-instance Agent Apps, desktop integrations | App UI, `ccr-app` | In-app window | `http://127.0.0.1:3456` |
|
||||
| npm CLI | Terminal, SSH, no Electron, external process supervisors | `ccr` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
|
||||
| Docker | Persistent servers and container operations | Nginx | Shared public endpoint | `http://127.0.0.1:3458` with the default mapping |
|
||||
|
||||
## Start The Service
|
||||
In desktop/CLI deployments, management and the model gateway do not use the same port. Do not use CLI management port `3458` as the default model gateway. Docker intentionally combines both through one Nginx endpoint.
|
||||
|
||||
Open the **Server** page and click **Start**. After the page shows Running, CCR listens on the default local address `http://localhost:8080`.
|
||||
## Install The Desktop App
|
||||
|
||||
If you want the service to start when the app opens, enable **Auto start** on the Server page.
|
||||
1. Open [GitHub Releases](https://github.com/musistudio/claude-code-router/releases).
|
||||
2. Download `.dmg`/`.zip` for macOS, `.exe` for Windows, or `.AppImage` for Linux.
|
||||
3. Install and open **Claude Code Router**.
|
||||
4. Add a provider/model, create a client key under **API Keys**, then click **Start** under **Server**.
|
||||
|
||||
When Server shows Running, the model gateway defaults to `http://127.0.0.1:3456`. Enable automatic startup under Server if the gateway should start whenever the app opens.
|
||||
|
||||
## Install The npm CLI
|
||||
|
||||
Node.js 22 or newer is required:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr ui
|
||||
```
|
||||
|
||||
`ccr ui` starts a background service and opens the browser. Use `ccr ui --no-open` on a headless host or `ccr serve --no-open` under a process supervisor. See [CLI Installation And Reference](../cli/) for all commands and profile launches.
|
||||
|
||||
## Use Docker
|
||||
|
||||
From a source checkout:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:3458>. Docker publishes one Nginx endpoint shared by management and the gateway. Add a provider/model, create a CCR client key, and start the gateway under Server. See [Docker Deployment](../docker/) for ports, authentication, persistence, backups, and remote access.
|
||||
|
||||
## Verify The Installation
|
||||
|
||||
After configuring a provider, model, and CCR client key:
|
||||
|
||||
1. Confirm Server shows Running.
|
||||
2. Request `/health` on the deployment's gateway address and expect a `200` running response.
|
||||
3. Send one minimal model request to a compatible endpoint using the CCR client key.
|
||||
4. Confirm requested/resolved model, provider, status, and latency under Logs.
|
||||
|
||||
A reachable management UI does not prove that the model gateway is usable. Docker `/health` returning `502` is expected before a provider/model has been configured.
|
||||
|
||||
## Data Locations
|
||||
|
||||
| Distribution | Configuration location |
|
||||
| --- | --- |
|
||||
| Desktop / CLI on macOS or Linux | `~/.claude-code-router` |
|
||||
| Desktop / CLI on Windows | `%APPDATA%\claude-code-router` |
|
||||
| Docker | `/data/.claude-code-router`; persist `/data` |
|
||||
|
||||
Current configuration is stored in `config.sqlite`. Legacy `config.json` is only a migration source when SQLite does not exist, or an initial Docker bootstrap. Do not edit live SQLite files.
|
||||
|
|
|
|||
|
|
@ -5,19 +5,6 @@ eyebrow: Product Documentation
|
|||
lead: Learn what CCR is for, where its boundaries are, and how the docs are organized. Start with Quick Start when you want to configure it; use Detailed Configuration for fields, Bots, or Fusion.
|
||||
---
|
||||
|
||||
## What CCR Can Do
|
||||
|
||||
**Claude Code Router (CCR) is a local model gateway.** It sits between agents such as Claude Code, Codex, and ZCode and upstream model services, then centralizes model management, API keys, routing rules, logs, observability, and Bot relay.
|
||||
|
||||
CCR is useful when:
|
||||
|
||||
- you do not want to maintain the same models and keys separately in every agent.
|
||||
- you want different tasks to automatically use different models: fast models for lightweight background work, stronger models for complex tasks, and Fusion for image or web-search work.
|
||||
- you need request logs showing which provider and model handled a request, whether it succeeded, how long it took, and roughly how much it cost.
|
||||
- you want to forward long-running agent messages to Slack, Telegram, Feishu, WeCom, or other IM platforms.
|
||||
|
||||
CCR listens on the local default address `http://localhost:8080`. Once an agent points to this address, CCR can take over the request and forward it to upstream providers according to routing rules.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
The top navigation is split into four standalone pages:
|
||||
|
|
@ -25,8 +12,8 @@ The top navigation is split into four standalone pages:
|
|||
| Page | Contents |
|
||||
| --- | --- |
|
||||
| [Documentation](./) | Product positioning, architecture overview, and reading path |
|
||||
| [Quick Start](guides/) | From installation and provider setup to connecting an agent |
|
||||
| [Detailed Configuration](configuration/providers/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
|
||||
| [Quick Start](guides/) | Desktop, CLI, and Docker installation plus provider and Agent setup |
|
||||
| [Detailed Configuration](configuration/overview/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
|
||||
| [Q&A](troubleshooting/) | Request logs, observability panel, and common questions |
|
||||
|
||||
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.
|
||||
|
|
@ -35,7 +22,8 @@ Bot platform guides are child pages under Detailed Configuration. Each platform
|
|||
|
||||
If this is your first time using CCR:
|
||||
|
||||
1. Start with [Quick Start](guides/) to connect a provider and Agent Config.
|
||||
2. Use the app's request logs to confirm whether requests are passing through CCR.
|
||||
3. Open [Detailed Configuration](configuration/providers/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
|
||||
4. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions.
|
||||
1. Choose desktop, npm CLI, or Docker on the [installation page](guides/install/), then use the dedicated [CLI](guides/cli/) or [Docker](guides/docker/) guide.
|
||||
2. Continue through [Quick Start](guides/) to connect a provider and Agent Config.
|
||||
3. Use request logs to confirm whether requests are passing through CCR.
|
||||
4. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
|
||||
5. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ LINE 适合把 Agent 消息接入已有的 LINE 好友、群聊或 LINE Official
|
|||
|
||||
## 你会用到哪些字段
|
||||
|
||||
CCR 里 LINE 的认证方式叫 **Bot Token**,但填的不是 Telegram 那种 token,而是下面这两个 channel 字段:
|
||||
CCR 里 LINE 的认证方式叫 **Bot Token**,这里需要填写下面这两个 channel 字段:
|
||||
|
||||
| LINE 后台里的名字 | CCR 字段 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
|
|
|
|||
|
|
@ -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、配置数据库位置和托盘配置对应设置弹窗中的同名配置页。
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ eyebrow: 详细配置
|
|||
lead: 管理客户端访问 CCR 网关时使用的 API Key,并为每个 Key 设置过期时间和本地限额。
|
||||
---
|
||||
|
||||
## 基本概念
|
||||
|
||||
“API 密钥”页面管理的是客户端访问 CCR 时使用的访问 Key。它不同于供应商配置里的 `API 密钥` 或 `凭据池`:这里的 Key 用来控制谁能调用 CCR;供应商 Key 用来控制 CCR 调用上游供应商。
|
||||
|
||||
如果配置了 API Key,客户端请求需要带 `Authorization: Bearer <key>` 或 `x-api-key: <key>`。
|
||||
|
||||
## 列表字段
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
|
|
|
|||
|
|
@ -5,15 +5,61 @@ eyebrow: 详细配置
|
|||
lead: 通过 IM Bot 转发 Agent 消息,或在桌面空闲后把任务接力到手机。
|
||||
---
|
||||
|
||||
## 能做什么
|
||||
|
||||
Bot 能把 Agent 消息转发到 IM 平台,也可以在桌面空闲后把任务接力到手机上继续看。
|
||||
CCR App Relay 的在线周期与由 CCR 打开的 Agent App 保持一致。打开 Claude、Codex、ZCode 或 OpenCode App 时,CCR 启动对应的伴生 worker;App 退出时 worker 和 Bot 连接同步停止。
|
||||
|
||||
## 常见模式
|
||||
|
||||
- **转发 Agent 消息**:把消息同步到 IM。
|
||||
- **接力**:桌面空闲后,把交互接力到 IM。
|
||||
- **仅回复**:关闭“转发 Agent 消息”和接力后,只回复从 IM 主动发起的 turn。
|
||||
|
||||
同一个 IM conversation 中的普通消息按顺序执行。`/project`、`/session status` 和 `/session cancel` 等管理命令保持即时响应;排队、取消、超时和 worker 重启恢复都有明确状态。
|
||||
|
||||
## Project 与 Session
|
||||
|
||||
Project 对应 Agent 的项目或工作目录,Session 对应该 Project 下的 Agent 原生会话。
|
||||
|
||||
### Project 命令
|
||||
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `/project` | 查看 Project 命令帮助和 App 在线边界。 |
|
||||
| `/project list [page]` | 分页列出 Agent 已知项目。 |
|
||||
| `/project find <text>` | 搜索项目名称或路径。 |
|
||||
| `/project current` | 查看当前 Project。 |
|
||||
| `/project use <n>` | 切换 Project,并清除原 Session 选择。 |
|
||||
| `/project name <label>` | 设置当前 Project 的 Bot 显示名称。 |
|
||||
|
||||
### Session 命令
|
||||
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `/session` | 查看全部 Session 命令。 |
|
||||
| `/session list [page]`、`/session find <text>` | 只浏览当前 Project 中的 Sessions。 |
|
||||
| `/session current`、`new [title]`、`use <n>`、`reset` | 查看、新建、继续或清除 Session 选择。 |
|
||||
| `/session status`、`cancel` | 查看当前 turn/队列,或取消当前 turn 并清空队列。 |
|
||||
| `/session approve [session]`、`deny`、`answer <text>` | 响应 Agent 已经产生的权限或输入请求;所有平台提供文本命令,具备卡片能力的平台同时显示操作按钮。 |
|
||||
| `/session name <label>` | 重命名当前 Session。 |
|
||||
| `/session archive <n>`、`restore <n>`、`delete <n> confirm` | 归档、恢复或确认永久删除。 |
|
||||
| `/session history [count]`、`usage` | 查看最近历史和 Token/缓存/成本摘要。 |
|
||||
| `/session models`、`model`、`effort`、`mode` | 查看或调整当前 conversation 的 Session 运行设置。 |
|
||||
| `/session memory ...`、`skills`、`skill`、`shortcut ...` | 管理持久上下文、Agent Skills 和快捷指令。 |
|
||||
| `/session doctor`、`deliveries` | 查看连接、outbox、最近投递和脱敏错误。 |
|
||||
|
||||
Bot 的公开命令域为 `/project` 和 `/session`。其他 slash command 统一返回未知命令;普通自然语言(包括 `help`、`list`)作为 prompt 进入 Agent。
|
||||
|
||||
## Bot 设置
|
||||
|
||||
- **Bot 语言**:自动、English 或简体中文。
|
||||
- **最长 turn 时间**:超时后中断 Agent turn 并回报最终状态。
|
||||
- **Session 空闲重置**:超过指定时间后在当前 Project 中准备新 Session;设为 `0` 关闭。
|
||||
- **消息分片与附件上限**:适配平台消息长度并限制入站文件大小。
|
||||
- **流式回复与进度**:仅发送可见文本和工具阶段。
|
||||
- **收发附件**:允许入站图片/文件和当前工作区产物回传。
|
||||
- **允许 Agent 使用 Shell 工具**:控制 Agent 的 Shell 工具权限;Bot 命令域保持为 `/project` 和 `/session`。
|
||||
|
||||
状态文件持久保存有界的去重记录、待处理 turn、outbox 和最近投递结果。事件幂等保证每个 Agent turn 执行一次;再次打开 App 后,在 App 在线期间恢复待投递消息。
|
||||
|
||||
## 平台页面
|
||||
|
||||
Slack、Discord、Telegram、LINE、微信、企业微信、飞书和钉钉都有独立页面。
|
||||
Slack、Discord、Telegram、LINE、微信、企业微信、飞书和钉钉都有独立页面;iMessage 使用本机接入。SDK 按平台能力选择 Markdown、卡片、流式更新、文件消息或文本消息。
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ lead: 添加 Bot、绑定 Agent配置,并选择消息转发或接力模式。
|
|||
2. 选择平台并填写 Token、Secret、Signing Secret、Robot Code 或 OAuth 信息。
|
||||
3. 保存 Bot。
|
||||
4. 打开目标 Agent配置,开启 **Bot**。
|
||||
5. 选择 **转发 Agent 消息** 或 **接力**,并重新从 CCR 打开 Agent。
|
||||
5. 按需设置转发、接力、语言、超时、附件、流式回复和 **允许 Agent 使用 Shell 工具**。
|
||||
6. 从 CCR 重新打开 Claude、Codex、ZCode 或 OpenCode App。Bot 只在 App 存活期间在线。
|
||||
|
||||
## 验证方式
|
||||
|
||||
从 CCR 打开 Agent 后发一条测试消息,确认请求日志中有 Bot 相关记录,IM 端也能收到消息。
|
||||
从 CCR 打开 Agent App 后,在 IM 发送 `/project current`、`/session list` 和一条普通消息。Profile 卡片会显示 Bot 连接、最后事件、最后投递、待投递数量和脱敏错误;也可发送 `/session doctor` 查看诊断。关闭 App 后,Bot 状态应切换为离线。
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ lead: 找到 CCR 桌面 App 默认维护的 SQLite 配置数据库。
|
|||
## 默认位置
|
||||
|
||||
- macOS/Linux:`~/.claude-code-router/config.sqlite`
|
||||
- Windows:`%APPDATA%\Claude Code Router\config.sqlite`
|
||||
- Windows:`%APPDATA%\claude-code-router\config.sqlite`
|
||||
|
||||
Docker 设置 `HOME=/data`,因此配置数据库位于 `/data/.claude-code-router/config.sqlite`;需要持久化挂载整个 `/data`,而不是只挂载单个数据库文件。
|
||||
|
||||
## 生效方式
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ eyebrow: 详细配置
|
|||
lead: 了解 CCR 扩展如何加载、能注册哪些能力,并从零创建、安装和调试自己的扩展。
|
||||
---
|
||||
|
||||
## 扩展是什么
|
||||
## 扩展类型
|
||||
|
||||
CCR 的扩展分为两层:
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ module.exports = {
|
|||
4. 保存配置。
|
||||
5. 打开 **Server** 页面,重启网关。
|
||||
|
||||
CCR 的运行配置存储在 SQLite 中,因此推荐通过 UI 添加扩展,而不是编辑旧版 JSON 配置文件。扩展条目的配置结构如下:
|
||||
CCR 的运行配置存储在 SQLite 中。请通过 UI 添加扩展;旧版 JSON 配置文件仅用于参考。扩展条目的配置结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 等搜索服务。
|
||||
|
||||
## 排查要点
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ lead: 把基础模型和能力模型组合成新的可选模型,让你已经
|
|||
|
||||
Fusion 的价值在于保留基础模型的手感,同时补齐它缺少的能力。基础模型继续负责推理、写作和代码生成;CCR 在需要时调用图像、搜索或 MCP 工具,把结果整理进上下文,再交给基础模型完成回答。
|
||||
|
||||
保存后的 Fusion 模型会像普通模型一样出现在路由和配置中。它不是额外的一次手动流程,而是一个可复用的新模型:把强文本模型升级成视觉模型,把稳定代码模型升级成可联网模型,也可以把 Agent 模型接入团队内部工具。
|
||||
保存后的 Fusion 模型会像普通模型一样出现在路由和配置中。保存结果是一个可复用的新模型:把强文本模型升级成视觉模型,把稳定代码模型升级成可联网模型,也可以把 Agent 模型接入团队内部工具。
|
||||
|
||||
## 适用能力
|
||||
|
||||
|
|
|
|||
|
|
@ -2,58 +2,130 @@
|
|||
title: 概览仪表盘
|
||||
pageTitle: 概览仪表盘
|
||||
eyebrow: 详细配置
|
||||
lead: 自定义 CCR 首页组件,查看系统状态、账户余额、请求量、tokens、成本、模型分布和可分享卡片。
|
||||
lead: 自定义 CCR 首页仪表盘,查看系统状态、账户余额、请求量、令牌、成本和模型分布。
|
||||
---
|
||||
|
||||
## 基本概念
|
||||
## 适用场景
|
||||
|
||||
“概览”是 CCR 的仪表盘页面。它把网关运行状态、供应商账户、请求用量和分析结果做成可拖拽组件。你可以切换时间范围、编辑组件、调整大小、切换样式,也可以把部分数据生成分享卡片。
|
||||
|
||||
概览依赖请求日志、用量统计和供应商账户用量。某些组件需要先在“设置”里开启请求日志或 Agent 观测,账户余额组件需要在供应商配置中开启“获取用量”。
|
||||
|
||||
## 顶部控件
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| 场景 | 看什么 |
|
||||
| --- | --- |
|
||||
| 按时间查看用量 | 切换仪表盘使用的统计时间范围。 |
|
||||
| 今天 / 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 中重试,确保卡片可见且没有被缩到过小。 |
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
title: Agent配置
|
||||
pageTitle: Agent配置
|
||||
eyebrow: 详细配置
|
||||
lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。
|
||||
lead: 为 Claude Code、Codex、Grok CLI、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。
|
||||
---
|
||||
|
||||
## Agent配置是什么
|
||||
## 配置流程
|
||||
|
||||
Agent配置是桌面 App 中管理 Claude Code、Codex、ZCode 启动入口的能力。它不是供应商或路由规则,而是一次 Agent 启动所需的完整入口:Agent 类型、打开方式、模型、作用范围、配置文件位置,以及可选的 Bot 绑定。
|
||||
1. 先在 **供应商配置** 中添加至少一个可用供应商和模型,或先创建需要使用的 Fusion 模型。
|
||||
2. 打开 **Agent配置**,点击 **添加配置**。
|
||||
3. 选择 Agent 类型,填写配置名称,并选择作用范围和入口模式。
|
||||
4. 选择模型。模型值通常是 `供应商名称/模型名称`,也可以选择 Fusion 模型。
|
||||
5. 如果入口模式包含 App,可以绑定 Bot,并选择是否转发 Agent 消息或开启接力。
|
||||
6. 保存后,从 Agent配置卡片打开:终端图标会复制 CLI 命令,播放图标会启动 App 实例。
|
||||
|
||||
因此详细配置里会有这个页面。它用于解释“用哪个配置打开哪个 Agent 实例”,而不是继续拆分供应商、路由或 Fusion 的字段。
|
||||
试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex、Grok CLI 或 ZCode。
|
||||
|
||||
## 多开机制
|
||||
|
||||
|
|
@ -18,41 +23,140 @@ Agent配置是桌面 App 中管理 Claude Code、Codex、ZCode 启动入口的
|
|||
| 机制 | 实际行为 |
|
||||
| --- | --- |
|
||||
| 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 |
|
||||
| 独立启动器 | Claude Code 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 |
|
||||
| 独立 App 数据目录 | 从 App 打开时,Claude App、Codex App、ZCode App 都会使用按配置 `id` 区分的用户数据目录 |
|
||||
| 独立启动器 | Claude Code 和 Grok CLI 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 |
|
||||
| 独立 App 数据目录 | 从 App 打开时,Claude App、ChatGPT(Codex 桌面端的新名称)、ZCode App 都会使用按配置 `id` 区分的用户数据目录 |
|
||||
| 运行状态 | CCR 按打开入口和配置 `id` 记录运行中的 App 实例;同一个配置再次打开会激活已有窗口,不同配置可以打开不同实例 |
|
||||
|
||||
这意味着你可以为同一个 Agent 建多个配置,例如“Claude Code - 工作项目”“Claude Code - 测试模型”“Codex - Fusion 图像能力”。它们可以选择不同模型、不同作用范围和不同 Bot,打开后就是不同的 Agent 实例。
|
||||
|
||||
## 常用选项
|
||||
|
||||
| 选项 | 说明 |
|
||||
| 选项 | 适用范围 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 全部 | 选择 Claude Code、Codex、OpenCode、Grok CLI 或 ZCode。Grok CLI 只支持 CLI,ZCode 只支持 App。 |
|
||||
| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr-app <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 |
|
||||
| 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 |
|
||||
| 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 |
|
||||
| 入口模式 | Claude Code、Codex、OpenCode、Grok CLI | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI 固定为 `CLI only`。 |
|
||||
| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 留空表示保留 Claude Code 默认模型。 |
|
||||
| Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 |
|
||||
| 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 |
|
||||
|
||||
## 各 Agent 的配置项
|
||||
|
||||
### Claude Code
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Agent | 选择 Claude Code、Codex 或 ZCode |
|
||||
| 配置名称 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标 |
|
||||
| 作用范围 | “仅从 CCR 打开时生效”会使用 CCR 管理的独立配置;“系统默认”会写入对应 Agent 的默认配置 |
|
||||
| 入口模式 | `CLI & APP`、`CLI only`、`App only`;ZCode 只支持 App |
|
||||
| 模型 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型 |
|
||||
| Bot | App 入口可以绑定 Bot,用于 IM 消息转发或接力 |
|
||||
| 模型覆盖 | 写入 Claude Code 使用的 `ANTHROPIC_MODEL`。留空时不覆盖 Claude Code 自己的默认模型。 |
|
||||
| 小模型 | 写入 `ANTHROPIC_SMALL_FAST_MODEL`,供 Claude Code 的轻量任务使用。留空时保留 Claude Code 默认值。 |
|
||||
| 设置文件 | 系统默认模式使用 Claude Code 默认设置文件;仅从 CCR 打开时生效会在 CCR 配置目录下按 Agent配置 `id` 生成独立设置文件。 |
|
||||
| 环境变量 | 会合并到 Claude Code 设置文件的 `env` 中。CCR 同时写入网关地址、API Key helper 和启动包装器。 |
|
||||
| Bot | 只在 Claude App 入口生效,可选择已保存 Bot,并配置转发 Agent 消息或接力。 |
|
||||
|
||||
Claude Code CLI 从 CCR 打开后,会通过 CCR 网关获取模型发现信息。进入 Claude Code CLI 后可以输入 `/model` 查看并切换 CCR 暴露的模型列表,包括普通供应商模型和可见的 Fusion 模型。
|
||||
|
||||
Claude App 是 **零配置(zero-config)**:从 CCR 桌面 App 打开 Claude App 时,CCR 会自动写入 Claude App 网关配置、API Key、模型发现列表和独立用户数据目录。用户不需要增加额外操作,直接使用 CCR 打开就会自动完成所有必要配置;如果 Claude App 已经打开,按提示重启或从 CCR 重新打开即可。
|
||||
|
||||
Claude App 和 Claude Code CLI 的模型列表适配方式不同:
|
||||
|
||||
| 入口 | 模型列表来源 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Claude Code CLI | CCR 网关模型发现 | CLI 内使用 `/model` 查看列表;选择后请求仍走 CCR 的供应商、路由和 Fusion。 |
|
||||
| Claude App | CCR 生成的 Claude App inference models | Claude App 需要 Claude 兼容的模型名。CCR 会把 `供应商/模型` 和 Fusion 模型映射成 Claude App 可识别的模型项,并用显示名称保留真实模型含义。 |
|
||||
|
||||
### OpenCode
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Provider ID | 写入 OpenCode 的供应商引用,默认是 `claude-code-router`。 |
|
||||
| Provider name | OpenCode 中展示的供应商名称,默认是 `Claude Code Router`。 |
|
||||
| OpenCode model | OpenCode CLI 和 App 的默认模型,可以选择普通供应商模型或 Fusion 模型。 |
|
||||
| 配置文件 | 系统默认模式使用 OpenCode 默认配置;仅从 CCR 打开时生效会在 CCR 配置目录下写入配置专属文件。 |
|
||||
| 环境变量 | 注入 OpenCode CLI、OpenCode App 以及对应的 Bot worker。 |
|
||||
| Bot | 在从 CCR 打开的 OpenCode App 入口生效。收到 Bot 消息后会通过 OpenCode CLI 执行,并把回复发回同一个 Bot 会话。 |
|
||||
|
||||
CCR 会为 OpenCode App 启动一个配套 Bot worker,并为每个 Bot conversation 分别保存 Project 与可选 Session。先用 `/project list|current|use` 选择 Agent Project,再用 `/session list|current|new|use|reset` 管理该 Project 下的 Agent Session。切换 Project 会清除原 Session,其他 Project 的 Session 无法被选中。Bot 只拦截这两个 slash 命令域;已移除的 `/task` 和旧平铺命令不再兼容。
|
||||
|
||||
CCR Desktop 进程环境中必须能够执行 `opencode`。如果 CLI 安装在其他位置,可以在 Agent配置环境变量中设置 `CCR_OPENCODE_BIN`。Bot session 默认使用全新 OpenCode Desktop 工作区对应的文件系统根目录;如果 App 当前打开了其他项目,应通过 `CCR_OPENCODE_BOT_CWD` 设置同一个项目目录。CCR 会把该目录显式传给 `opencode run --dir`,使新 session 出现在 App 对应的项目下。默认不会自动批准权限;只有在可信环境中才应设置 `CCR_OPENCODE_BOT_AUTO_APPROVE=true`,因为它会启用 OpenCode 的高风险 `--auto` 模式。
|
||||
|
||||
### Codex
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Provider ID | 写入 Codex 的 `model_provider`,默认是 `claude-code-router`。建议保持稳定,只使用字母、数字、点、下划线或短横线。 |
|
||||
| Provider name | Codex 中展示的供应商名称,默认是 `Claude Code Router`。 |
|
||||
| Codex model | 写入 Codex 默认模型。可以选择普通供应商模型或 Fusion 模型;留空时 CCR 使用可用模型中的默认值。 |
|
||||
| Show all sessions | 让 Codex 显示所有会话。ZCode 不提供该项。 |
|
||||
| 配置文件 | 默认是 `~/.codex/config.toml`。仅从 CCR 打开时生效会写入 CCR 管理的独立配置目录。 |
|
||||
| 环境变量 | 注入 Codex CLI 或 ChatGPT。Claude Code 专用的模型发现变量不会传给 Codex。 |
|
||||
| Bot | 只在 ChatGPT App 入口生效。 |
|
||||
|
||||
保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr-app "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。
|
||||
|
||||
模型和公共插件列表不再由中间层合成。原生 Codex app-server 读取生成的 `model_catalog_json`,并原样处理 `model/list` 与公共 `plugin/list` 请求,因此 Codex 可以自行联网刷新官方公开 [`openai/plugins`](https://github.com/openai/plugins) Git marketplace。虚拟 workspace 中,只有必须使用真实 ChatGPT 鉴权的账号私有 marketplace 请求会得到明确空结果,绝不会用本地插件替代。下载后的 Git checkout 只作为 Codex 自己的常规 last-known-good 数据,CCR 不会拿它替代远端目录。
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。保存后复制并运行配置卡片上的命令,例如 `ccr-app "Grok - Work"`。
|
||||
|
||||
生成的包装器会把 Grok 的模型网关和模型列表地址指向 CCR 的 `/v1`,注入该配置专属的 CCR API Key,并把选中的 CCR 模型设为默认模型。如果 CCR Desktop 网关尚未运行,`ccr-app` 会为 Grok 会话启动一个可共享的临时服务,并在最后一个会话退出后清理。Grok CLI 没有单独指定用户配置文件的选项,因此 CCR 会把 `GROK_HOME` 指向配置专属目录;其中的 `config.toml` 初始复制自用户配置,之后可以独立修改,不会回写原文件,同时隔离 `auth.json`,避免本机 xAI OAuth token 覆盖 CCR Key。插件、技能和会话目录仍与原 Grok home 共享。进入 Grok CLI 后可以使用 `/model` 切换 CCR 返回的普通供应商模型或 Fusion 模型,切换后的请求仍然经过 CCR。
|
||||
|
||||
### ZCode
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Provider ID | 写入 ZCode 供应商引用,默认是 `claude-code-router`。 |
|
||||
| Provider name | ZCode 中展示的供应商名称,默认是 `Claude Code Router`。 |
|
||||
| ZCode model | ZCode App 打开后的默认模型。可以选择普通供应商模型或 Fusion 模型。 |
|
||||
| 配置文件 | 默认是 `~/.zcode/cli/config.json`,CCR 还会写入 ZCode v2 配置和模型缓存。 |
|
||||
| 环境变量 | 注入 ZCode App 和中间层启动器。 |
|
||||
| Bot | 只在 ZCode App 入口生效。 |
|
||||
|
||||
ZCode 只支持 App 打开,因此入口模式固定为 `App only`,也不会显示 `Show all sessions`。
|
||||
|
||||
## CLI 与 App 模式区别
|
||||
|
||||
| 模式 | 如何打开 | 适合场景 | 主要差异 |
|
||||
| --- | --- | --- | --- |
|
||||
| CLI | 点击终端图标复制命令,然后在终端运行 `ccr-app <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 |
|
||||
| App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、Bot 消息转发或接力 | 同一配置重复打开会激活已有窗口。是否支持多开取决于 Agent;OpenCode Desktop 是单实例应用,切换 OpenCode 配置时 CCR 会先停止其管理的旧实例。 |
|
||||
| CLI & APP | 同一个配置同时提供 CLI 和 App 入口 | 同一套模型配置既用于终端,也用于桌面 App | 两个入口共用配置名称、模型、作用范围和环境变量,但启动方式不同。 |
|
||||
|
||||
## 各 Agent 的差异
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code 配置会写入设置文件。选择“仅从 CCR 打开时生效”时,CCR 会在自己的配置目录下为这个 Agent配置生成独立设置文件,并通过独立启动包装器打开 Claude Code。
|
||||
Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生效”时,CCR 会在自己的配置目录下为这个 Agent配置生成独立设置文件,并通过独立启动包装器打开 Claude Code。
|
||||
|
||||
从桌面 App 打开 Claude App 时,CCR 还会为该配置准备独立用户数据目录。不同 Agent配置使用不同目录,因此可以同时打开多个 Claude App 实例。
|
||||
|
||||
绑定 Bot 后,Claude App 的伴生 worker 会把 Project/Session、流式回复、附件、会话用量和原生权限/Ask User 请求接入 IM;App 退出时 worker 同步停止。
|
||||
|
||||
### Codex
|
||||
|
||||
Codex 配置会写入 `config.toml`,并生成模型目录文件。选择“仅从 CCR 打开时生效”时,CCR 会把这些文件放在按配置 `id` 区分的目录中。
|
||||
|
||||
Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 Codex App。
|
||||
Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会启动 ChatGPT、使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 App。
|
||||
|
||||
绑定 Bot 后,Codex App 的伴生 worker 使用 Codex 原生 rollout Session,实现 Project/Session 浏览、续接、队列、取消、模型设置、用量、附件和诊断。该 worker 只随受管 App 存活。
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode 配置会写入 JSON/JSONC 文件,把当前选择的供应商和模型路由到 CCR。CLI 通过配置专属包装器启动;App 使用相同的有效配置启动已安装的 OpenCode Desktop。
|
||||
|
||||
选择 Bot 并从 CCR 打开 App 后,CCR 会启动配套 worker,通过 OpenCode 原生 Session 处理收到的 Bot 消息,并提供与其他 App 一致的 Project/Session、队列、媒体、设置和诊断合同。受管 OpenCode App 退出或切换配置时,该 worker 也会同步停止。
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI 只支持 CLI。CCR 通过配置专属包装器启动它,注入 CCR 模型网关、模型发现地址、API Key 和默认模型,并通过不含 xAI OAuth 凭据的配置专属 Grok home 保证推理使用 CCR Key;用户原有的 Grok home 不会被改写。
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode 只支持 App 打开。CCR 会根据 ZCode home 或自定义配置文件写入 ZCode 的 CLI 配置、v2 配置和模型缓存,并在 App 启动时使用当前 Agent配置的模型、供应商和独立用户数据目录。
|
||||
|
||||
绑定 Bot 后,ZCode 使用与 Codex 同类的 App 伴生 worker 和原生 Session 扫描;ZCode App 关闭时接力立即离线。
|
||||
|
||||
## 多开建议
|
||||
|
||||
1. 为每个需要独立运行的 Agent 实例创建一个 Agent配置。
|
||||
|
|
|
|||
|
|
@ -70,6 +70,34 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少
|
|||
<span class="provider-import-icon-shell"><img src="../../provider-icons/siliconflow.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">硅基流动</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-runapi" href="ccr://provider?name=RunAPI&base_url=https%3A%2F%2Frunapi.co%2Fv1&protocol=openai_responses" aria-label="导入 RunAPI 供应商">
|
||||
<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-unity2" href="ccr://provider?name=Unity2.Ai&base_url=https%3A%2F%2Funity2.ai%2Fv1&protocol=openai_chat_completions&source=https%3A%2F%2Funity2.ai%2Fregister%3Fsource%3Dclaudecoderouter" aria-label="导入 Unity2.Ai 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/unity2.jpg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Unity2.Ai</span><span class="provider-import-meta">OpenAI 兼容网关</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%2Fagent%2Fregister%2F9n9jOsSnYQoemIVL%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" 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%2Fconsole.claudeapi.com%2Fagent%2Fregister%2FLbmB7Y9kPloyzhwF%3Futm_source%3Dclaudecoderouter%26utm_medium%3Dpartner%26utm_campaign%3Dclaudecoderouter_2026%26utm_content%3Ddefault" 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>
|
||||
<a class="provider-import-button provider-qiniu-ai" href="ccr://provider?name=%E4%B8%83%E7%89%9B%E4%BA%91+AI&base_url=https%3A%2F%2Fapi.qnaigc.com&protocol=openai_chat_completions&source=https%3A%2F%2Fs.qiniu.com%2FAVjMVf" aria-label="导入七牛云 AI 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/qiniu-ai.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">七牛云 AI</span><span class="provider-import-meta">Chat / Responses / Anthropic / Gemini Generate</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-fenno" href="ccr://provider?name=Fenno.ai&base_url=https%3A%2F%2Fapi.fenno.ai&protocol=openai_chat_completions&source=https%3A%2F%2Fapi.fenno.ai%2Fregister%3Fredirect%3D%2Fpurchase%3Ftab%3Dsubscription%2526group%3D16%26aff%3D9HHHAB5QLAES" aria-label="导入 Fenno.ai 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/fenno.jpg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Fenno.ai</span><span class="provider-import-meta">Chat / Responses / Anthropic</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 嵌入式按钮组件
|
||||
|
|
@ -129,7 +157,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 官网或配置来源 |
|
||||
|
|
@ -257,7 +285,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 官网或配置来源 |
|
||||
|
|
|
|||
|
|
@ -5,20 +5,54 @@ eyebrow: 详细配置
|
|||
lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列表和凭据。
|
||||
---
|
||||
|
||||
## 基本概念
|
||||
## 导入本机 Agent 登录态
|
||||
|
||||
供应商是 CCR 转发请求的上游模型服务。一个供应商配置同时描述了上游地址、协议能力、模型列表、认证方式、可选的多 Key 轮换,以及可选的账号用量读取能力。
|
||||
添加供应商时,CCR 会扫描本机已有的 Agent 登录状态。检测到可复用凭据后,添加弹窗会显示对应导入入口。导入会创建一个普通供应商和配套 provider plugin,让 CCR 复用本机 Agent 授权访问上游服务,不需要再手动粘贴常规 API Key。
|
||||
|
||||
每个供应商至少需要名称、API 地址、可用协议、模型列表和一条可用凭据。添加或编辑供应商时,弹窗中的字段会根据“预设供应商 / 自定义 API 地址 / 本机 Agent 登录态导入 / 高级设置”动态显示。
|
||||
### Claude Code
|
||||
|
||||
预设供应商会自动填入常见 API 地址、协议、模型和用量读取配置,适合优先使用。自定义供应商需要手动填写 API 地址,CCR 会根据 API 地址和 API 密钥做协议探测,帮助判断当前上游是否兼容 OpenAI Chat、OpenAI Responses、Anthropic Messages 或 Gemini Generate。
|
||||
Claude Code 导入会读取本机 Claude Code OAuth 凭据。检测到可用 access token 时,可以导入为 `Claude Code API` 供应商。
|
||||
|
||||
导入后:
|
||||
|
||||
1. 协议使用 `anthropic_messages`。
|
||||
2. 默认模型包含 `claude-sonnet-4-20250514`,后续可以在供应商模型列表中增减模型。
|
||||
3. CCR 会创建 OAuth provider plugin,把请求认证转换为 Claude Code 登录态。
|
||||
4. 账号用量会使用 Anthropic OAuth 用量接口,适合在供应商列表、托盘或账号面板里查看额度状态。
|
||||
|
||||
如果只检测到登录痕迹但没有可用 access token,导入入口会显示不可导入原因。此时先在 Claude Code 中重新登录,再回到 CCR 添加供应商。
|
||||
|
||||
### Codex
|
||||
|
||||
Codex 导入会读取本机 Codex 登录文件和模型缓存。检测到 Codex access token 或 refresh token 时,可以导入为 `Codex API` 供应商。
|
||||
|
||||
导入后:
|
||||
|
||||
1. 协议使用 `openai_responses`。
|
||||
2. API 地址指向 Codex 后端,默认模型至少包含 `gpt-5-codex`,也会合并本机模型缓存中的模型和显示名。
|
||||
3. CCR 会创建 Codex OAuth provider plugin,并在需要时刷新访问凭据。
|
||||
4. 账号用量会读取 Codex 额度、余额和 token 统计接口。
|
||||
|
||||
导入后可以直接在路由或 Agent配置中选择 `Codex API/模型名`。如果模型缓存较旧,可以先打开 Codex 让它刷新模型列表,再回到 CCR 重新导入或编辑模型。
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode 导入会读取本机 ZCode 配置中的供应商 API Key、API 地址和模型列表。只有检测到可用供应商 Key 和 Base URL 时,才能导入为 `ZCode API` 供应商。
|
||||
|
||||
导入后:
|
||||
|
||||
1. 协议使用 `anthropic_messages`。
|
||||
2. 模型优先来自 ZCode 本机配置;没有配置时会使用 ZCode 运行缓存或默认模型。
|
||||
3. CCR 会创建 API Key provider plugin,把 ZCode 本机配置中的 Key 用于请求认证。
|
||||
4. 如果 API 地址命中 CCR 内置预设,账号用量配置会复用对应预设。
|
||||
|
||||
如果只检测到 ZCode 登录态,但没有检测到可用供应商 API Key,导入入口会显示不可导入。此时需要先在 ZCode 中配置可用模型供应商,再回到 CCR 添加供应商。
|
||||
|
||||
## 主字段
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 选择 预设供应商 | 套用 CCR 内置供应商模板,包括默认 API 地址、可用协议、默认模型、图标、官网链接和部分供应商的用量读取配置。选择 `其他 / 自定义 API 地址` 时,可以接入任意兼容 OpenAI、Anthropic 或 Gemini 协议的上游服务。 |
|
||||
| 导入本机 Agent 登录态 | 添加供应商时,如果 CCR 在本机发现 Claude Code、Codex 或 ZCode 的可用登录状态,会显示导入入口。导入后 CCR 会生成供应商和对应 provider plugin,用本机已有登录凭据访问上游,不需要手动粘贴常规 API Key。 |
|
||||
| 名称 | CCR 内部显示名,也是路由、模型选择、日志和配置中识别供应商的名字。名称必须唯一,建议短且稳定。 |
|
||||
| API 地址 | 上游 API Base URL。它决定请求实际发往哪里,也用于协议探测、模型列表探测、图标探测和安全校验。预设供应商添加时默认隐藏该字段,可在高级设置里覆盖。自定义供应商必须填写。 |
|
||||
| API 密钥 | 默认供应商凭据。没有配置凭据池时,模型请求会使用这条 Key;协议探测、模型探测、连通性检测和默认用量读取也会使用它。只填写由当前 API 地址对应供应商签发的 Key。 |
|
||||
|
|
@ -46,7 +80,7 @@ lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列
|
|||
| 添加 Key | 新增一条上游 API Key。 |
|
||||
| 启用 | 控制单条凭据是否参与请求转发和用量读取。关闭后保留配置,但不会被选中。 |
|
||||
| 名称 | 单条凭据的显示名。会出现在账号用量、日志和内部诊断里,建议写成可识别的用途或额度来源。 |
|
||||
| API 密钥 | 该凭据实际发送给上游的 Key。配置了凭据池后,CCR 会把启用的凭据展开成多个内部上游目标,并优先使用凭据池,而不是主表单的默认 `API 密钥`。 |
|
||||
| API 密钥 | 该凭据实际发送给上游的 Key。配置了凭据池后,CCR 会把启用的凭据展开成多个内部上游目标,并优先使用凭据池中的 Key。主表单的默认 `API 密钥` 只作为单 Key 配置使用。 |
|
||||
| 移除 | 删除当前凭据行。 |
|
||||
| Key 高级选项 | 展开单条凭据的调度和限额字段。 |
|
||||
| 优先级 | 凭据优先级,数字越小越优先。未填写时按凭据行顺序作为优先级。 |
|
||||
|
|
@ -90,7 +124,7 @@ lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列
|
|||
|
||||
### HTTP JSON 请求
|
||||
|
||||
该模式适合供应商已有自己的余额或额度接口,但返回格式不是 CCR 标准格式的情况。
|
||||
该模式适合供应商已有自己的余额或额度接口,且返回自定义 JSON 格式的情况。
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
|
|
|
|||
|
|
@ -5,20 +5,141 @@ eyebrow: 详细配置
|
|||
lead: 设置请求如何选择模型,并在失败时通过 Fallback 自动重试或切换到备用模型。
|
||||
---
|
||||
|
||||
## 路由如何生效
|
||||
## 内置路由
|
||||
|
||||
CCR 的路由会先决定本次请求使用哪个模型,然后再把请求交给上游。当前代码中的主要顺序是:
|
||||
### Claude Code
|
||||
|
||||
1. 如果请求里的 `model` 已经是已知的 `供应商/模型` 形式,CCR 会直接使用这个模型。
|
||||
2. 如果配置了自定义路由脚本,脚本返回的模型优先级高于界面里的路由规则。
|
||||
3. 路由规则按列表顺序匹配,第一条命中的规则会执行对应的请求改写。
|
||||
4. 没有规则命中时,使用默认路由;如果没有默认路由,就保留请求原本的模型。
|
||||
Claude Code 内置路由的作用是识别 Claude Code 发来的请求,并把主请求路由到 Claude Code Agent 配置中的模型。
|
||||
|
||||
路由规则的核心是 **条件 + 请求动作**。条件判断请求是否命中,请求动作修改请求字段。最常用的动作是把 `request.body.model` 设置为目标模型或 Fusion 模型。
|
||||
Claude Code **主请求** 使用 Claude Code Agent 配置中的模型;如果未设置,该内置路由不会生效。CCR 也会自动删除 Claude Code 注入的第一条 `x-anthropic-billing-header` system 消息,避免这类计费辅助消息影响后续路由判断。Claude Code 创建的 Subagent、Task 或 Workflow 内部 Agent 可以继续用下面的标签机制自动选择不同模型。
|
||||
|
||||
## Fallback 是什么
|
||||
#### Subagent / Workflow 自动路由
|
||||
|
||||
Fallback 是请求失败后的降级策略。它不负责第一次选模型,而是在当前模型或上游失败时决定是否继续尝试。
|
||||
Claude Code 的 Agent / Task / Workflow 可以派生新的模型请求。CCR 使用标签注入来让这些派生请求选择更合适的 CCR 模型:
|
||||
|
||||
```text
|
||||
<CCR-SUBAGENT-MODEL>供应商/模型</CCR-SUBAGENT-MODEL>
|
||||
```
|
||||
|
||||
完整流程如下:
|
||||
|
||||
1. Claude Code 主请求命中内置路由后,CCR 会检查当前工具列表。
|
||||
2. 如果至少有一个模型配置了 **Description**,CCR 会把可用模型及其说明注入到 `Agent` / `Task` 工具说明和 `prompt` 字段说明里。
|
||||
3. 如果工具列表里有 `Workflow`,CCR 会给 Workflow 工具说明追加要求:workflow 内部创建 `Agent` / `Task` 时,每个派生 Agent 的 prompt 第一行都要带同样的模型标签。
|
||||
4. Claude Code 调用 `Agent` / `Task`,或 Workflow 内部创建 Agent 时,prompt 第一行会携带 `<CCR-SUBAGENT-MODEL>供应商/模型</CCR-SUBAGENT-MODEL>`。
|
||||
5. 派生请求进入 CCR 后,CCR 从 system 或前两条 user message 中提取并删除这个标签,然后把该请求路由到标签里的模型。
|
||||
|
||||
因此,Subagent / Workflow 的自动路由由 prompt 标签决定模型。`x-claude-code-agent-id` 等 Header 用于观测,模型选择以标签为准。
|
||||
|
||||
##### 与模型页配合
|
||||
|
||||
模型页里的 **Description** 是这套机制的开关和选择依据。没有任何模型 Description 时,CCR 不会注入 Agent / Task / Workflow 路由提示词,避免把空模型列表写进工具说明。
|
||||
|
||||
推荐配置步骤:
|
||||
|
||||
1. 在 **供应商** 中添加可用模型,确认模型 ID 可以真实请求。
|
||||
2. 打开 **模型** 页面,为希望 Subagent 自动选择的模型填写 Description。说明要写清模型适合的任务、速度、成本和限制。
|
||||
3. 在 **Agent配置** 中启用 Claude Code 配置,并设置主模型。这个模型负责 Claude Code 主会话。
|
||||
4. 在 **路由** 页面确认 **Claude Code** 内置路由已启用。
|
||||
5. 在 Claude Code 中使用 Agent、Task 或 Workflow。需要派生 Agent 时,Claude Code 会根据模型 Description 选择一个 CCR 模型并写入标签。
|
||||
|
||||
Description 建议写成任务导向,而不是只写模型厂商名。例如:
|
||||
|
||||
| 模型用途 | Description 示例 |
|
||||
| --- | --- |
|
||||
| 快速便宜模型 | 适合代码搜索、文件梳理、摘要、简单修改和低成本并行 Subagent。 |
|
||||
| 强推理模型 | 适合复杂架构分析、大规模重构计划、跨文件推理和高风险代码审查。 |
|
||||
| 长上下文模型 | 适合读取大量日志、长文档、仓库级上下文整理和 Workflow 汇总。 |
|
||||
|
||||
保存后,CCR 会把这些 Description 组织成 “Configured CCR gateway models” 注入给 Claude Code。Claude Code 选择模型后,CCR 会在派生请求上看到 `builtin:claude-code-subagent`,并把标签里的模型作为最终 `resolved model`。
|
||||
|
||||
### Codex
|
||||
|
||||
Codex 内置路由会为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件编辑工具。目标是让这些模型通过 patch 工具完成文件修改,而不是生成 `cat >`、`sed -i`、`python`、`node` 等命令或脚本来编辑文件。
|
||||
|
||||
技术原理是做一次工具协议桥接:Codex 原生的 `apply_patch` 是 custom/freeform 工具,入参是原始 patch 文本;很多 OpenAI-compatible 三方模型更擅长普通 function tool。CCR 会在上游请求中把 `apply_patch` 转成 `virtual_apply_patch` function tool,并在工具说明里注入完整的 `apply_patch.lark` 语法,要求模型把 patch 写入 `patch` 字段。
|
||||
|
||||
模型返回 `virtual_apply_patch` 后,CCR 会把它转换回 Codex 期望的 `custom_tool_call`:`name = apply_patch`,`input = 原始 patch 文本`。CCR 不直接修改文件,真正执行 patch 的仍然是 Codex 客户端。这个适配跟随 **Codex** 内置路由启用或关闭,没有单独开关;GPT 命名模型继续使用 Codex 原生 freeform `apply_patch` 路径。
|
||||
|
||||
## 自定义路由
|
||||
|
||||
自定义路由在路由页的规则列表中配置。页面顶部的 **搜索路由规则** 可以按名称、条件、请求动作等文本过滤列表;右上角 **添加** 按钮打开 **添加路由规则** 弹窗。规则表格按 **名称**、**条件**、**请求动作**、**状态**、**操作** 展示每条规则。
|
||||
|
||||
自定义规则按列表顺序匹配,第一条命中的启用规则会改写请求。表格右侧的上移、下移按钮用来调整优先级,编辑按钮打开 **编辑路由规则**,删除按钮会先弹出确认框。**状态** 列的开关关闭后,规则保留在列表里,但不会参与匹配。
|
||||
|
||||
### 添加或编辑规则
|
||||
|
||||
弹窗里的字段和保存后的配置一一对应:
|
||||
|
||||
| UI 字段 | 填写方式 | 保存后的含义 |
|
||||
| --- | --- | --- |
|
||||
| **名称** | 填一个便于识别的规则名。该字段不能为空。 | 显示在列表 **名称** 列,也参与搜索。 |
|
||||
| **条件** | 选择 `request.header` 或 `request.body`,填写字段名、操作符和值。 | 生成 `condition.left`、`condition.operator` 和 `condition.right`。 |
|
||||
| **改写请求参数** | 至少保留一行 rewrite。每行选择操作、目标 key 和需要的值。 | 生成 `rewrites`,规则命中后按行改写请求。 |
|
||||
| **启用** | 打开或关闭规则。 | 控制 `enabled`,关闭时不会匹配。 |
|
||||
| **失败时** | 配置这条规则自己的 Fallback。 | 规则命中后覆盖页面顶部的 **默认失败处理**。 |
|
||||
|
||||
**添加** 或 **保存** 按钮只有在表单有效时才可点击:名称、条件字段、条件值都必须填写;每条 rewrite 都必须有 key。`删除` 操作只需要 key;`替换数组元素` 需要同时填写 **匹配值** 和 **值**;其他操作需要填写 **值**。
|
||||
|
||||
### 条件
|
||||
|
||||
**条件** 区域有四个输入:来源、字段、操作符和值。
|
||||
|
||||
| 来源 | 字段示例 | 实际匹配路径 |
|
||||
| --- | --- | --- |
|
||||
| `request.header` | `user-agent`、`x-api-key`、`x-client-name` | `request.header.user-agent` |
|
||||
| `request.body` | `model`、`messages`、`messages.0.role`、`tools` | `request.body.model` |
|
||||
|
||||
Header 名不区分大小写。Body 字段按点号路径读取,数字片段表示数组下标;例如 `messages.0.role` 读取第一条 message 的 role。对于 messages、tools 这类嵌套数组,通常用 `contains deep` 比固定下标更稳。
|
||||
|
||||
值输入框会按常见字面量解析:`true`、`false`、`null`、数字、JSON 对象或数组会按对应类型比较;其他内容按字符串处理。需要强制作为字符串时,可以写成 `"123"` 或 `'123'`。
|
||||
|
||||
| 操作符 | 用法 |
|
||||
| --- | --- |
|
||||
| `==` / `!=` | 比较实际值和输入值。数字会按数字比较,其他值按可比较文本比较。 |
|
||||
| `>` / `>=` / `<` / `<=` | 两边都是数字时按数字比较,否则按文本顺序比较。 |
|
||||
| `starts with` | 判断实际值是否以输入值开头,适合模型前缀分流。 |
|
||||
| `contains` | 对字符串做包含判断;对数组只检查数组元素。 |
|
||||
| `contains deep` | 递归检查对象和数组,适合在 `messages`、`tools` 中查找内容。 |
|
||||
| `not contains` | `contains` 的反向判断。 |
|
||||
|
||||
### 改写请求参数
|
||||
|
||||
**改写请求参数** 区域默认给出一行 `request.body.model`。这也是最常用的模型路由写法:选择 **设置**,key 填 `request.body.model`,值填目标 `供应商/模型` 或 Fusion 模型。
|
||||
|
||||
点击 **添加参数** 可以追加多行 rewrite;垃圾桶按钮删除当前行,最后一行不能删除。规则命中后,CCR 会按列表顺序应用这些 rewrite。
|
||||
|
||||
| 操作 | 需要填写 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| **设置** | key、值 | 设置请求里的字段,例如 `request.body.model = provider/model` 或 `request.body.temperature = 0.2`。 |
|
||||
| **删除** | key | 删除请求字段。删除 `request.header.x-test` 会移除对应 Header;删除 `request.body.foo` 会移除 body 字段。 |
|
||||
| **追加到数组** | key、值 | 把值追加到目标数组末尾。目标不是数组时按空数组开始。 |
|
||||
| **插入到数组开头** | key、值 | 把值插到目标数组开头。 |
|
||||
| **从数组移除** | key、值 | 从目标数组中移除等于该值的元素。 |
|
||||
| **替换数组元素** | key、匹配值、值 | 把数组中匹配 **匹配值** 的元素替换为新值。 |
|
||||
|
||||
Rewrite 的值也会按字面量解析,所以 `0.2` 会变成数字,`true` 会变成布尔值,`{"type":"web_search"}` 会变成对象。只有 `request.body.model` 的值会额外按 CCR 的模型选择器格式规范化。
|
||||
|
||||
### 失败时
|
||||
|
||||
弹窗底部的 **失败时** 和页面顶部的 **默认失败处理** 是同一套控件。选择 **关闭** 时不降级;选择 **继续重试** 时会出现 **重试次数**;选择 **失败降级目标** 时会出现 **失败降级目标** 输入框和 **添加** 按钮。添加后的目标会以标签形式显示,标签上的上移、下移、移除按钮用于调整降级顺序。
|
||||
|
||||
规则命中时会使用这条规则自己的 **失败时** 设置;没有命中的请求才继续使用页面顶部的默认设置。
|
||||
|
||||
### 配置示例
|
||||
|
||||
| 目标 | 条件来源 | 字段 | 操作符 | 值 | 改写请求参数 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 按客户端 Header 分流 | `request.header` | `x-client-name` | `==` | `claude-code` | **设置** `request.body.model = 供应商/模型` |
|
||||
| 按原始模型前缀分流 | `request.body` | `model` | `starts with` | `claude-` | **设置** `request.body.model = 供应商/模型` |
|
||||
| 按消息内容分流到视觉模型 | `request.body` | `messages` | `contains deep` | `image` | **设置** `request.body.model = 视觉供应商/模型` |
|
||||
| 删除调试 Header | `request.header` | `x-debug-route` | `==` | `1` | **删除** `request.header.x-debug-route` |
|
||||
|
||||
保存后,规则会出现在列表中。请求日志里的 `request model`、`resolved provider`、`resolved model` 和路由原因可以用来确认规则是否命中。
|
||||
|
||||
## Fallback 处理
|
||||
|
||||
Fallback 处理请求失败后的降级。第一次选模型由路由完成;当前模型或上游失败时,Fallback 决定是否继续尝试。
|
||||
|
||||
路由页面顶部的 **默认失败处理** 是全局 Fallback。每条路由规则里的 **失败时** 是规则级 Fallback:当某条规则命中时,规则级配置会覆盖全局配置。
|
||||
|
||||
|
|
@ -41,7 +162,7 @@ Fallback 是请求失败后的降级策略。它不负责第一次选模型,
|
|||
| 继续重试 | `408`、`409`、`429`、`5xx` |
|
||||
| 失败降级目标 | 任意 `4xx` 或 `5xx` |
|
||||
|
||||
对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
|
||||
进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。
|
||||
|
||||
**失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。
|
||||
|
||||
|
|
@ -55,7 +176,7 @@ Fallback 是请求失败后的降级策略。它不负责第一次选模型,
|
|||
2. 如果选择 **继续重试**,填写 `Retries`。
|
||||
3. 如果选择 **失败降级目标**,按优先级添加备用模型。
|
||||
|
||||
全局 Fallback 会应用到默认路由,以及没有单独配置 Fallback 的规则。
|
||||
全局 Fallback 会应用到没有单独配置 Fallback 的规则。
|
||||
|
||||
### 规则级失败降级
|
||||
|
||||
|
|
@ -63,24 +184,6 @@ Fallback 是请求失败后的降级策略。它不负责第一次选模型,
|
|||
|
||||
规则级 Fallback 适合高风险或高成本模型。例如:图片任务先走 Fusion 视觉模型,失败后切到另一个多模态模型;复杂任务先走强模型,失败后切到稳定模型。
|
||||
|
||||
### 条件路由
|
||||
|
||||
当前界面新增规则时主要使用条件路由。条件可以读取请求 Header 或请求 Body:
|
||||
|
||||
| 来源 | 示例 |
|
||||
| --- | --- |
|
||||
| `request.header` | `x-client-name == claude-code` |
|
||||
| `request.body` | `model starts-with claude-` |
|
||||
| `request.body` | `messages contains-deep image` |
|
||||
|
||||
命中后,请求动作可以设置、删除或修改请求字段。最常用的是:
|
||||
|
||||
```text
|
||||
set request.body.model = 供应商/模型
|
||||
```
|
||||
|
||||
模型也可以是 Fusion 模型。这样路由可以把特定请求导向视觉、搜索或工具增强模型。
|
||||
|
||||
## 验证方式
|
||||
|
||||
保存后发一次请求,到请求日志里检查:
|
||||
|
|
|
|||
|
|
@ -5,9 +5,17 @@ eyebrow: 详细配置
|
|||
lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MITM 劫持并代理到 CCR 的能力。
|
||||
---
|
||||
|
||||
## 基本概念
|
||||
## 先区分管理地址和网关地址
|
||||
|
||||
“服务”页面控制 CCR 本地网关如何监听请求。普通模型请求通常只需要配置 `Host` 和 `Port`。
|
||||
**服务配置** 中的 Host / Port 指模型网关,不是浏览器管理页面:
|
||||
|
||||
| 运行方式 | 管理入口 | 模型网关 |
|
||||
| --- | --- | --- |
|
||||
| 桌面应用 | 应用窗口 | 默认 `http://127.0.0.1:3456` |
|
||||
| npm CLI | 默认 `http://127.0.0.1:3458` | 默认 `http://127.0.0.1:3456` |
|
||||
| Docker | 默认公开入口 `http://127.0.0.1:3458` | 由 Nginx 合并到同一公开入口 |
|
||||
|
||||
CLI 的 `--host` / `--port` 配置管理服务;本页字段配置模型网关。Docker 的内部管理和网关端口不应单独发布,详见 [Docker 部署](../../guides/docker/)。
|
||||
|
||||
## 主字段
|
||||
|
||||
|
|
@ -16,6 +24,20 @@ lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MI
|
|||
| Host | CCR 网关监听的主机地址。常见值是 `127.0.0.1` 或 `0.0.0.0`。 |
|
||||
| Port | CCR 网关监听端口。客户端需要把 API Base URL 指向这个端口。 |
|
||||
|
||||
Host 使用 `127.0.0.1` 时仅本机可访问;`0.0.0.0` 会监听所有 IPv4 网卡。只有在确实需要局域网或远程访问时才使用通配地址,并同时配置 CCR 客户端 API Key、防火墙 / 私网和 TLS 反向代理。
|
||||
|
||||
管理 Token、CCR 客户端 API Key 和上游供应商凭据彼此独立。客户端访问网关时使用 **API 密钥** 页面创建的 CCR Key,不要直接暴露上游凭据。
|
||||
|
||||
## 启动和验证
|
||||
|
||||
1. 至少添加一个供应商和模型。
|
||||
2. 在 **API 密钥** 页面创建客户端 Key。
|
||||
3. 点击 **启动** 或 **重启**。
|
||||
4. 确认状态显示运行中,并请求网关的 `/health`。
|
||||
5. 发出最小模型请求,再到请求日志核对最终供应商 / 模型。
|
||||
|
||||
管理 UI 可访问不代表模型网关已运行。Docker 在网关未启动时会让 `/health` 返回 `502`;桌面版 / CLI 也可能在没有可用模型时只保留管理服务。
|
||||
|
||||
## 代理模式
|
||||
|
||||
代理模式是本地代理能力。开启后,客户端可以把 HTTP/HTTPS 流量交给 CCR;CCR 会通过 MITM 劫持识别和解密 HTTPS 请求,并把可处理的模型请求代理到 CCR 网关链路。
|
||||
|
|
@ -30,3 +52,5 @@ lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MI
|
|||
| 检查信任 | 重新检测代理 CA 是否已被系统信任。 |
|
||||
| 代理状态 | 显示代理服务当前是否运行。 |
|
||||
| 重启代理 | 代理模式开启时,重新启动代理服务。 |
|
||||
|
||||
代理模式需要操作本机网络和证书信任,主要面向桌面环境。容器部署通常应把客户端直接指向 CCR 的 Nginx 网关入口,不建议依赖容器修改宿主机系统代理或安装宿主机 CA。
|
||||
|
|
|
|||
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。
|
||||
|
|
@ -5,10 +5,6 @@ eyebrow: 详细配置
|
|||
lead: 配置 CCR 系统托盘图标、余额进度条和托盘窗口组件。
|
||||
---
|
||||
|
||||
## 基本概念
|
||||
|
||||
“托盘”位于“设置”里,用于配置系统托盘图标和托盘窗口。托盘窗口可以展示供应商、账户余额、token 趋势、活跃度、指标和模型占比等信息,适合在不打开主窗口时快速查看 CCR 状态。
|
||||
|
||||
## 顶部字段
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
|
|
|
|||
|
|
@ -7,17 +7,15 @@ lead: 从安装开始,逐步接入供应商、让 Agent 通过 CCR 发请求
|
|||
|
||||
## 安装并启动 CCR
|
||||
|
||||
### 下载安装
|
||||
CCR 提供三种发行方式:桌面应用、Node.js 22+ 的 npm CLI,以及 Docker 单入口部署。
|
||||
|
||||
1. 打开 [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) 页面。
|
||||
2. 按你的系统下载安装包:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。
|
||||
3. 像普通桌面软件一样安装并打开 **Claude Code Router**。
|
||||
| 方式 | 启动入口 | 默认管理地址 | 默认模型网关 |
|
||||
| --- | --- | --- | --- |
|
||||
| 桌面应用 | 应用界面 / `ccr-app` | 应用内窗口 | `http://127.0.0.1:3456` |
|
||||
| npm CLI | `ccr ui` / `ccr serve` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
|
||||
| Docker | `docker compose up -d --build` | 与网关共用 `http://127.0.0.1:3458` | 与管理界面共用 Nginx 入口 |
|
||||
|
||||
### 启动服务
|
||||
|
||||
进入 **服务** 页面,点击 **启动**。页面显示运行中后,CCR 会在本机监听默认地址 `http://localhost:8080`。
|
||||
|
||||
如果希望打开 App 后自动启动服务,可以在服务页面开启自动启动。
|
||||
先阅读[安装页](install/)选择发行方式;完整终端命令见 [CLI 参考](cli/),容器端口、鉴权、持久化和升级见 [Docker 部署](docker/)。
|
||||
|
||||
## 接入供应商
|
||||
|
||||
|
|
@ -57,7 +55,7 @@ lead: 从安装开始,逐步接入供应商、让 Agent 通过 CCR 发请求
|
|||
|
||||
## 接入 Agent配置
|
||||
|
||||
Agent配置让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
|
||||
Agent配置让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
|
||||
|
||||
通用建议:
|
||||
|
||||
|
|
@ -73,13 +71,17 @@ Agent配置让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、
|
|||
|
||||
在 **Agent配置** 中选择 Codex,确认供应商 ID、供应商名称、模型和配置文件。需要特定 CLI 时再填写 Codex CLI path 和 Codex home。
|
||||
|
||||
### Grok CLI
|
||||
|
||||
选择 Grok CLI 并设置默认模型,然后运行复制出的 `ccr-app <配置名称>` 命令。即使 CCR Desktop 网关尚未运行,该命令也会启动一个可共享的临时网关服务;并发 Grok 会话会共同保持服务运行,直到最后一个会话退出。CCR 会把 Grok 的模型发现和推理请求指向本地网关;进入 Grok 后可以用 `/model` 切换 CCR 模型。
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。
|
||||
|
||||
### 复用本机已登录的 Agent
|
||||
|
||||
如果本机已经登录过 Claude Code、Codex 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
|
||||
如果本机已经登录过 Claude Code、Codex、Grok CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
|
||||
|
||||
## 日志&观测
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: 接入 Agent配置
|
||||
pageTitle: 接入 Agent配置
|
||||
eyebrow: 快速开始
|
||||
lead: 让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
|
||||
lead: 让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。
|
||||
---
|
||||
|
||||
## 通用建议
|
||||
|
|
@ -23,10 +23,14 @@ lead: 让 Claude Code、Codex、ZCode 等 Agent 使用 CCR 的供应商、路由
|
|||
|
||||
需要特定 CLI 时再填写 Codex CLI path 和 Codex home。
|
||||
|
||||
## Grok CLI
|
||||
|
||||
选择 Grok CLI、设置模型,然后运行复制出的 `ccr-app <配置名称>` 命令。CCR Desktop 网关尚未运行时,该命令会启动一个可共享的临时网关服务,并保持运行到最后一个并发 Grok 会话退出。进入 Grok 后可以使用 `/model` 切换 CCR 暴露的模型。
|
||||
|
||||
## ZCode
|
||||
|
||||
ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。
|
||||
|
||||
## 复用本机已登录的 Agent
|
||||
|
||||
如果本机已经登录过 Claude Code、Codex 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
|
||||
如果本机已经登录过 Claude Code、Codex、Grok CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。
|
||||
|
|
|
|||
220
docs/src/content/docs/zh/guides/cli.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
---
|
||||
title: CLI 安装与命令参考
|
||||
pageTitle: CLI 安装与命令参考
|
||||
eyebrow: 快速开始
|
||||
lead: 使用 npm 版 CCR 在开发机或无桌面服务器上运行管理界面、模型网关,并按 Agent 配置启动本机工具。
|
||||
---
|
||||
|
||||
## CLI 与桌面版命令的区别
|
||||
|
||||
CCR 有两个相关命令:
|
||||
|
||||
| 命令 | 来源 | 主要用途 |
|
||||
| --- | --- | --- |
|
||||
| `ccr` | npm 包 `@musistudio/claude-code-router` | 不依赖 Electron,启动浏览器管理界面、模型网关和 Agent 配置。 |
|
||||
| `ccr-app` | CCR 桌面应用 | 桌面版生成的配置启动器;Agent配置卡片复制的命令使用这个名称。 |
|
||||
|
||||
两个发行版会读取同一套本机配置目录,但不要把命令名混用。需要托盘、桌面通知、自动更新和桌面专属浏览器集成时,使用桌面版;需要无桌面部署或由进程管理器托管时,使用 npm CLI。
|
||||
|
||||
## 安装、升级与卸载
|
||||
|
||||
CLI 要求 Node.js 22 或更高版本:
|
||||
|
||||
```sh
|
||||
node --version
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr --help
|
||||
```
|
||||
|
||||
升级和卸载:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router@latest
|
||||
npm uninstall -g @musistudio/claude-code-router
|
||||
```
|
||||
|
||||
卸载 npm 包不会删除 CCR 的本地配置和数据库。
|
||||
|
||||
如果安装成功但找不到命令,执行 `npm prefix -g`,确认 npm 全局可执行目录已经加入 `PATH`,然后打开一个新终端。
|
||||
|
||||
## 第一次启动
|
||||
|
||||
在后台启动 CCR 并打开管理界面:
|
||||
|
||||
```sh
|
||||
ccr ui
|
||||
```
|
||||
|
||||
SSH 或无桌面环境使用:
|
||||
|
||||
```sh
|
||||
ccr ui --no-open
|
||||
```
|
||||
|
||||
随后按这个顺序完成配置:
|
||||
|
||||
1. 添加供应商和至少一个模型。
|
||||
2. 在 **API 密钥** 页面创建用于访问网关的 CCR 客户端 Key。
|
||||
3. 按需要设置默认模型、路由规则和 Fallback。
|
||||
4. 在 **服务** 页面确认网关已经运行。
|
||||
5. 把客户端 Base URL 指向界面显示的网关地址。
|
||||
|
||||
管理界面默认使用 `http://127.0.0.1:3458`,模型网关默认使用 `http://127.0.0.1:3456`。管理 Token 与 CCR 客户端 Key 是两种独立凭据:前者保护 UI / RPC,后者验证模型请求。
|
||||
|
||||
## 服务命令总览
|
||||
|
||||
| 命令 | 运行方式 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `ccr start` | 后台 | 启动管理服务和模型网关,打印带认证信息的管理 URL。 |
|
||||
| `ccr ui` | 后台 | 复用或启动后台服务,并打开浏览器。 |
|
||||
| `ccr stop` | 一次性 | 停止由 `start` 或 `ui` 启动的后台服务。 |
|
||||
| `ccr serve` | 前台 | 在当前终端运行,适合查看日志或交给进程管理器。 |
|
||||
| `ccr web` | 前台 | `serve` 的别名。 |
|
||||
| `ccr <配置名称或 ID>` | 前台 | 启动一个已启用的 Agent 配置。 |
|
||||
|
||||
## `ccr start`
|
||||
|
||||
```text
|
||||
ccr start [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| `--host <host>` | 管理服务监听地址,默认 `127.0.0.1`。也接受 `--host=value`。 |
|
||||
| `--port <port>` | 管理服务首选端口,默认 `3458`。也接受 `--port=value`。 |
|
||||
| `--open` | 启动后打开浏览器。 |
|
||||
| `--no-open` | 不打开浏览器。 |
|
||||
| `--gateway` | 明确要求启动模型网关;这是默认行为。 |
|
||||
| `--no-gateway` | 只启动管理服务,不在启动阶段拉起模型网关。 |
|
||||
|
||||
如果首选端口被占用,CCR 会继续尝试后续端口并打印实际 URL。端口必须是 `1` 到 `65535` 的整数。
|
||||
|
||||
## `ccr ui`
|
||||
|
||||
```text
|
||||
ccr ui [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
`ui` 与 `start` 使用同一个后台服务,但默认会打开浏览器。管理 URL 包含 `ccr_web_token` 查询参数;请把完整 URL 当作密码,不要粘贴到日志、工单或公开截图。
|
||||
|
||||
## `ccr serve`
|
||||
|
||||
```text
|
||||
ccr serve [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
`serve` 留在前台,收到 `SIGINT` 或 `SIGTERM` 后关闭管理服务和已配置服务。排查启动错误时优先使用它,因为错误会直接输出到当前终端。
|
||||
|
||||
`ccr stop` 只管理后台服务。前台 `serve` 应通过当前终端或外部进程管理器停止。
|
||||
|
||||
## 后台服务的复用规则
|
||||
|
||||
`start` 和 `ui` 会把进程 ID、URL 和私有服务 Token 写入 `service.json`。再次执行时,CCR 会先验证对应进程和 RPC 身份:
|
||||
|
||||
- 服务有效时直接复用,不会再启动第二个后台进程。
|
||||
- 新传入的 Host、Port 和 `--no-gateway` 不会重配已经运行的进程。
|
||||
- 如果新命令要求网关运行,CCR 会尝试在现有管理进程中启动网关。
|
||||
- 状态文件失效或进程已经退出时,CCR 会清理旧状态并启动新服务。
|
||||
|
||||
需要修改监听参数时先执行:
|
||||
|
||||
```sh
|
||||
ccr stop
|
||||
ccr start --host 127.0.0.1 --port 3458
|
||||
```
|
||||
|
||||
## 按 Agent 配置启动
|
||||
|
||||
先在 **Agent配置** 中创建并启用配置,然后使用:
|
||||
|
||||
```text
|
||||
ccr <配置名称或 ID> [cli|app] [-- <Agent 参数>]
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```sh
|
||||
ccr "Codex - Work"
|
||||
ccr "Codex - Work" app
|
||||
ccr "Claude - Review" cli -- --model sonnet
|
||||
ccr profile-id -- --help
|
||||
```
|
||||
|
||||
规则如下:
|
||||
|
||||
- `--cli` 和 `--app` 可以替代位置形式的 `cli` / `app`。
|
||||
- Agent 自己的参数放到 `--` 后,避免与 CCR 选项或入口名冲突。
|
||||
- 省略入口时,Claude Code、Codex、Grok CLI 默认使用 CLI,ZCode 默认使用 App。
|
||||
- Grok 只支持 CLI,ZCode 只支持 App。
|
||||
- Claude App 和 ZCode App 不支持额外 Agent 参数。
|
||||
- 启动 App 需要本机安装对应桌面应用,并且当前环境有图形会话。
|
||||
- 只有已启用的配置可以启动。名称产生歧义时使用配置 ID。
|
||||
|
||||
大多数配置要求 CCR 网关已经运行。Grok CLI 是例外:如果服务不存在,它可以自动启动一个受管的临时共享服务,并在最后一个 Grok 会话退出后关闭。
|
||||
|
||||
## 配置和数据位置
|
||||
|
||||
| 平台 | 配置目录 |
|
||||
| --- | --- |
|
||||
| macOS / Linux | `~/.claude-code-router` |
|
||||
| Windows | `%APPDATA%\claude-code-router` |
|
||||
|
||||
常见文件和目录:
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `config.sqlite` | 当前应用配置。 |
|
||||
| `app-data/` | API Key、用量、请求日志、证书等运行数据。 |
|
||||
| `service.json` | 后台 CLI 服务状态和私有 Token。 |
|
||||
| `gateway.config.json` | 生成的网关运行配置。 |
|
||||
| `profiles/` | 按 Agent 配置隔离的文件。 |
|
||||
| `bin/` | CCR 生成的 Agent 启动包装器。 |
|
||||
|
||||
不要在 CCR 运行时直接编辑或复制活跃 SQLite 文件。优先使用 **Settings → Export data**;文件级备份前先停止 CLI 和桌面应用。
|
||||
|
||||
## 环境变量与远程访问
|
||||
|
||||
公开的管理认证变量是:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `CCR_WEB_HOST` | 省略 `--host` 时使用的管理服务监听地址。 |
|
||||
| `CCR_WEB_PORT` | 省略 `--port` 时使用的管理服务端口。 |
|
||||
| `CCR_WEB_AUTH_TOKEN` | 固定管理 UI / RPC Token;不设置时进程会生成随机 Token。 |
|
||||
|
||||
监听到 `0.0.0.0` 会让管理界面进入局域网或外部网络。只有在确实需要时才这样配置,并同时使用固定强 Token、主机防火墙或私网,以及可信反向代理提供的 TLS。
|
||||
|
||||
模型网关还需要单独创建 CCR 客户端 Key。上游供应商凭据保存在本地数据目录,因此目录和备份都应按敏感数据保护。
|
||||
|
||||
## 进程管理器示例
|
||||
|
||||
生产环境应使用 `ccr serve --no-open`,让外部管理器负责重启和日志。启动命令至少应固定工作用户、`HOME`、监听地址和 `CCR_WEB_AUTH_TOKEN`。不要同时运行由 `ccr start` 创建的后台服务,否则可能得到两个管理端口或竞争同一套配置。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### UI 能打开,但 `/health` 或模型请求失败
|
||||
|
||||
管理服务可以在没有可用模型网关时运行。添加供应商和模型、创建 CCR 客户端 Key,然后从 **服务** 页面启动或重启网关。使用 `ccr serve` 查看启动错误。
|
||||
|
||||
### 实际管理端口不是 3458
|
||||
|
||||
3458 已被占用,CCR 使用了后续可用端口。以命令打印的 URL 为准;需要固定端口时,先停止冲突进程。
|
||||
|
||||
### 找不到 Agent 配置
|
||||
|
||||
确认配置已启用,并检查名称是否重复。CCR 会按 ID、名称、忽略大小写的名称和清理后的名称匹配;多个结果时必须使用 ID。
|
||||
|
||||
### 提示启动器不存在
|
||||
|
||||
先打开一次 CCR 或重新保存该 Agent 配置,让 CCR 重新生成 `bin/` 下的启动包装器。
|
||||
|
||||
### 后台服务无法停止
|
||||
|
||||
先运行 `ccr stop`。如果状态文件已经失效,命令会清理它并报告服务未运行。前台 `ccr serve` 不受 `ccr stop` 管理,应回到对应终端或进程管理器停止。
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [安装并启动 CCR](../install/)
|
||||
- [Agent配置](../../configuration/profile/)
|
||||
- [服务配置](../../configuration/server/)
|
||||
- [Docker 部署](../docker/)
|
||||
297
docs/src/content/docs/zh/guides/docker.md
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
---
|
||||
title: Docker 部署
|
||||
pageTitle: Docker 部署
|
||||
eyebrow: 快速开始
|
||||
lead: 使用 Nginx 单入口运行 CCR Core 和浏览器管理界面,并正确处理端口、鉴权、持久化、远程访问、备份和升级。
|
||||
---
|
||||
|
||||
## 适用范围与限制
|
||||
|
||||
Docker 镜像适合常驻模型网关和浏览器管理。它包含 CCR Core、构建后的管理 UI、PM2 和 Nginx,但不包含:
|
||||
|
||||
- Electron 桌面应用、系统托盘和桌面通知;
|
||||
- npm 发行版的 `ccr` 命令;
|
||||
- 从容器中启动宿主机 Claude App、ChatGPT、ZCode 等桌面 App;
|
||||
- 桌面自动更新和桌面专属的内置浏览器集成。
|
||||
|
||||
如果主要需求是本机 Agent 多开、托盘或桌面 App 启动,请使用桌面版;如果需要终端命令但不需要容器,请使用 [CLI](../cli/)。
|
||||
|
||||
## 进程和端口拓扑
|
||||
|
||||
```text
|
||||
宿主机 3458 -> 容器 Nginx 8080
|
||||
|-> 静态管理 UI
|
||||
|-> 管理 RPC:127.0.0.1:3459
|
||||
|-> 模型网关:127.0.0.1:3456
|
||||
`-> Core Runtime:127.0.0.1:3457
|
||||
```
|
||||
|
||||
只应发布 Nginx 的容器端口 `8080`。`3459`、`3456`、`3457` 都是容器内部实现端口,不要分别映射到宿主机。
|
||||
|
||||
Nginx 对外提供:
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `/`、`/pages/home/index.html` | 管理 UI。根路径会跳转到带管理 Token 的页面。 |
|
||||
| `/api/ccr/rpc` | 需要管理 Token 的管理 RPC。 |
|
||||
| `/health` | 模型网关健康状态,不是容器或 UI 健康状态。 |
|
||||
| `/v1/*`、`/v1beta/*`、`/messages`、`/chat/completions`、`/responses`、`/interactions`、`/mcp/*` | 模型和 MCP 网关接口。 |
|
||||
|
||||
## 使用 Compose 快速启动
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose logs -f ccr
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:3458>。新数据卷上管理 UI 会立即可用;模型网关要在添加供应商和模型后才能正常启动。
|
||||
|
||||
首次配置顺序:
|
||||
|
||||
1. 添加供应商和至少一个模型。
|
||||
2. 在 **API 密钥** 页面创建 CCR 客户端 Key。
|
||||
3. 在 **服务** 页面启动网关。
|
||||
4. 请求 `/health`,确认返回 `200` 和运行状态。
|
||||
5. 把客户端 Base URL 指向 `http://127.0.0.1:3458`,并使用刚创建的 CCR 客户端 Key。
|
||||
|
||||
停止或移除容器不会自动删除命名卷:
|
||||
|
||||
```sh
|
||||
docker compose stop
|
||||
docker compose down
|
||||
```
|
||||
|
||||
不要给 `docker compose down` 添加 `--volumes`,除非你明确要删除全部 CCR 数据。
|
||||
|
||||
## 只允许本机访问
|
||||
|
||||
仓库默认映射 `3458:8080` 会监听宿主机所有网卡。如果只从当前机器访问,修改为:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "127.0.0.1:3458:8080"
|
||||
```
|
||||
|
||||
端口映射左侧是宿主机地址和端口,右侧是 Nginx 容器端口。不要把右侧改为内部网关的 `3456`。
|
||||
|
||||
## 使用 `docker run`
|
||||
|
||||
不使用 Compose 时:
|
||||
|
||||
```sh
|
||||
docker build -t claude-code-router:local .
|
||||
docker run -d \
|
||||
--name claude-code-router \
|
||||
--restart unless-stopped \
|
||||
-p 127.0.0.1:3458:8080 \
|
||||
-e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \
|
||||
-v ccr-data:/data \
|
||||
claude-code-router:local
|
||||
```
|
||||
|
||||
仓库也提供 `npm run docker:build` 和 `npm run docker:run`。后者使用 `3458` 和 `ccr-data`,但容器带 `--rm`,没有固定名称和自动重启策略,更适合临时验证。
|
||||
|
||||
## 三类凭据不要混用
|
||||
|
||||
| 凭据 | 用途 | 配置位置 |
|
||||
| --- | --- | --- |
|
||||
| `CCR_WEB_AUTH_TOKEN` | 管理 UI / RPC 鉴权 | 容器环境变量 |
|
||||
| CCR 客户端 API Key | 模型网关请求鉴权 | UI 的 **API 密钥** 页面 |
|
||||
| 上游供应商凭据 | CCR 调用模型供应商 | UI 的 **供应商** 页面 |
|
||||
|
||||
不设置 `CCR_WEB_AUTH_TOKEN` 时,EntryPoint 每次启动容器都会生成新的随机 Token。打开根地址仍可工作,因为 Nginx 会跳转到包含当前 Token 的 URL;但持久部署和远程部署应固定一个足够长的强 Token。
|
||||
|
||||
不要把 Token 直接写进 Shell 历史。可以创建不进入版本控制的环境文件:
|
||||
|
||||
```dotenv
|
||||
CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value
|
||||
CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458
|
||||
```
|
||||
|
||||
通过 `docker run --env-file` 使用,或把同名变量映射到 Compose 服务的 `environment`。包含 `ccr_web_token` 的完整管理 URL 也应按密码保护,因为它可能出现在浏览器历史、反向代理日志、截图和工单中。
|
||||
|
||||
## 修改外部端口或地址
|
||||
|
||||
宿主机对外地址与容器内部端口是两层配置。修改宿主机端口时,还要把 `CCR_PUBLIC_BASE_URL` 设置为客户端真实使用的完整地址:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "127.0.0.1:8088:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
|
||||
CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
`CCR_PUBLIC_BASE_URL` 会同步到 CCR 的公开 Router Endpoint。它本身不会发布 Docker 端口,也不会改变 Nginx 监听地址。
|
||||
|
||||
## 域名、HTTPS 与反向代理
|
||||
|
||||
由反向代理或 Ingress 终止 TLS 时:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "127.0.0.1:3458:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: https://ccr.example.com
|
||||
CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
|
||||
```
|
||||
|
||||
反向代理应把全部路径交给 CCR Nginx,并满足:
|
||||
|
||||
- 支持长时间模型请求;
|
||||
- 不缓冲 SSE 和流式模型响应;
|
||||
- 允许足够的请求体大小;
|
||||
- 只有反向代理入口对外公开,宿主机 `3458` 保持仅本机监听;
|
||||
- 配合防火墙、VPN / 私网或额外访问控制,避免管理界面直接暴露到不可信网络。
|
||||
|
||||
## 持久化目录
|
||||
|
||||
EntryPoint 会设置 `HOME=/data`,实际数据位于:
|
||||
|
||||
```text
|
||||
/data/.claude-code-router/
|
||||
├── config.sqlite
|
||||
├── gateway.config.json
|
||||
├── app-data/
|
||||
│ ├── api-keys.sqlite
|
||||
│ ├── request-logs.sqlite
|
||||
│ ├── usage.sqlite
|
||||
│ └── certs/
|
||||
├── profiles/
|
||||
└── bin/
|
||||
```
|
||||
|
||||
优先使用命名卷。Bind Mount 目录必须允许容器写入,而且不能让两个运行中的 CCR 容器共享同一份数据。
|
||||
|
||||
全新数据目录中既没有 `config.json` 也没有 `config.sqlite` 时,EntryPoint 默认写入最小的旧格式 `config.json` 作为首次引导。UI 保存后 SQLite 成为权威配置。每次启动默认还会把 JSON / SQLite 中的网关监听字段和 `routerEndpoint` 同步到当前 Docker 公开地址。
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
应用级备份优先使用 **Settings → Export data**。做完整文件备份时,先停止写入:
|
||||
|
||||
```sh
|
||||
docker compose stop ccr
|
||||
docker compose cp ccr:/data/. ./ccr-data-backup/
|
||||
docker compose start ccr
|
||||
```
|
||||
|
||||
备份包含供应商凭据、CCR 客户端 Key,并可能包含请求 / 响应数据,必须按敏感数据保存。
|
||||
|
||||
完整恢复时,应把备份复制到新的空卷或空 `/data` 目录,并确保容器已停止。不要把旧备份直接覆盖到仍有新数据的活动目录,否则旧 SQLite WAL / SHM 和新运行文件可能混合。替换现有数据前再做一份备份。
|
||||
|
||||
## 升级与回滚
|
||||
|
||||
先备份 `/data`,再更新源码、刷新基础镜像并重建:
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose build --pull
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs --tail=200 ccr
|
||||
```
|
||||
|
||||
升级会对持久化数据执行当前版本需要的迁移。回滚时应同时使用旧镜像 / 旧源码和升级前备份,不要假设旧版本一定能读取新版本数据库。
|
||||
|
||||
## 环境变量完整参考
|
||||
|
||||
一般部署只需要设置 `CCR_WEB_AUTH_TOKEN`、`CCR_PUBLIC_BASE_URL` 和 Docker Port Mapping。内部监听变量通常不需要修改。
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `CCR_WEB_AUTH_TOKEN` | 每次启动随机生成 | 管理 UI / RPC Token。持久或远程部署应设置固定强值。 |
|
||||
| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | 写入 CCR 配置的完整公开地址;设置后优先于 Public Host / Port。 |
|
||||
| `CCR_PUBLIC_HOST` | `127.0.0.1` | 仅在没有完整公开 URL 时用于拼接公开地址,不会改变 Docker 端口绑定。 |
|
||||
| `CCR_PUBLIC_PORT` | `3458` | 仅在没有完整公开 URL 时用于拼接公开地址。 |
|
||||
| `CCR_DATA_DIR` | `/data` | 数据根目录,同时作为进程 `HOME`。 |
|
||||
| `CCR_NGINX_PORT` | `8080` | Nginx 容器内监听端口,应与 Port Mapping 右侧一致。 |
|
||||
| `CCR_WEB_HOST` | `127.0.0.1` | 管理服务容器内监听地址。 |
|
||||
| `CCR_WEB_PORT` | `3459` | 管理服务容器内端口。 |
|
||||
| `CCR_GATEWAY_HOST` | `127.0.0.1` | 模型网关容器内监听地址。 |
|
||||
| `CCR_GATEWAY_PORT` | `3456` | Nginx 转发到的模型网关容器内端口。 |
|
||||
| `CCR_GATEWAY_CORE_PORT` | `3457` | Core Gateway Runtime 容器内端口。 |
|
||||
| `CCR_NO_GATEWAY` | `0` | 设为 `1`、`true` 或 `yes` 时,启动阶段只运行管理 UI。 |
|
||||
| `CCR_DOCKER_INIT_CONFIG` | `1` | 设为 `0` 时禁用首次最小 `config.json` 引导。 |
|
||||
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | 设为 `0` 时不再在启动时同步已有 JSON / SQLite 的监听和公开地址字段。 |
|
||||
|
||||
修改内部端口需要同时保证 PM2 和 Nginx 变量一致,正常部署没有收益。对外仍然只发布 `CCR_NGINX_PORT`。
|
||||
|
||||
## 构建和烟雾测试
|
||||
|
||||
默认使用 `node:22-bookworm` 构建原生依赖,再把生产依赖和构建产物复制到 `node:22-bookworm-slim`。需要替换基础镜像时:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE=node:22-bookworm \
|
||||
--build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
|
||||
-t claude-code-router:local .
|
||||
```
|
||||
|
||||
运行 Docker 烟雾测试:
|
||||
|
||||
```sh
|
||||
npm run test:docker
|
||||
```
|
||||
|
||||
测试会创建临时容器和数据卷,检查单一 Nginx 端口、UI / RPC 鉴权、公开地址迁移、网关启动和 `/health`,最后自动清理。使用 `CCR_DOCKER_TEST_SKIP_BUILD=1` 复用已有镜像,或通过 `CCR_DOCKER_TEST_IMAGE` 指定本地 Tag。
|
||||
|
||||
## 日常运维命令
|
||||
|
||||
```sh
|
||||
docker compose ps
|
||||
docker compose logs -f ccr
|
||||
docker compose restart ccr
|
||||
docker compose config
|
||||
```
|
||||
|
||||
`docker compose ps` 显示的是容器健康;`/health` 显示的是模型网关健康。两者不能互相替代。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 根地址返回 `302`
|
||||
|
||||
这是正常行为。Nginx 正在把根地址跳转到带 URL 编码管理 Token 的页面。
|
||||
|
||||
### `/health` 返回 `502`
|
||||
|
||||
它检查模型网关,不检查 Nginx 或 UI。新数据卷尚未配置供应商 / 模型时会返回 `502`。先打开 UI 完成配置并启动网关。
|
||||
|
||||
### 修改 Token 后 UI 返回 `401`
|
||||
|
||||
重新打开不带参数的根地址,让 Nginx 生成包含新 Token 的 URL;关闭仍使用旧 `ccr_web_token` 的标签页和书签。
|
||||
|
||||
### 客户端仍使用旧端口或域名
|
||||
|
||||
更新 `CCR_PUBLIC_BASE_URL` 并重新创建容器。保持 `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1`,让已有 SQLite 配置在启动时同步。
|
||||
|
||||
### 重建后配置消失
|
||||
|
||||
确认 `/data` 仍挂载同一个命名卷或 Bind Mount。`docker compose down` 保留卷,`docker compose down --volumes` 删除卷。
|
||||
|
||||
### Bind Mount 权限错误
|
||||
|
||||
确认宿主机目录存在、容器可写且没有只读挂载。命名卷通常可以避免宿主机 UID、所有权和安全标签问题。
|
||||
|
||||
### 容器健康,但模型请求失败
|
||||
|
||||
容器健康只代表 Nginx / UI 可访问。继续检查 **服务** 状态、供应商连通性、CCR 客户端 Key、路由和请求日志,并查看:
|
||||
|
||||
```sh
|
||||
docker compose logs --tail=200 ccr
|
||||
```
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [安装并启动 CCR](../install/)
|
||||
- [CLI 安装与命令参考](../cli/)
|
||||
- [服务配置](../../configuration/server/)
|
||||
- [API 密钥](../../configuration/api-keys/)
|
||||
|
||||
|
|
@ -2,17 +2,66 @@
|
|||
title: 安装并启动 CCR
|
||||
pageTitle: 安装并启动 CCR
|
||||
eyebrow: 快速开始
|
||||
lead: 下载桌面应用,安装后启动本地 CCR 服务。
|
||||
lead: 根据桌面版、npm CLI 或 Docker 的运行场景选择安装方式,并确认管理界面与模型网关的不同地址。
|
||||
---
|
||||
|
||||
## 下载安装
|
||||
## 选择发行方式
|
||||
|
||||
| 方式 | 适合场景 | 入口 | 默认管理地址 | 默认网关地址 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 桌面应用 | 日常本机使用、托盘、多开 Agent App、桌面集成 | 应用界面、`ccr-app` | 应用内窗口 | `http://127.0.0.1:3456` |
|
||||
| npm CLI | 终端、SSH、无 Electron 环境、进程管理器 | `ccr` | `http://127.0.0.1:3458` | `http://127.0.0.1:3456` |
|
||||
| Docker | 常驻服务器、容器运维、统一浏览器入口 | Nginx | 与网关共用公开地址 | `http://127.0.0.1:3458`(默认端口映射) |
|
||||
|
||||
管理 UI 地址和模型网关地址在桌面版 / CLI 中不是同一个端口。不要把 CLI 的管理端口 `3458` 当成默认模型网关端口;Docker 才通过 Nginx 把两者合并到同一公开入口。
|
||||
|
||||
## 安装桌面应用
|
||||
|
||||
1. 打开 [GitHub Releases](https://github.com/musistudio/claude-code-router/releases) 页面。
|
||||
2. 按你的系统下载安装包:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。
|
||||
3. 像普通桌面软件一样安装并打开 **Claude Code Router**。
|
||||
2. 按系统下载:macOS 使用 `.dmg` 或 `.zip`,Windows 使用 `.exe`,Linux 使用 `.AppImage`。
|
||||
3. 安装并打开 **Claude Code Router**。
|
||||
4. 添加供应商和模型,在 **API 密钥** 中创建客户端 Key,然后从 **服务** 页面点击 **启动**。
|
||||
|
||||
## 启动服务
|
||||
页面显示运行中后,模型网关默认监听 `http://127.0.0.1:3456`。需要打开应用时自动启动网关,可在 **服务** 页面开启自动启动。
|
||||
|
||||
进入 **Server** 页面,点击 **Start**。页面显示 Running 后,CCR 会在本机监听默认地址 `http://localhost:8080`。
|
||||
## 安装 npm CLI
|
||||
|
||||
如果希望打开 App 后自动启动服务,可以在 Server 页面开启 **Auto start**。
|
||||
要求 Node.js 22 或更高版本:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr ui
|
||||
```
|
||||
|
||||
`ccr ui` 会启动后台服务并打开浏览器。无桌面环境使用 `ccr ui --no-open`,生产前台托管使用 `ccr serve --no-open`。完整命令和 Profile 启动说明见 [CLI 安装与命令参考](../cli/)。
|
||||
|
||||
## 使用 Docker
|
||||
|
||||
在源码仓库根目录执行:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:3458>。Docker 只发布 Nginx 单入口,管理 UI 和模型网关共用该地址。首次启动后仍需添加供应商 / 模型、创建 CCR 客户端 Key,并从 **服务** 页面启动网关。端口、鉴权、持久化、备份和远程部署见 [Docker 部署](../docker/)。
|
||||
|
||||
## 验证安装
|
||||
|
||||
完成供应商、模型和 CCR 客户端 Key 配置后:
|
||||
|
||||
1. 在 **服务** 页面确认状态为运行中。
|
||||
2. 请求当前部署的 `/health`;成功时应返回 `200` 和运行状态。
|
||||
3. 用 CCR 客户端 Key 向兼容路径发送一个最小模型请求。
|
||||
4. 在 **日志** 页面确认请求模型、最终供应商 / 模型、状态码和耗时。
|
||||
|
||||
管理界面能打开并不代表模型网关已经可用。没有供应商 / 模型时,Docker 的 `/health` 返回 `502` 属于预期行为。
|
||||
|
||||
## 数据位置
|
||||
|
||||
| 方式 | 配置位置 |
|
||||
| --- | --- |
|
||||
| 桌面 / CLI(macOS、Linux) | `~/.claude-code-router` |
|
||||
| 桌面 / CLI(Windows) | `%APPDATA%\claude-code-router` |
|
||||
| Docker | `/data/.claude-code-router`,应持久化挂载 `/data` |
|
||||
|
||||
CCR 当前配置存储在 `config.sqlite` 中;`config.json` 只在没有 SQLite 配置时作为旧版迁移或 Docker 首次引导来源。不要在 CCR 运行时直接编辑 SQLite。
|
||||
|
|
|
|||
|
|
@ -5,19 +5,6 @@ eyebrow: 产品文档
|
|||
lead: 了解 CCR 的定位、能力边界和文档结构。需要动手配置时,从顶部的「快速开始」开始;需要查字段、Bot 或 Fusion 时,进入「详细配置」。
|
||||
---
|
||||
|
||||
## CCR 能帮你做什么
|
||||
|
||||
**Claude Code Router(CCR)是本地运行的模型网关。** 它位于 Claude Code、Codex、ZCode 等 Agent 和上游模型服务之间,统一管理模型、API Key、路由规则、日志观测和 Bot 接力。
|
||||
|
||||
CCR 适合解决这些问题:
|
||||
|
||||
- 你不想在每个 Agent 里重复维护模型和 Key。
|
||||
- 你希望不同任务自动走不同模型:轻量后台任务用快模型,复杂任务用强模型,看图或联网任务走 Fusion。
|
||||
- 你需要在请求日志里看到请求实际去了哪个供应商、哪个模型、是否成功、延迟和成本大概是多少。
|
||||
- 你想把长时间运行的 Agent 消息转发到 Slack、Telegram、飞书、企业微信等 IM 平台。
|
||||
|
||||
CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个地址,请求就可以被 CCR 接管并按路由规则转发到上游供应商。
|
||||
|
||||
## 文档结构
|
||||
|
||||
顶部栏现在对应四个独立页面:
|
||||
|
|
@ -25,8 +12,8 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个
|
|||
| 分类 | 内容 |
|
||||
| --- | --- |
|
||||
| [文档](./) | 产品定位、架构概览、阅读路径 |
|
||||
| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 |
|
||||
| [详细配置](configuration/provider/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
|
||||
| [快速开始](guides/) | 桌面版、CLI、Docker 安装部署,以及供应商和 Agent 接入流程 |
|
||||
| [详细配置](configuration/overview/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
|
||||
| [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 |
|
||||
|
||||
Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。
|
||||
|
|
@ -35,9 +22,10 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独
|
|||
|
||||
第一次使用时可以从这些页面了解 CCR 的主要流程:
|
||||
|
||||
1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。
|
||||
2. App 的请求日志页面展示请求是否经过 CCR。
|
||||
3. [详细配置](configuration/provider/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
|
||||
4. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。
|
||||
1. 从[安装页](guides/install/)选择桌面版、npm CLI 或 Docker;对应细节见 [CLI](guides/cli/) 和 [Docker](guides/docker/) 页面。
|
||||
2. [快速开始](guides/) 继续覆盖供应商接入和 Agent配置。
|
||||
3. App 的请求日志页面展示请求是否经过 CCR。
|
||||
4. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
|
||||
5. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。
|
||||
|
||||
这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ A: 服务运行状态、Agent 启动方式、配置应用状态和作用范围
|
|||
|
||||
### Q: 请求命中了错误模型怎么办?
|
||||
|
||||
A: 请求日志会展示 `request model`、`resolved provider` 和 `resolved model`。路由配置页包含默认路由、规则顺序、匹配条件和 fallback。
|
||||
A: 请求日志会展示 `request model`、`resolved provider` 和 `resolved model`。路由配置页包含规则顺序、匹配条件和 fallback。
|
||||
|
||||
### Q: 供应商返回 401 或 403 是什么原因?
|
||||
### Q: 供应商返回 401 或 403 怎么处理?
|
||||
|
||||
A: 相关字段包括 API Key、凭据启用状态、基础 URL、协议和额外请求头。供应商页面提供模型连通性检查。
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
@ -40,6 +40,8 @@ export const docsContent = {
|
|||
icon: "book",
|
||||
items: [
|
||||
"安装并启动 CCR",
|
||||
"CLI 安装与命令参考",
|
||||
"Docker 部署",
|
||||
"接入供应商",
|
||||
"接入 Agent配置",
|
||||
"日志&观测",
|
||||
|
|
@ -51,6 +53,8 @@ export const docsContent = {
|
|||
sidebarChildren: {},
|
||||
sidebarLinks: {
|
||||
"安装并启动 CCR": "/guides/install/",
|
||||
"CLI 安装与命令参考": "/guides/cli/",
|
||||
"Docker 部署": "/guides/docker/",
|
||||
接入供应商: "/guides/provider/",
|
||||
"接入 Agent配置": "/guides/agent-profile/",
|
||||
"日志&观测": "/guides/observability/",
|
||||
|
|
@ -60,24 +64,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 +117,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 +176,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: {
|
||||
|
|
@ -188,6 +201,8 @@ export const docsContent = {
|
|||
icon: "book",
|
||||
items: [
|
||||
"Install And Start CCR",
|
||||
"CLI Installation And Reference",
|
||||
"Docker Deployment",
|
||||
"Add A Provider",
|
||||
"Connect Agent Config",
|
||||
"Logs & Observability",
|
||||
|
|
@ -199,6 +214,8 @@ export const docsContent = {
|
|||
sidebarChildren: {},
|
||||
sidebarLinks: {
|
||||
"Install And Start CCR": "/en/guides/install/",
|
||||
"CLI Installation And Reference": "/en/guides/cli/",
|
||||
"Docker Deployment": "/en/guides/docker/",
|
||||
"Add A Provider": "/en/guides/provider/",
|
||||
"Connect Agent Config": "/en/guides/agent-profile/",
|
||||
"Logs & Observability": "/en/guides/observability/",
|
||||
|
|
@ -208,24 +225,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 +268,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;
|
||||
|
|
@ -394,14 +399,21 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";
|
|||
</h2>
|
||||
<nav>
|
||||
{
|
||||
tocItems.map((item, index) => (
|
||||
<a
|
||||
class:list={{ active: index === 0 }}
|
||||
href={typeof item === "string" ? `#section-${index + 1}` : item.href}
|
||||
>
|
||||
{typeof item === "string" ? item : item.label}
|
||||
</a>
|
||||
))
|
||||
tocItems.map((item, index) => {
|
||||
const tocItem = typeof item === "string"
|
||||
? { label: item, href: `#section-${index + 1}`, depth: 2 }
|
||||
: { depth: 2, ...item };
|
||||
const depth = Math.min(Math.max(Number(tocItem.depth), 2), 6);
|
||||
|
||||
return (
|
||||
<a
|
||||
class:list={[`toc-depth-${depth}`, { active: index === 0 }]}
|
||||
href={tocItem.href}
|
||||
>
|
||||
{tocItem.label}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { enGuideDocs, sectionSlugFromPath } from "../../../section-docs";
|
|||
export function getStaticPaths() {
|
||||
const activeLabels: Record<string, string> = {
|
||||
install: "Install And Start CCR",
|
||||
cli: "CLI Installation And Reference",
|
||||
docker: "Docker Deployment",
|
||||
provider: "Add A Provider",
|
||||
"agent-profile": "Connect Agent Config",
|
||||
observability: "Logs & Observability",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { sectionSlugFromPath, zhGuideDocs } from "../../section-docs";
|
|||
export function getStaticPaths() {
|
||||
const activeLabels: Record<string, string> = {
|
||||
install: "安装并启动 CCR",
|
||||
cli: "CLI 安装与命令参考",
|
||||
docker: "Docker 部署",
|
||||
provider: "接入供应商",
|
||||
"agent-profile": "接入 Agent配置",
|
||||
observability: "日志&观测",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -847,6 +853,8 @@ pre {
|
|||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
|
@ -956,6 +964,24 @@ h1 {
|
|||
font-weight: 720;
|
||||
}
|
||||
|
||||
.doc-article h4,
|
||||
.doc-markdown > h4 {
|
||||
margin: 24px 0 8px;
|
||||
color: var(--heading);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doc-article h5,
|
||||
.doc-markdown > h5 {
|
||||
margin: 18px 0 6px;
|
||||
color: var(--heading);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.doc-markdown > ul,
|
||||
.doc-markdown > ol {
|
||||
margin: 0 0 22px;
|
||||
|
|
@ -1280,6 +1306,36 @@ h1 {
|
|||
--provider-brand-3: #d9e3f1;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-runapi {
|
||||
--provider-brand: #070707;
|
||||
--provider-brand-2: #747474;
|
||||
--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-unity2 {
|
||||
--provider-brand: #050505;
|
||||
--provider-brand-2: #7a7f85;
|
||||
--provider-brand-3: #f4f5f6;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-code0 {
|
||||
--provider-brand: #101214;
|
||||
--provider-brand-2: #267dff;
|
||||
--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;
|
||||
|
|
@ -1552,6 +1608,7 @@ pre code span {
|
|||
}
|
||||
|
||||
.toc a {
|
||||
--toc-dot-left: 0px;
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 5px 0 5px 21px;
|
||||
|
|
@ -1559,6 +1616,25 @@ pre code span {
|
|||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.toc a.toc-depth-3 {
|
||||
--toc-dot-left: 12px;
|
||||
padding-left: 33px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.toc a.toc-depth-4 {
|
||||
--toc-dot-left: 24px;
|
||||
padding-left: 45px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toc a.toc-depth-5,
|
||||
.toc a.toc-depth-6 {
|
||||
--toc-dot-left: 36px;
|
||||
padding-left: 57px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.toc a.active {
|
||||
color: var(--green);
|
||||
font-weight: 720;
|
||||
|
|
@ -1566,7 +1642,7 @@ pre code span {
|
|||
|
||||
.toc a.active::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: var(--toc-dot-left);
|
||||
top: 13px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
1603
package-lock.json
generated
48
package.json
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "claude-code-router",
|
||||
"version": "3.0.4",
|
||||
"name": "claude-code-router-monorepo",
|
||||
"version": "3.0.14",
|
||||
"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,39 @@
|
|||
"prepack": "npm run build:assets",
|
||||
"prepublishOnly": "npm run typecheck",
|
||||
"preview": "npm run build:assets && electron .",
|
||||
"test": "node build/test.mjs && node build/run-tests.mjs",
|
||||
"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",
|
||||
"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": "npm run test:packages && npm run test:architecture",
|
||||
"test:packages": "npm run test --workspaces --if-present",
|
||||
"test:core": "npm test -w @claude-code-router/core",
|
||||
"test:ui": "npm test -w @claude-code-router/ui",
|
||||
"test:electron": "npm test -w @claude-code-router/electron",
|
||||
"test:cli": "npm test -w @musistudio/claude-code-router",
|
||||
"test:architecture": "node build/test.mjs architecture && node build/run-tests.mjs architecture",
|
||||
"test:system": "node tests/system/docker-smoke.mjs",
|
||||
"test:docker": "npm run test:system",
|
||||
"test:e2e": "npm run build:assets && playwright test",
|
||||
"test:e2e:install": "playwright install chromium",
|
||||
"test:main": "npm run test:core && npm run test:electron",
|
||||
"test:renderer": "npm run test:ui",
|
||||
"benchmark:request-logs": "node build/benchmark-request-logs.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.2",
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@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
|
|
@ -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.
|
||||
184
packages/cli/README.md
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# Claude Code Router CLI
|
||||
|
||||
[中文](README_zh.md) · [Documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router)
|
||||
|
||||
`@musistudio/claude-code-router` is the Node.js distribution of Claude Code Router. It provides the `ccr` command, the browser-based management UI, the local model gateway, and profile launch commands without requiring Electron.
|
||||
|
||||
Use the CLI on developer machines and headless hosts. If you want the tray, desktop notifications, automatic app updates, or desktop-only browser integrations, install the desktop application instead.
|
||||
|
||||
## Requirements And Installation
|
||||
|
||||
- Node.js 22 or newer
|
||||
- A supported upstream model provider, or a locally logged-in agent account that CCR can import
|
||||
- A locally installed agent executable when using profile launch commands
|
||||
|
||||
Install globally:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr --help
|
||||
```
|
||||
|
||||
Upgrade or remove it with npm:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router@latest
|
||||
npm uninstall -g @musistudio/claude-code-router
|
||||
```
|
||||
|
||||
Removing the package does not delete CCR's local configuration or databases.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start the background service and open the management UI:
|
||||
|
||||
```sh
|
||||
ccr ui
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
1. Add an upstream provider and at least one model.
|
||||
2. Create a CCR client key under **API Keys**.
|
||||
3. Configure routing if the default provider/model is not sufficient.
|
||||
4. Confirm that the gateway is running under **Server**.
|
||||
5. Point your client at the gateway URL shown in the UI. The default gateway is `http://127.0.0.1:3456`; the management UI defaults to `http://127.0.0.1:3458`.
|
||||
|
||||
The management token and CCR client API keys are different credentials. The management token protects the browser UI and RPC API. CCR client keys authenticate model requests sent to the gateway.
|
||||
|
||||
## Service Commands
|
||||
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| `ccr start` | Starts a detached background management service and gateway, then prints its authenticated management URL. |
|
||||
| `ccr ui` | Reuses or starts the background service and opens the management UI. |
|
||||
| `ccr stop` | Stops the detached service started by `ccr start` or `ccr ui`. |
|
||||
| `ccr serve` | Runs the management service and gateway in the foreground. `ccr web` is an alias. |
|
||||
| `ccr <profile>` | Opens an enabled Agent Config profile by name or ID. |
|
||||
|
||||
### `ccr start`
|
||||
|
||||
```text
|
||||
ccr start [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
- `--host <host>`: management listener, default `127.0.0.1`.
|
||||
- `--port <port>`: preferred management port, default `3458`.
|
||||
- `--open` / `--no-open`: enable or disable opening a browser.
|
||||
- `--gateway`: explicitly request gateway startup; this is the default.
|
||||
- `--no-gateway`: start only the management service.
|
||||
|
||||
### `ccr ui`
|
||||
|
||||
```text
|
||||
ccr ui [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
`ui` opens the browser by default. Use `--no-open` on SSH or other headless sessions.
|
||||
|
||||
### `ccr serve`
|
||||
|
||||
```text
|
||||
ccr serve [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
`serve` stays attached to the current terminal and handles `SIGINT`/`SIGTERM`. It is the appropriate mode for a process supervisor. `ccr stop` only manages the detached service; stop a foreground server through its terminal or supervisor.
|
||||
|
||||
If the preferred management port is occupied, CCR tries the next available ports and prints the actual URL. When `start` or `ui` reuses an existing service, new host, port, and `--no-gateway` choices do not reconfigure that process. Run `ccr stop` first when those settings must change.
|
||||
|
||||
## Agent Config Profiles
|
||||
|
||||
Create and enable profiles in **Agent Config**, then launch one by name or ID:
|
||||
|
||||
```sh
|
||||
ccr "Codex - Work"
|
||||
ccr "Codex - Work" app
|
||||
ccr "Claude - Review" cli -- --model sonnet
|
||||
ccr profile-id -- --help
|
||||
```
|
||||
|
||||
The syntax is:
|
||||
|
||||
```text
|
||||
ccr <profile-name-or-id> [cli|app] [-- <agent arguments>]
|
||||
```
|
||||
|
||||
- `--cli` and `--app` are accepted alternatives to the positional surface.
|
||||
- Put agent-specific arguments after `--` so they cannot be confused with CCR options.
|
||||
- If the surface is omitted, CCR uses the first surface allowed by the profile: CLI for Claude Code, Codex, and Grok CLI; App for ZCode.
|
||||
- Grok supports CLI only. ZCode supports App only. Claude App and ZCode App do not accept trailing agent arguments.
|
||||
- Desktop App launches require that app to be installed and a graphical session to be available.
|
||||
- Start the CCR service before opening most profiles. A Grok CLI profile can start a temporary shared service automatically and stops it after the last managed session exits.
|
||||
|
||||
The desktop application installs a related command named `ccr-app`. Commands copied from desktop Agent Config cards use `ccr-app`; the npm package documented here installs `ccr`.
|
||||
|
||||
## Configuration And Runtime Files
|
||||
|
||||
| Platform | Config directory |
|
||||
| --- | --- |
|
||||
| macOS / Linux | `~/.claude-code-router` |
|
||||
| Windows | `%APPDATA%\claude-code-router` |
|
||||
|
||||
Important files include:
|
||||
|
||||
- `config.sqlite`: current application configuration.
|
||||
- `app-data/`: API key, usage, request-log, certificate, and other runtime databases/files.
|
||||
- `service.json`: state and private token for a detached CLI service.
|
||||
- `gateway.config.json`: generated gateway runtime configuration.
|
||||
- `profiles/` and `bin/`: isolated profile configuration and launch wrappers.
|
||||
|
||||
Do not edit or copy live SQLite files while CCR is writing to them. Use the UI export feature, or stop CCR before taking a filesystem backup.
|
||||
|
||||
## Environment And Security
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `CCR_WEB_HOST` | Default management listener when `--host` is omitted. |
|
||||
| `CCR_WEB_PORT` | Default management port when `--port` is omitted. |
|
||||
| `CCR_WEB_AUTH_TOKEN` | Fixes the management UI/RPC token instead of generating a random token for the process. |
|
||||
|
||||
The authenticated management URL contains `ccr_web_token` in its query string. Treat that URL like a password and avoid copying it into logs, tickets, or shell history. Bind to `127.0.0.1` unless remote access is intentional. For remote access, use a firewall or private network plus TLS at a trusted reverse proxy.
|
||||
|
||||
Do not expose the gateway without creating CCR client API keys. Upstream provider credentials are stored in CCR's local data directory, so protect that directory and its backups.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `ccr` is not found
|
||||
|
||||
Confirm Node.js is version 22 or later and that npm's global binary directory is on `PATH`:
|
||||
|
||||
```sh
|
||||
node --version
|
||||
npm prefix -g
|
||||
```
|
||||
|
||||
Open a new shell after installation if your shell caches command paths.
|
||||
|
||||
### The management URL changed ports
|
||||
|
||||
The requested port was already occupied. Use the URL printed by CCR, or stop the conflicting process and restart CCR.
|
||||
|
||||
### The UI opens but the gateway is unavailable
|
||||
|
||||
The management service can run without a usable gateway. Add a provider and model, create a client API key, then start or restart the gateway from **Server**. Check the foreground output from `ccr serve` when diagnosing startup errors.
|
||||
|
||||
### A profile cannot be found
|
||||
|
||||
Only enabled profiles are launchable. Names are matched without case and sanitized names are accepted, but ambiguous names require the profile ID. Re-save the profile if its generated launcher is missing.
|
||||
|
||||
### A background service uses old options
|
||||
|
||||
Stop and recreate it:
|
||||
|
||||
```sh
|
||||
ccr stop
|
||||
ccr start --host 127.0.0.1 --port 3458
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
The repository also includes a Docker image for gateway and browser-UI deployments. It does not install the npm `ccr` command into the runtime image. See the [Docker deployment guide](https://github.com/musistudio/claude-code-router/blob/main/docker/README.md).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
184
packages/cli/README_zh.md
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# Claude Code Router CLI
|
||||
|
||||
[English](README.md) · [完整文档](https://ccrdesk.top/) · [GitHub](https://github.com/musistudio/claude-code-router)
|
||||
|
||||
`@musistudio/claude-code-router` 是 Claude Code Router 的 Node.js 发行版。它通过 `ccr` 命令提供浏览器管理界面、本地模型网关和 Agent 配置启动能力,不需要安装 Electron。
|
||||
|
||||
CLI 适合开发机和无桌面的服务器。如果你需要系统托盘、桌面通知、应用自动更新或桌面端专属的浏览器集成,请安装桌面应用。
|
||||
|
||||
## 环境要求与安装
|
||||
|
||||
- Node.js 22 或更高版本
|
||||
- 一个可用的上游模型供应商,或 CCR 支持导入的本机 Agent 登录态
|
||||
- 使用配置启动命令时,本机需要已经安装对应 Agent
|
||||
|
||||
全局安装:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router
|
||||
ccr --help
|
||||
```
|
||||
|
||||
升级或卸载:
|
||||
|
||||
```sh
|
||||
npm install -g @musistudio/claude-code-router@latest
|
||||
npm uninstall -g @musistudio/claude-code-router
|
||||
```
|
||||
|
||||
卸载 npm 包不会删除 CCR 的本地配置和数据库。
|
||||
|
||||
## 快速开始
|
||||
|
||||
启动后台服务并打开管理界面:
|
||||
|
||||
```sh
|
||||
ccr ui
|
||||
```
|
||||
|
||||
然后按以下顺序配置:
|
||||
|
||||
1. 添加上游供应商和至少一个模型。
|
||||
2. 在 **API 密钥** 页面创建 CCR 客户端密钥。
|
||||
3. 如果默认供应商 / 模型不够用,再配置路由规则。
|
||||
4. 在 **服务** 页面确认网关已经运行。
|
||||
5. 把客户端指向界面显示的网关地址。网关默认是 `http://127.0.0.1:3456`,管理界面默认是 `http://127.0.0.1:3458`。
|
||||
|
||||
管理 Token 和 CCR 客户端 API Key 是两种不同凭据。管理 Token 保护浏览器 UI 和 RPC 接口,CCR 客户端 Key 用于验证发送到模型网关的请求。
|
||||
|
||||
## 服务命令
|
||||
|
||||
| 命令 | 行为 |
|
||||
| --- | --- |
|
||||
| `ccr start` | 在后台启动管理服务和网关,并打印带认证信息的管理 URL。 |
|
||||
| `ccr ui` | 复用或启动后台服务,然后打开管理界面。 |
|
||||
| `ccr stop` | 停止由 `ccr start` 或 `ccr ui` 启动的后台服务。 |
|
||||
| `ccr serve` | 在前台运行管理服务和网关;`ccr web` 是别名。 |
|
||||
| `ccr <配置>` | 按名称或 ID 打开一个已启用的 Agent 配置。 |
|
||||
|
||||
### `ccr start`
|
||||
|
||||
```text
|
||||
ccr start [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
- `--host <host>`:管理服务监听地址,默认 `127.0.0.1`。
|
||||
- `--port <port>`:管理服务首选端口,默认 `3458`。
|
||||
- `--open` / `--no-open`:是否打开浏览器。
|
||||
- `--gateway`:明确要求启动模型网关;这是默认行为。
|
||||
- `--no-gateway`:只启动管理服务,不启动模型网关。
|
||||
|
||||
### `ccr ui`
|
||||
|
||||
```text
|
||||
ccr ui [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
`ui` 默认会打开浏览器。在 SSH 或其他无桌面环境中使用 `--no-open`。
|
||||
|
||||
### `ccr serve`
|
||||
|
||||
```text
|
||||
ccr serve [--host <host>] [--port <port>] [--open|--no-open] [--gateway|--no-gateway]
|
||||
```
|
||||
|
||||
`serve` 会留在当前终端并处理 `SIGINT` / `SIGTERM`,适合交给进程管理器托管。`ccr stop` 只管理后台服务;前台服务需要在终端或进程管理器中停止。
|
||||
|
||||
如果首选管理端口已被占用,CCR 会继续尝试后续端口并打印实际 URL。`start` 或 `ui` 复用已运行服务时,新传入的 Host、Port 和 `--no-gateway` 不会重配该进程;要修改这些选项,请先运行 `ccr stop`。
|
||||
|
||||
## Agent 配置启动
|
||||
|
||||
先在 **Agent配置** 中创建并启用配置,然后按名称或 ID 启动:
|
||||
|
||||
```sh
|
||||
ccr "Codex - Work"
|
||||
ccr "Codex - Work" app
|
||||
ccr "Claude - Review" cli -- --model sonnet
|
||||
ccr profile-id -- --help
|
||||
```
|
||||
|
||||
完整语法:
|
||||
|
||||
```text
|
||||
ccr <配置名称或 ID> [cli|app] [-- <Agent 参数>]
|
||||
```
|
||||
|
||||
- `--cli` 和 `--app` 也可以代替位置形式的入口类型。
|
||||
- Agent 自己的参数建议统一放到 `--` 后,避免被识别为 CCR 参数。
|
||||
- 省略入口类型时,Claude Code、Codex、Grok CLI 默认使用 CLI,ZCode 默认使用 App。
|
||||
- Grok 只支持 CLI,ZCode 只支持 App。Claude App 和 ZCode App 不接受额外 Agent 参数。
|
||||
- 启动桌面 App 时,本机必须已安装对应应用,并且当前环境必须有图形会话。
|
||||
- 大多数配置需要先启动 CCR 服务。Grok CLI 配置可以自动启动一个临时共享服务,并在最后一个受管会话退出后停止。
|
||||
|
||||
桌面应用会安装一个相关命令 `ccr-app`。桌面 Agent配置卡片复制出来的命令使用 `ccr-app`;本文介绍的 npm 包安装的是 `ccr`。
|
||||
|
||||
## 配置与运行文件
|
||||
|
||||
| 平台 | 配置目录 |
|
||||
| --- | --- |
|
||||
| macOS / Linux | `~/.claude-code-router` |
|
||||
| Windows | `%APPDATA%\claude-code-router` |
|
||||
|
||||
重要文件包括:
|
||||
|
||||
- `config.sqlite`:当前应用配置。
|
||||
- `app-data/`:API Key、用量、请求日志、证书等运行数据库和文件。
|
||||
- `service.json`:后台 CLI 服务的状态和私有 Token。
|
||||
- `gateway.config.json`:生成的网关运行配置。
|
||||
- `profiles/` 和 `bin/`:隔离的 Agent 配置和启动包装器。
|
||||
|
||||
CCR 写入 SQLite 时不要直接编辑或复制活跃数据库。优先使用 UI 导出;要做文件级备份,请先停止 CCR。
|
||||
|
||||
## 环境变量与安全
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `CCR_WEB_HOST` | 省略 `--host` 时使用的管理服务监听地址。 |
|
||||
| `CCR_WEB_PORT` | 省略 `--port` 时使用的管理服务端口。 |
|
||||
| `CCR_WEB_AUTH_TOKEN` | 固定管理 UI / RPC 的认证 Token;不设置时每个进程会生成随机 Token。 |
|
||||
|
||||
认证后的管理 URL 会在查询参数中包含 `ccr_web_token`。请把这个 URL 当作密码,不要复制到日志、工单或公开的 Shell 历史中。除非确实需要远程访问,否则监听地址应保持 `127.0.0.1`。远程访问时,应同时使用防火墙或私网,并在可信反向代理上启用 TLS。
|
||||
|
||||
不要在未创建 CCR 客户端 API Key 的情况下暴露网关。上游供应商凭据保存在 CCR 本地数据目录中,因此也要保护该目录及其备份。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 找不到 `ccr` 命令
|
||||
|
||||
确认 Node.js 不低于 22,并检查 npm 全局可执行目录是否在 `PATH`:
|
||||
|
||||
```sh
|
||||
node --version
|
||||
npm prefix -g
|
||||
```
|
||||
|
||||
如果 Shell 缓存了命令路径,安装后请打开一个新终端。
|
||||
|
||||
### 管理 URL 的端口发生变化
|
||||
|
||||
首选端口已被占用。请使用 CCR 打印的实际 URL,或停止占用端口的进程后重启 CCR。
|
||||
|
||||
### UI 能打开,但网关不可用
|
||||
|
||||
管理服务可以在没有可用网关时单独运行。请添加供应商和模型、创建客户端 API Key,然后从 **服务** 页面启动或重启网关。排查启动错误时,可以使用 `ccr serve` 查看前台输出。
|
||||
|
||||
### 找不到 Agent 配置
|
||||
|
||||
只有已启用的配置才能启动。名称匹配不区分大小写,也接受清理后的名称;如果多个名称产生歧义,必须使用配置 ID。生成的启动器缺失时,请重新保存配置。
|
||||
|
||||
### 后台服务仍使用旧参数
|
||||
|
||||
停止并重新创建服务:
|
||||
|
||||
```sh
|
||||
ccr stop
|
||||
ccr start --host 127.0.0.1 --port 3458
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
仓库还提供面向模型网关和浏览器 UI 的 Docker 镜像。运行时镜像不会安装 npm 的 `ccr` 命令。请参阅 [Docker 部署文档](https://github.com/musistudio/claude-code-router/blob/main/docker/README.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
50
packages/cli/package.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "3.0.6",
|
||||
"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",
|
||||
"test": "node ../../build/test.mjs cli && node ../../build/run-tests.mjs cli",
|
||||
"test:integration": "node ../../build/test.mjs cli --scope integration && node ../../build/run-tests.mjs cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.9",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"undici": "^7.27.2"
|
||||
}
|
||||
}
|
||||
1041
packages/cli/src/cli.ts
Normal file
69
packages/cli/test/integration/cli-help.test.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const cliRuntime = path.join(process.cwd(), ".test-dist", "cli", "runtime", "cli.js");
|
||||
|
||||
test("built CLI exposes package-owned help", () => {
|
||||
const result = runCli(["--help"]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage:/);
|
||||
assert.match(result.stdout, /ccr serve/);
|
||||
assert.match(result.stdout, /ccr <profile-name-or-id>/);
|
||||
});
|
||||
|
||||
test("built CLI rejects invalid ports before starting services", () => {
|
||||
const result = runCli(["serve", "--port", "invalid", "--no-open", "--no-gateway"]);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Invalid port: invalid/);
|
||||
});
|
||||
|
||||
test("every service command exposes its package-owned command help", () => {
|
||||
for (const [command, usage] of [
|
||||
["start", /ccr start/],
|
||||
["ui", /ccr ui/],
|
||||
["serve", /ccr serve/],
|
||||
["stop", /ccr stop/]
|
||||
]) {
|
||||
const result = runCli([command, "--help"]);
|
||||
assert.equal(result.status, 0, `${command}: ${result.stderr}`);
|
||||
assert.match(result.stdout, usage, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("built CLI rejects missing, out-of-range, and unknown service options", () => {
|
||||
const cases = [
|
||||
[["serve", "--host"], /--host requires a value/],
|
||||
[["serve", "--host="], /--host requires a value/],
|
||||
[["serve", "--port", "0"], /Invalid port: 0/],
|
||||
[["serve", "--port=65536"], /Invalid port: 65536/],
|
||||
[["ui", "--unknown"], /Unknown web option: --unknown/],
|
||||
[["stop", "--force"], /Unknown stop option: --force/]
|
||||
];
|
||||
|
||||
for (const [args, error] of cases) {
|
||||
const result = runCli(args);
|
||||
assert.equal(result.status, 1, `${args.join(" ")}: ${result.stderr}`);
|
||||
assert.match(result.stderr, error, args.join(" "));
|
||||
}
|
||||
});
|
||||
|
||||
test("built CLI requires a profile reference when no command is supplied", () => {
|
||||
const result = runCli([]);
|
||||
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.match(`${result.stdout}${result.stderr}`, /ccr <profile-name-or-id>/);
|
||||
});
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [cliRuntime, ...args], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_CLI_COMMAND_NAME: "ccr"
|
||||
}
|
||||
});
|
||||
}
|
||||