Compare commits
119 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c27b7f912 | ||
|
|
0663c0463e | ||
|
|
78f4859a9b | ||
|
|
9c98e353e6 | ||
|
|
81fbae63a6 | ||
|
|
c0da81d6f3 | ||
|
|
5a3e1cb71f | ||
|
|
4c5af02aae | ||
|
|
4cf0c2468c | ||
|
|
15f67ab8d8 | ||
|
|
9163e306c0 | ||
|
|
088ec97e7b | ||
|
|
768baa8575 | ||
|
|
2cc208d857 | ||
|
|
ab6a2c1c50 | ||
|
|
113c9059b4 | ||
|
|
990bc0d958 | ||
|
|
fcd386c80c | ||
|
|
1aaee2ef60 | ||
|
|
92a16b0d4b | ||
|
|
2020e8f53c | ||
|
|
70f85864a6 | ||
|
|
8361805bdf | ||
|
|
f568968673 | ||
|
|
421cbd05b2 | ||
|
|
a65b8bb6f9 | ||
|
|
d8154e5357 | ||
|
|
cce7292877 | ||
|
|
980a9bae63 | ||
|
|
a8ccdf0403 | ||
|
|
499d25d200 | ||
|
|
04185dba99 | ||
|
|
d29f2176e8 | ||
|
|
bc872a29b3 | ||
|
|
c8cbc09b96 | ||
|
|
52a6371600 | ||
|
|
027826fd13 | ||
|
|
7216b9dcfa | ||
|
|
300096e39e | ||
|
|
07fd47ad65 | ||
|
|
4d7c1990ba | ||
|
|
8cf350e041 | ||
|
|
7faf59276a | ||
|
|
a4f5cc9172 | ||
|
|
ad0d9db795 | ||
|
|
1da1723530 | ||
|
|
d4ff6706f4 | ||
|
|
fce80a0c3a | ||
|
|
9af987d224 | ||
|
|
61108ed6e3 | ||
|
|
c409797bc5 | ||
|
|
964d1dbf23 | ||
|
|
5f92120114 | ||
|
|
528a8e0efa | ||
|
|
daf745ac3f | ||
|
|
ee44357a34 | ||
|
|
87a666df21 | ||
|
|
d41dae4e4a | ||
|
|
3983322519 | ||
|
|
2929903bdc | ||
|
|
7130e1e632 | ||
|
|
6bfb2e1901 | ||
|
|
46fa7e2181 | ||
|
|
2410530450 | ||
|
|
6118d52df5 | ||
|
|
43948b4c2a | ||
|
|
b6f8bb3c52 | ||
|
|
15fcaaecf5 | ||
|
|
0d51897d18 | ||
|
|
89d2d2394c | ||
|
|
d45e88387a | ||
|
|
f13e3b4598 | ||
|
|
7e44650008 | ||
|
|
5f7345fdc8 | ||
|
|
0f54b7d7a3 | ||
|
|
ec99720037 | ||
|
|
01b3c3bc3b | ||
|
|
ccd2694d43 | ||
|
|
109b83bd80 | ||
|
|
32db838b0b | ||
|
|
1adfbbf006 | ||
|
|
ffc6318dee | ||
|
|
44e4ff6cd2 | ||
|
|
1faa217a40 | ||
|
|
513fa11ead | ||
|
|
22dad1afb5 | ||
|
|
cbf1a72507 | ||
|
|
9f99eb2d84 | ||
|
|
371520bb4c | ||
|
|
5ad41aba2b | ||
|
|
2cbaaded8f | ||
|
|
054c4fcd94 | ||
|
|
f6c112148d | ||
|
|
56c1b666c6 | ||
|
|
11392a5b09 | ||
|
|
73bcf2e6e6 | ||
|
|
c5f7a3a392 | ||
|
|
451bdb572c | ||
|
|
98cd66f261 | ||
|
|
98375b7f4e | ||
|
|
83ea5d8b5b | ||
|
|
437cf29d3e | ||
|
|
f6174752b7 | ||
|
|
13517cc2a0 | ||
|
|
1a8d7cb610 | ||
|
|
20691a8215 | ||
|
|
a784eba837 | ||
|
|
608915c037 | ||
|
|
589f299319 | ||
|
|
7cfe854336 | ||
|
|
c583f69666 | ||
|
|
1534059fd6 | ||
|
|
81882616b9 | ||
|
|
8d306c562f | ||
|
|
c899b51b04 | ||
|
|
1b0c481376 | ||
|
|
ac39e702b3 | ||
|
|
673373d958 | ||
|
|
0549e3d7eb |
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
|
||||
4
.github/workflows/docs.yml
vendored
|
|
@ -29,8 +29,8 @@ jobs:
|
|||
- name: Build and upload docs
|
||||
uses: withastro/action@v6
|
||||
env:
|
||||
ASTRO_SITE: https://musistudio.github.io
|
||||
ASTRO_BASE: /claude-code-router
|
||||
ASTRO_SITE: https://ccrdesk.top
|
||||
ASTRO_BASE: /
|
||||
with:
|
||||
path: docs
|
||||
node-version: 24
|
||||
|
|
|
|||
106
.github/workflows/release.yml
vendored
|
|
@ -14,8 +14,24 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
macos:
|
||||
name: macOS
|
||||
runs-on: macos-14
|
||||
name: macOS (${{ matrix.display_name }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
arch_flag: --arm64
|
||||
runner: macos-14
|
||||
display_name: Apple Silicon
|
||||
release_label: Apple-Silicon
|
||||
artifact_name: macos-apple-silicon-arm64
|
||||
- arch: x64
|
||||
arch_flag: --x64
|
||||
runner: macos-15-intel
|
||||
display_name: Intel
|
||||
release_label: Intel
|
||||
artifact_name: macos-intel-x64
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -39,16 +55,92 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build and publish ad-hoc macOS artifacts
|
||||
- name: Build ad-hoc macOS artifacts
|
||||
run: |
|
||||
npm run build:assets
|
||||
npx electron-builder \
|
||||
--config build/electron-builder.local.cjs \
|
||||
--mac ${{ matrix.arch_flag }} \
|
||||
--publish never \
|
||||
-c.mac.artifactName='Claude-Code-Router_${version}-mac-${{ matrix.release_label }}-${arch}.${ext}'
|
||||
|
||||
- name: Upload macOS release files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: |
|
||||
release-local/Claude-Code-Router_*-mac-${{ matrix.release_label }}-${{ matrix.arch }}.*
|
||||
release-local/latest-mac.yml
|
||||
if-no-files-found: error
|
||||
|
||||
publish-macos:
|
||||
name: Publish macOS
|
||||
needs: macos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install metadata merge dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download macOS release files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: macos-*
|
||||
path: macos-release-artifacts
|
||||
|
||||
- name: Merge macOS update metadata
|
||||
run: node build/merge-macos-update-metadata.mjs latest-mac.yml macos-release-artifacts/*/latest-mac.yml
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm run build:assets
|
||||
npx electron-builder --config build/electron-builder.local.cjs --mac --publish always
|
||||
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \
|
||||
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes
|
||||
|
||||
- name: Upload macOS release assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
assets=(macos-release-artifacts/*/Claude-Code-Router_*-mac-*.*)
|
||||
if (( ${#assets[@]} == 0 )); then
|
||||
echo "No macOS release assets were found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_args=()
|
||||
for asset in "${assets[@]}"; do
|
||||
name="$(basename "$asset")"
|
||||
case "$name" in
|
||||
*Apple-Silicon-arm64.dmg) label="macOS Apple Silicon DMG (arm64)" ;;
|
||||
*Apple-Silicon-arm64.zip) label="macOS Apple Silicon ZIP (arm64)" ;;
|
||||
*Apple-Silicon-arm64.dmg.blockmap) label="macOS Apple Silicon DMG blockmap (arm64)" ;;
|
||||
*Apple-Silicon-arm64.zip.blockmap) label="macOS Apple Silicon ZIP blockmap (arm64)" ;;
|
||||
*Intel-x64.dmg) label="macOS Intel DMG (x64)" ;;
|
||||
*Intel-x64.zip) label="macOS Intel ZIP (x64)" ;;
|
||||
*Intel-x64.dmg.blockmap) label="macOS Intel DMG blockmap (x64)" ;;
|
||||
*Intel-x64.zip.blockmap) label="macOS Intel ZIP blockmap (x64)" ;;
|
||||
*) label="$name" ;;
|
||||
esac
|
||||
upload_args+=("${asset}#${label}")
|
||||
done
|
||||
upload_args+=("latest-mac.yml#macOS update metadata")
|
||||
|
||||
gh release upload "$GITHUB_REF_NAME" "${upload_args[@]}" --clobber
|
||||
|
||||
windows:
|
||||
name: Windows
|
||||
needs: macos
|
||||
needs: publish-macos
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
|
@ -82,7 +174,7 @@ jobs:
|
|||
|
||||
linux:
|
||||
name: Linux
|
||||
needs: macos
|
||||
needs: publish-macos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
|
|
|||
6
.gitignore
vendored
|
|
@ -13,4 +13,8 @@ release
|
|||
tmp
|
||||
release-local
|
||||
logs
|
||||
.opencat
|
||||
.opencat
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
.tmp
|
||||
75
Dockerfile
Normal file
|
|
@ -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"]
|
||||
21
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.
|
||||
527
README.md
|
|
@ -1,56 +1,89 @@
|
|||
<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>
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" alt="Claude Code Router Desktop screenshot" />
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr">API China</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>Thanks to Kimi for sponsoring this project!</strong> Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR already supports Kimi. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">cost-effective Coding Plan</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
|
||||
|
||||
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`.
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop screenshot" />
|
||||
</p>
|
||||
|
||||
## Why Use CCR
|
||||
|
||||
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
|
||||
- Route different workloads to different models, such as fast background work, reasoning tasks, long-context requests, image tasks, or web-search-capable models.
|
||||
- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Mistral, Z.AI, Bailian, and custom providers.
|
||||
- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand.
|
||||
- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers.
|
||||
- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs.
|
||||
- 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.
|
||||
|
||||
## Features
|
||||
|
||||
- **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**: set default, background, thinking, long-context, image, web-search, subagent, model-prefix, and conditional routing rules.
|
||||
- **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.
|
||||
- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards.
|
||||
- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available.
|
||||
- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites.
|
||||
- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles.
|
||||
- **Gateway compatibility**: translate supported client requests through the local CCR model gateway.
|
||||
- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture.
|
||||
- **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.
|
||||
- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model.
|
||||
|
||||
## Documentation
|
||||
|
||||
Read the full documentation at [ccrdesk.top](https://ccrdesk.top/).
|
||||
|
||||
## Download And Install
|
||||
|
||||
1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
|
||||
2. Download the package for your platform:
|
||||
- macOS: `Claude Code Router_<version>.dmg` or `.zip`
|
||||
- macOS Apple Silicon: `Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` or `.zip`
|
||||
- macOS Intel: `Claude-Code-Router_<version>-mac-Intel-x64.dmg` or `.zip`
|
||||
- Windows: `Claude Code Router_<version>.exe`
|
||||
- Linux: `Claude Code Router_<version>.AppImage`
|
||||
3. Install and launch **Claude Code Router**.
|
||||
4. On first launch, CCR creates its local configuration:
|
||||
- macOS/Linux: `~/.claude-code-router/config.json`
|
||||
- Windows: `%APPDATA%\Claude Code Router\config.json`
|
||||
4. On first launch, CCR creates its local configuration database:
|
||||
- macOS/Linux: `~/.claude-code-router/config.sqlite`
|
||||
- Windows: `%APPDATA%\Claude Code Router\config.sqlite`
|
||||
|
||||
CCR starts two local services when the gateway is enabled:
|
||||
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
|
||||
|
||||
- CCR wrapper gateway: `http://127.0.0.1:3456`
|
||||
- Core gateway runtime: `http://127.0.0.1:3457`
|
||||
After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -58,226 +91,286 @@ 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 or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
|
||||
|
||||
### 2. Configure routing
|
||||
|
||||
Open **Routing** and select which provider/model should handle the default route. Then fill optional routes for background work, thinking requests, long-context requests, image tasks, and web search if you want different models for those scenarios.
|
||||
Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior.
|
||||
|
||||
Use **Add Routing Rule** when you need more control, such as model-prefix routing, subagent routing, request conditions, or 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://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
|
||||
|
||||
### 4. Connect your agent tool
|
||||
|
||||
Open **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, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR.
|
||||
|
||||
### 5. Monitor and adjust
|
||||
|
||||
Use **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. Aliases: `baseUrl`, `api_base_url`, `url`, `endpoint`.
|
||||
- `api_key`: optional provider API key. Aliases: `apiKey`, `apikey`, `key`, `token`.
|
||||
- `models`: comma-separated or newline-separated model list. You can also repeat `model=...`.
|
||||
- `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 tray window for quick token 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?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai icon" />
|
||||
<br />
|
||||
<strong>code0.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.claudeapi.com?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi icon" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Community Sponsors</h4>
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="220">@Simon Leischnig</td>
|
||||
<td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
|
||||
<td align="center" width="220">@*o</td>
|
||||
<td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
|
||||
<td align="center" width="220">@*说</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@K*g</td>
|
||||
<td align="center" width="220">@R*R</td>
|
||||
<td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220">@*划</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
|
||||
<td align="center" width="220">@S*r</td>
|
||||
<td align="center" width="220">@*晖</td>
|
||||
<td align="center" width="220">@*敏</td>
|
||||
<td align="center" width="220">@Z*z</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*然</td>
|
||||
<td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
|
||||
<td align="center" width="220">@*应</td>
|
||||
<td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*飞</td>
|
||||
<td align="center" width="220">@董*</td>
|
||||
<td align="center" width="220">@*汀</td>
|
||||
<td align="center" width="220">@*涯</td>
|
||||
<td align="center" width="220">@*:-)</td>
|
||||
<td align="center" width="220">@**磊</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*琢</td>
|
||||
<td align="center" width="220">@*成</td>
|
||||
<td align="center" width="220">@Z*o</td>
|
||||
<td align="center" width="220">@*琨</td>
|
||||
<td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
|
||||
<td align="center" width="220">@*_</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@Z*m</td>
|
||||
<td align="center" width="220">@*鑫</td>
|
||||
<td align="center" width="220">@c*y</td>
|
||||
<td align="center" width="220">@*昕</td>
|
||||
<td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
|
||||
<td align="center" width="220">@b*g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*亿</td>
|
||||
<td align="center" width="220">@*辉</td>
|
||||
<td align="center" width="220">@JACK</td>
|
||||
<td align="center" width="220">@*光</td>
|
||||
<td align="center" width="220">@W*l</td>
|
||||
<td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
|
||||
<td align="center" width="220">@二吉吉</td>
|
||||
<td align="center" width="220">@a*g</td>
|
||||
<td align="center" width="220">@*林</td>
|
||||
<td align="center" width="220">@*咸</td>
|
||||
<td align="center" width="220">@*明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@S*y</td>
|
||||
<td align="center" width="220">@f*o</td>
|
||||
<td align="center" width="220">@*智</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@r*c</td>
|
||||
<td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*军</td>
|
||||
<td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220">@zcutlip</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@*.</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@*政</td>
|
||||
<td align="center" width="220">@*铭</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*叶</td>
|
||||
<td align="center" width="220">@七*o</td>
|
||||
<td align="center" width="220">@*青</td>
|
||||
<td align="center" width="220">@**晨</td>
|
||||
<td align="center" width="220">@*远</td>
|
||||
<td align="center" width="220">@*霄</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@**吉</td>
|
||||
<td align="center" width="220">@**飞</td>
|
||||
<td align="center" width="220">@**驰</td>
|
||||
<td align="center" width="220">@x*g</td>
|
||||
<td align="center" width="220">@**东</td>
|
||||
<td align="center" width="220">@*落</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@哆*k</td>
|
||||
<td align="center" width="220">@*涛</td>
|
||||
<td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
|
||||
<td align="center" width="220">@*呢</td>
|
||||
<td align="center" width="220">@d*u</td>
|
||||
<td align="center" width="220">@crizcraig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">s*s</td>
|
||||
<td align="center" width="220">*火</td>
|
||||
<td align="center" width="220">*勤</td>
|
||||
<td align="center" width="220">**锟</td>
|
||||
<td align="center" width="220">*涛</td>
|
||||
<td align="center" width="220">**明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">*知</td>
|
||||
<td align="center" width="220">*语</td>
|
||||
<td align="center" width="220">*瓜</td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>If your name is masked, please contact me via my homepage email to update it with your GitHub username.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
|
|
|||
526
README_zh.md
|
|
@ -1,56 +1,88 @@
|
|||
<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>
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" alt="Claude Code Router Desktop 项目截图" />
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.kimi.com/code?aff=ccr">
|
||||
<img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API,或了解高性价比 <a href="https://www.kimi.com/code?aff=ccr">Coding Plan</a> 套餐。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://127.0.0.1:3456`。
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop 项目截图" />
|
||||
</p>
|
||||
|
||||
## 为什么使用 CCR
|
||||
|
||||
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
|
||||
- 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。
|
||||
- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Mistral、Z.AI、百炼以及自定义 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 扩展网关能力。
|
||||
|
||||
## 功能和特性
|
||||
|
||||
- **桌面控制台**:启动或停止本地网关,查看用量,配置托盘窗口和运行时设置。
|
||||
- **Provider 管理**:添加预设或自定义端点,检测连通性,管理凭据,并在可用时查看账号余额。
|
||||
- **路由规则**:配置默认、后台、thinking、长上下文、图片、Web Search、Subagent、模型前缀和条件路由。
|
||||
- **Agent Profiles**:为 Claude Code、Codex 和 ZCode 配置指向 CCR 网关的 Profile。
|
||||
- **网关兼容层**:通过本地 CCR wrapper 和 core gateway runtime 转换客户端请求。
|
||||
- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。
|
||||
- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。
|
||||
- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。
|
||||
- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。
|
||||
- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。
|
||||
- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。
|
||||
- **插件系统**:安装或加载 wrapper 插件,包括 Claude Design、Cursor Proxy 这类集成路由。
|
||||
- **虚拟模型**:为客户端暴露模型别名或组合模型配置,适配固定模型名场景。
|
||||
- **Provider Deeplink**:通过 `ccr://provider?...` 链接导入 Provider 配置,写入前会弹出确认。
|
||||
- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档见 [ccrdesk.top](https://ccrdesk.top/)。
|
||||
|
||||
## 下载和安装
|
||||
|
||||
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
|
||||
2. 按系统下载对应安装包:
|
||||
- macOS:`Claude Code Router_<version>.dmg` 或 `.zip`
|
||||
- macOS Apple 芯片:`Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
|
||||
- macOS Intel 芯片:`Claude-Code-Router_<version>-mac-Intel-x64.dmg` 或 `.zip`
|
||||
- Windows:`Claude Code Router_<version>.exe`
|
||||
- Linux:`Claude Code Router_<version>.AppImage`
|
||||
3. 安装并启动 **Claude Code Router**。
|
||||
4. 首次启动后,CCR 会创建本地配置:
|
||||
- macOS/Linux:`~/.claude-code-router/config.json`
|
||||
- Windows:`%APPDATA%\Claude Code Router\config.json`
|
||||
4. 首次启动后,CCR 会创建本地配置数据库:
|
||||
- macOS/Linux:`~/.claude-code-router/config.sqlite`
|
||||
- Windows:`%APPDATA%\Claude Code Router\config.sqlite`
|
||||
|
||||
启用网关后,CCR 会启动两个本地服务:
|
||||
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
|
||||
|
||||
- CCR wrapper gateway:`http://127.0.0.1:3456`
|
||||
- Core gateway runtime:`http://127.0.0.1:3457`
|
||||
从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
|
@ -58,226 +90,286 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
|
|||
|
||||
### 1. 添加 Provider
|
||||
|
||||
打开 **Providers**,点击 **Add Provider**,选择内置预设或创建自定义 Provider。按表单填写 Provider 名称、端点、协议、API Key 和模型列表。可用时先运行连通性检测,然后保存 Provider。
|
||||
打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
|
||||
|
||||
### 2. 设置路由
|
||||
|
||||
打开 **Routing**,先选择默认路由要使用的 provider/model。然后根据需要设置后台任务、Thinking、长上下文、图片任务和 Web Search 等场景的专用模型。
|
||||
打开 **路由**,添加条件规则,配置请求改写和失败降级。
|
||||
|
||||
如果需要更细粒度控制,使用 **Add Routing Rule** 添加模型前缀、Subagent、请求条件或 fallback 规则。
|
||||
如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
|
||||
|
||||
### 3. 启动网关
|
||||
|
||||
打开 **Server**,点击 **Start** 启动本地网关。如果希望每次打开桌面应用时自动启动网关,可以启用 auto start。
|
||||
打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
|
||||
|
||||
### 4. 连接 Agent 工具
|
||||
|
||||
打开 **Profiles**,选择要使用的客户端。通过表单配置 Claude Code、Codex 或 ZCode Profile,选择目标模型并应用配置。对于 App 类型的 Profile,可以使用页面里的操作按钮通过 CCR 打开目标应用。
|
||||
打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 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。别名:`baseUrl`、`api_base_url`、`url`、`endpoint`。
|
||||
- `api_key`:可选 Provider API Key。别名:`apiKey`、`apikey`、`key`、`token`。
|
||||
- `models`:逗号或换行分隔的模型列表,也可以重复传入 `model=...`。
|
||||
- `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?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai 图标" />
|
||||
<br />
|
||||
<strong>code0.ai</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://www.claudeapi.com?source=claudecoderouter">
|
||||
<img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi 图标" />
|
||||
<br />
|
||||
<strong>claudeapi</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>社区赞助者</h4>
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="220">@Simon Leischnig</td>
|
||||
<td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
|
||||
<td align="center" width="220">@*o</td>
|
||||
<td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
|
||||
<td align="center" width="220">@*说</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@K*g</td>
|
||||
<td align="center" width="220">@R*R</td>
|
||||
<td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220">@*划</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
|
||||
<td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
|
||||
<td align="center" width="220">@S*r</td>
|
||||
<td align="center" width="220">@*晖</td>
|
||||
<td align="center" width="220">@*敏</td>
|
||||
<td align="center" width="220">@Z*z</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*然</td>
|
||||
<td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
|
||||
<td align="center" width="220">@*苗</td>
|
||||
<td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
|
||||
<td align="center" width="220">@*应</td>
|
||||
<td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*飞</td>
|
||||
<td align="center" width="220">@董*</td>
|
||||
<td align="center" width="220">@*汀</td>
|
||||
<td align="center" width="220">@*涯</td>
|
||||
<td align="center" width="220">@*:-)</td>
|
||||
<td align="center" width="220">@**磊</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*琢</td>
|
||||
<td align="center" width="220">@*成</td>
|
||||
<td align="center" width="220">@Z*o</td>
|
||||
<td align="center" width="220">@*琨</td>
|
||||
<td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
|
||||
<td align="center" width="220">@*_</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@Z*m</td>
|
||||
<td align="center" width="220">@*鑫</td>
|
||||
<td align="center" width="220">@c*y</td>
|
||||
<td align="center" width="220">@*昕</td>
|
||||
<td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
|
||||
<td align="center" width="220">@b*g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*亿</td>
|
||||
<td align="center" width="220">@*辉</td>
|
||||
<td align="center" width="220">@JACK</td>
|
||||
<td align="center" width="220">@*光</td>
|
||||
<td align="center" width="220">@W*l</td>
|
||||
<td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
|
||||
<td align="center" width="220">@二吉吉</td>
|
||||
<td align="center" width="220">@a*g</td>
|
||||
<td align="center" width="220">@*林</td>
|
||||
<td align="center" width="220">@*咸</td>
|
||||
<td align="center" width="220">@*明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@S*y</td>
|
||||
<td align="center" width="220">@f*o</td>
|
||||
<td align="center" width="220">@*智</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@r*c</td>
|
||||
<td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*军</td>
|
||||
<td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
|
||||
<td align="center" width="220">@*王</td>
|
||||
<td align="center" width="220">@zcutlip</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
|
||||
<td align="center" width="220">@*更</td>
|
||||
<td align="center" width="220">@*.</td>
|
||||
<td align="center" width="220">@F*t</td>
|
||||
<td align="center" width="220">@*政</td>
|
||||
<td align="center" width="220">@*铭</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@*叶</td>
|
||||
<td align="center" width="220">@七*o</td>
|
||||
<td align="center" width="220">@*青</td>
|
||||
<td align="center" width="220">@**晨</td>
|
||||
<td align="center" width="220">@*远</td>
|
||||
<td align="center" width="220">@*霄</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@**吉</td>
|
||||
<td align="center" width="220">@**飞</td>
|
||||
<td align="center" width="220">@**驰</td>
|
||||
<td align="center" width="220">@x*g</td>
|
||||
<td align="center" width="220">@**东</td>
|
||||
<td align="center" width="220">@*落</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">@哆*k</td>
|
||||
<td align="center" width="220">@*涛</td>
|
||||
<td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
|
||||
<td align="center" width="220">@*呢</td>
|
||||
<td align="center" width="220">@d*u</td>
|
||||
<td align="center" width="220">@crizcraig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">s*s</td>
|
||||
<td align="center" width="220">*火</td>
|
||||
<td align="center" width="220">*勤</td>
|
||||
<td align="center" width="220">**锟</td>
|
||||
<td align="center" width="220">*涛</td>
|
||||
<td align="center" width="220">**明</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="220">*知</td>
|
||||
<td align="center" width="220">*语</td>
|
||||
<td align="center" width="220">*瓜</td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
<td align="center" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。</sub>
|
||||
|
||||
</div>
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [MIT License](LICENSE) 发布。
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, 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";
|
||||
|
||||
|
|
@ -15,7 +15,10 @@ await Promise.all([
|
|||
buildBrowserRenderer({ mode }),
|
||||
buildRenderer({ mode }),
|
||||
buildTrayRenderer({ mode }),
|
||||
buildWebClientBridge({ mode }),
|
||||
buildStyles({ minify: mode === "production" })
|
||||
]);
|
||||
|
||||
console.log(`Built Electron app assets in ${mode} mode.`);
|
||||
syncUiRendererToRuntimeDists();
|
||||
|
||||
console.log(`Built monorepo package assets in ${mode} mode.`);
|
||||
|
|
|
|||
288
build/dev.mjs
|
|
@ -5,26 +5,30 @@ import { spawn } from "node:child_process";
|
|||
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
binPath,
|
||||
buildStyles,
|
||||
cleanDist,
|
||||
browserRendererHtmlInput,
|
||||
cliSourceRoot,
|
||||
coreSourceRoot,
|
||||
copyAppAssets,
|
||||
copyBrowserRendererHtml,
|
||||
copyCliRuntimeToElectronDist,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
createBrowserRendererBuildOptions,
|
||||
createCliBuildOptions,
|
||||
createMainBuildOptions,
|
||||
createRendererBuildOptions,
|
||||
createTrayRendererBuildOptions,
|
||||
cssInput,
|
||||
cssOutput,
|
||||
createWebClientBridgeBuildOptions,
|
||||
appAssetsInput,
|
||||
modelCatalogInput,
|
||||
projectRoot,
|
||||
rendererRoot,
|
||||
rendererHtmlInput,
|
||||
syncUiRendererToRuntimeDists,
|
||||
trayRendererHtmlInput,
|
||||
watchPlugin
|
||||
} from "./esbuild.config.mjs";
|
||||
|
|
@ -35,13 +39,46 @@ 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,
|
||||
main: false,
|
||||
renderer: false,
|
||||
tray: false
|
||||
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}`);
|
||||
|
|
@ -53,6 +90,7 @@ function relativePath(file) {
|
|||
|
||||
function readyState() {
|
||||
return Object.entries(ready)
|
||||
.filter(([name]) => activeReadyNames.has(name))
|
||||
.map(([name, value]) => `${name}:${value ? "ready" : "pending"}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -159,15 +197,71 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on
|
|||
}
|
||||
|
||||
onChange();
|
||||
scheduleRestart(reason);
|
||||
if (enabled.electron && options?.restart !== false) {
|
||||
scheduleRestart(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStyleBuild(reason) {
|
||||
queuedStyleBuildReason = reason;
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
styleBuildTimer = setTimeout(() => {
|
||||
styleBuildTimer = null;
|
||||
void rebuildStyles(queuedStyleBuildReason ?? reason);
|
||||
}, styleBuildDelayMs);
|
||||
}
|
||||
|
||||
async function rebuildStyles(reason) {
|
||||
if (styleBuildInFlight) {
|
||||
queuedStyleBuildReason = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
styleBuildInFlight = true;
|
||||
queuedStyleBuildReason = null;
|
||||
try {
|
||||
logDev(`rebuilding styles: ${reason}`);
|
||||
await buildStyles({ minify: false });
|
||||
syncUiRendererToRuntimeDists();
|
||||
if (enabled.electron) {
|
||||
scheduleRestart(`styles rebuilt: ${reason}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logDev(`style rebuild failed: ${message}`);
|
||||
} finally {
|
||||
styleBuildInFlight = false;
|
||||
if (queuedStyleBuildReason) {
|
||||
const queuedReason = queuedStyleBuildReason;
|
||||
queuedStyleBuildReason = null;
|
||||
scheduleStyleBuild(queuedReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pollStyleWatchRoots() {
|
||||
for (const styleWatchRoot of styleWatchRoots) {
|
||||
const label = `styles ${relativePath(styleWatchRoot)}`;
|
||||
const signature = contentSignature(styleWatchRoot);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
if (previousSignature === signature.key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${label}; ${signature.summary}; content=changed`);
|
||||
scheduleStyleBuild(label);
|
||||
}
|
||||
}
|
||||
|
||||
function markReady(name, reason = `${name} esbuild completed`) {
|
||||
if (name === "browser" || name === "main" || name === "renderer" || name === "tray") {
|
||||
if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
|
||||
ready[name] = true;
|
||||
}
|
||||
logDev(`build ready: ${reason}; ${readyState()}`);
|
||||
if (ready.browser && ready.main && ready.renderer && ready.tray) {
|
||||
if (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) {
|
||||
scheduleRestart(reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -217,100 +311,153 @@ function restartElectron() {
|
|||
});
|
||||
}
|
||||
|
||||
logDev("starting dev build");
|
||||
logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`);
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
if (enabled.electron) {
|
||||
copyAppAssets();
|
||||
}
|
||||
if (enabled.cli || enabled.electron) {
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
}
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
copyTrayRendererHtml();
|
||||
await buildStyles({ minify: false });
|
||||
|
||||
const tailwindProcess = spawn(binPath("tailwindcss"), ["-i", cssInput, "-o", cssOutput, "--watch"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32"
|
||||
});
|
||||
logDev(`Tailwind watcher started pid=${tailwindProcess.pid ?? "unknown"} input=${relativePath(cssInput)} output=${relativePath(cssOutput)}`);
|
||||
tailwindProcess.on("exit", (code, signal) => {
|
||||
logDev(`Tailwind watcher exited code=${code ?? "null"} signal=${signal ?? "null"}`);
|
||||
});
|
||||
syncUiRendererToRuntimeDists();
|
||||
|
||||
rememberWatchSignature("home html", rendererHtmlInput);
|
||||
rememberWatchSignature("browser html", browserRendererHtmlInput);
|
||||
rememberWatchSignature("tray html", trayRendererHtmlInput);
|
||||
rememberWatchSignature("app assets", appAssetsInput);
|
||||
if (existsSync(modelCatalogInput)) {
|
||||
for (const styleWatchRoot of styleWatchRoots) {
|
||||
rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot);
|
||||
}
|
||||
if (enabled.electron) {
|
||||
rememberWatchSignature("app assets", appAssetsInput);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
rememberWatchSignature("model catalog", modelCatalogInput);
|
||||
}
|
||||
|
||||
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml);
|
||||
handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, copyBrowserRendererHtml);
|
||||
handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyBrowserRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, copyTrayRendererHtml);
|
||||
handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyTrayRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
|
||||
});
|
||||
const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs);
|
||||
|
||||
const modelCatalogWatcher = existsSync(modelCatalogInput)
|
||||
const appAssetsWatcher = enabled.electron
|
||||
? watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
|
||||
})
|
||||
: { close: () => undefined };
|
||||
|
||||
const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput)
|
||||
? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog);
|
||||
})
|
||||
: { close: () => undefined };
|
||||
|
||||
const mainContext = await esbuild.context(
|
||||
createMainBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [watchPlugin("main", (name) => markReady(name))]
|
||||
})
|
||||
);
|
||||
const contexts = [];
|
||||
|
||||
const 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);
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([mainContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch()]);
|
||||
await Promise.all(contexts.map((context) => context.watch()));
|
||||
logDev("watchers are active");
|
||||
|
||||
async function shutdown() {
|
||||
|
|
@ -322,13 +469,16 @@ async function shutdown() {
|
|||
if (electronProcess) {
|
||||
electronProcess.kill();
|
||||
}
|
||||
tailwindProcess.kill();
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
htmlWatcher.close();
|
||||
browserHtmlWatcher.close();
|
||||
trayHtmlWatcher.close();
|
||||
clearInterval(stylePoller);
|
||||
appAssetsWatcher.close();
|
||||
modelCatalogWatcher.close();
|
||||
await Promise.all([mainContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose()]);
|
||||
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");
|
||||
|
|
@ -26,6 +66,19 @@ export const trayRendererHtmlInput = path.join(rendererRoot, "pages", "tray", "i
|
|||
export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", "index.html");
|
||||
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");
|
||||
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
|
||||
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
|
||||
const lightweightMcpBundleMaxBytes = 128 * 1024;
|
||||
const forbiddenLightweightMcpInputs = [
|
||||
{ prefix: "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" }
|
||||
];
|
||||
const forbiddenLightweightMcpExternalImports = new Set(["better-sqlite3", "electron"]);
|
||||
|
||||
const nodeExternals = [
|
||||
"electron",
|
||||
|
|
@ -35,15 +88,28 @@ const nodeExternals = [
|
|||
];
|
||||
|
||||
export function cleanDist() {
|
||||
rmSync(distDir, { force: true, recursive: true });
|
||||
rmSync(legacyDistDir, { force: true, recursive: true });
|
||||
rmSync(cliDistDir, { force: true, recursive: true });
|
||||
rmSync(coreDistDir, { force: true, recursive: true });
|
||||
rmSync(electronDistDir, { force: true, recursive: true });
|
||||
rmSync(uiDistDir, { force: true, recursive: true });
|
||||
ensureDist();
|
||||
}
|
||||
|
||||
export function ensureDist() {
|
||||
mkdirSync(mainOutDir, { recursive: true });
|
||||
mkdirSync(cliMainOutDir, { recursive: true });
|
||||
mkdirSync(coreMainOutDir, { recursive: true });
|
||||
mkdirSync(electronMainOutDir, { recursive: true });
|
||||
mkdirSync(electronBotGatewaySdkDistDir, { recursive: true });
|
||||
mkdirSync(electronBotGatewaySdkBinDir, { recursive: true });
|
||||
mkdirSync(appAssetsDir, { recursive: true });
|
||||
mkdirSync(marketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(cliMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(coreMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(electronMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(rendererAssetsDir, { recursive: true });
|
||||
for (const outputDir of runtimeRendererOutDirs) {
|
||||
mkdirSync(path.join(outputDir, "assets"), { recursive: true });
|
||||
}
|
||||
mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true });
|
||||
|
|
@ -59,12 +125,16 @@ export function copyAppAssets() {
|
|||
export function copyModelCatalog() {
|
||||
ensureDist();
|
||||
if (existsSync(modelCatalogInput)) {
|
||||
cpSync(modelCatalogInput, modelCatalogOutput);
|
||||
cpSync(modelCatalogInput, cliModelCatalogOutput);
|
||||
cpSync(modelCatalogInput, coreModelCatalogOutput);
|
||||
cpSync(modelCatalogInput, electronModelCatalogOutput);
|
||||
}
|
||||
}
|
||||
|
||||
export function copyRendererHtml() {
|
||||
copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js");
|
||||
copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", {
|
||||
beforeModuleScriptTags: [' <script src="../../assets/web-client-bridge.js"></script>']
|
||||
});
|
||||
}
|
||||
|
||||
export function copyTrayRendererHtml() {
|
||||
|
|
@ -79,14 +149,25 @@ export function copyMarketplacePlugins() {
|
|||
ensureDist();
|
||||
for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) {
|
||||
const source = path.join(projectRoot, "examples", "plugins", filename);
|
||||
const target = path.join(marketplacePluginsDir, filename);
|
||||
if (existsSync(source)) {
|
||||
cpSync(source, target);
|
||||
cpSync(source, path.join(cliMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(coreMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(electronMarketplacePluginsDir, filename));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyRendererPageHtml(input, output, scriptName) {
|
||||
export function syncUiRendererToRuntimeDists() {
|
||||
ensureDist();
|
||||
for (const outputDir of runtimeRendererOutDirs) {
|
||||
rmSync(outputDir, { force: true, recursive: true });
|
||||
if (existsSync(rendererOutDir)) {
|
||||
cpSync(rendererOutDir, outputDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyRendererPageHtml(input, output, scriptName, options = {}) {
|
||||
ensureDist();
|
||||
const source = readFileSync(input, "utf8");
|
||||
const styleTag = ' <link rel="stylesheet" href="../../assets/main.css" />';
|
||||
|
|
@ -95,6 +176,12 @@ function copyRendererPageHtml(input, output, scriptName) {
|
|||
? source.replace(' <script type="module" src="./main.tsx"></script>', scriptTag)
|
||||
: source.replace("</body>", `${scriptTag}\n </body>`);
|
||||
|
||||
for (const extraScriptTag of options.beforeModuleScriptTags ?? []) {
|
||||
if (!hasScriptTag(html, extraScriptTag)) {
|
||||
html = html.replace(scriptTag, `${extraScriptTag}\n${scriptTag}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!html.includes('href="../../assets/main.css"')) {
|
||||
html = html.replace("</head>", `${styleTag}\n </head>`);
|
||||
}
|
||||
|
|
@ -102,26 +189,96 @@ 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", "main", "cli.ts"),
|
||||
path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(projectRoot, "src", "main", "preload.ts")
|
||||
path.join(electronSourceRoot, "main", "main.ts"),
|
||||
path.join(electronSourceRoot, "main", "browser-preload.ts"),
|
||||
gatewayRuntimeInput,
|
||||
path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
|
||||
electronUndiciProxyAgentInput,
|
||||
path.join(electronSourceRoot, "main", "preload.ts")
|
||||
],
|
||||
external: nodeExternals,
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
metafile: true,
|
||||
minify: mode === "production",
|
||||
outdir: mainOutDir,
|
||||
outdir: electronMainOutDir,
|
||||
platform: "node",
|
||||
plugins,
|
||||
plugins: [packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
}
|
||||
|
||||
export function createCliBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: [
|
||||
path.join(cliSourceRoot, "cli.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
],
|
||||
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outdir: cliMainOutDir,
|
||||
platform: "node",
|
||||
plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
}
|
||||
|
||||
export function createCoreServerBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: [
|
||||
path.join(coreSourceRoot, "entrypoints", "server.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
],
|
||||
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outdir: coreMainOutDir,
|
||||
platform: "node",
|
||||
plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
|
|
@ -152,7 +309,7 @@ export function createRendererBuildOptions({ mode = "production", plugins = [] }
|
|||
minify: mode === "production",
|
||||
outfile: path.join(rendererAssetsDir, "main.js"),
|
||||
platform: "browser",
|
||||
plugins: [rendererAliasPlugin(), ...plugins],
|
||||
plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
|
||||
publicPath: "../../assets",
|
||||
sourcemap: mode !== "production",
|
||||
target: "chrome120"
|
||||
|
|
@ -175,6 +332,44 @@ export function createBrowserRendererBuildOptions({ mode = "production", plugins
|
|||
};
|
||||
}
|
||||
|
||||
export function createWebClientBridgeBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryPoints: [path.join(uiSourceRoot, "web-client-bridge.ts")],
|
||||
format: "iife",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outfile: webClientBridgeOutput,
|
||||
platform: "browser",
|
||||
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`,
|
||||
|
|
@ -189,7 +384,38 @@ export function watchPlugin(name, onEnd) {
|
|||
}
|
||||
|
||||
export async function buildMain(options = {}) {
|
||||
await esbuild.build(createMainBuildOptions(options));
|
||||
const [mainBuildResult] = await Promise.all([
|
||||
esbuild.build(createMainBuildOptions(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 = {}) {
|
||||
|
|
@ -204,6 +430,18 @@ export async function buildBrowserRenderer(options = {}) {
|
|||
await esbuild.build(createBrowserRendererBuildOptions(options));
|
||||
}
|
||||
|
||||
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];
|
||||
|
|
@ -249,20 +487,109 @@ 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",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^electron$/ }, () => {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary."
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function validateLightweightMcpBundles(metafile) {
|
||||
if (!metafile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputsByName = new Map(
|
||||
Object.entries(metafile.outputs).map(([outputPath, output]) => [path.basename(outputPath), { output, outputPath }])
|
||||
);
|
||||
|
||||
for (const bundleName of lightweightMcpBundleNames) {
|
||||
const entry = outputsByName.get(bundleName);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
if (entry.output.bytes > lightweightMcpBundleMaxBytes) {
|
||||
violations.push(`bundle size ${entry.output.bytes} bytes exceeds ${lightweightMcpBundleMaxBytes} bytes`);
|
||||
}
|
||||
|
||||
for (const inputPath of Object.keys(entry.output.inputs ?? {})) {
|
||||
const normalizedInput = normalizeBuildPath(inputPath);
|
||||
for (const rule of forbiddenLightweightMcpInputs) {
|
||||
if (normalizedInput.startsWith(rule.prefix)) {
|
||||
violations.push(`${normalizedInput} (${rule.reason})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const imported of entry.output.imports ?? []) {
|
||||
if (imported.external && forbiddenLightweightMcpExternalImports.has(imported.path)) {
|
||||
violations.push(`${imported.path} (external native/runtime dependency is not allowed)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
throw new Error([
|
||||
`Lightweight MCP bundle ${bundleName} crossed its dependency boundary.`,
|
||||
...violations.map((violation) => `- ${violation}`)
|
||||
].join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBuildPath(value) {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -271,5 +598,5 @@ function resolveRendererImport(importPath) {
|
|||
}
|
||||
}
|
||||
|
||||
return basePath;
|
||||
return packageBasePath;
|
||||
}
|
||||
|
|
|
|||
78
build/merge-macos-update-metadata.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const [outputFile, ...inputFiles] = process.argv.slice(2);
|
||||
|
||||
if (!outputFile || inputFiles.length < 2) {
|
||||
console.error("Usage: node build/merge-macos-update-metadata.mjs <output> <latest-mac.yml> <latest-mac.yml> [...]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updateInfos = inputFiles.map((file) => {
|
||||
const info = yaml.load(readFileSync(file, "utf8"));
|
||||
if (!info || typeof info !== "object") {
|
||||
throw new Error(`${file} is not a valid update metadata object`);
|
||||
}
|
||||
if (!Array.isArray(info.files) || info.files.length === 0) {
|
||||
throw new Error(`${file} does not contain any update files`);
|
||||
}
|
||||
return { file, info };
|
||||
});
|
||||
|
||||
const version = updateInfos[0].info.version;
|
||||
if (!version || updateInfos.some(({ info }) => info.version !== version)) {
|
||||
throw new Error("All macOS update metadata files must have the same version");
|
||||
}
|
||||
|
||||
const files = uniqueByUrl(
|
||||
updateInfos
|
||||
.flatMap(({ info }) => info.files)
|
||||
.filter((file) => file && typeof file.url === "string")
|
||||
.sort(compareMacUpdateFile)
|
||||
);
|
||||
|
||||
const defaultZip = files.find((file) => file.url.endsWith(".zip") && file.url.includes("arm64")) ?? files.find((file) => file.url.endsWith(".zip"));
|
||||
if (!defaultZip?.sha512) {
|
||||
throw new Error("Merged macOS update metadata must include at least one ZIP with sha512");
|
||||
}
|
||||
|
||||
const releaseDate = updateInfos
|
||||
.map(({ info }) => info.releaseDate)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1);
|
||||
|
||||
const { files: _files, path: _path, sha512: _sha512, releaseDate: _releaseDate, ...baseInfo } = updateInfos[0].info;
|
||||
const mergedInfo = {
|
||||
...baseInfo,
|
||||
version,
|
||||
files,
|
||||
path: defaultZip.url,
|
||||
sha512: defaultZip.sha512,
|
||||
...(releaseDate ? { releaseDate } : {})
|
||||
};
|
||||
|
||||
writeFileSync(outputFile, yaml.dump(mergedInfo, { lineWidth: 120, noRefs: true }), "utf8");
|
||||
console.log(`Wrote ${path.relative(process.cwd(), outputFile)} with ${files.length} macOS artifacts.`);
|
||||
|
||||
function uniqueByUrl(files) {
|
||||
const seen = new Set();
|
||||
return files.filter((file) => {
|
||||
if (seen.has(file.url)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(file.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function compareMacUpdateFile(left, right) {
|
||||
return fileRank(left) - fileRank(right) || left.url.localeCompare(right.url);
|
||||
}
|
||||
|
||||
function fileRank(file) {
|
||||
const isZip = file.url.endsWith(".zip") ? 0 : 10;
|
||||
const isAppleSilicon = file.url.includes("arm64") ? 0 : 1;
|
||||
return isZip + isAppleSilicon;
|
||||
}
|
||||
67
build/run-tests.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import electron from "electron";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, 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;
|
||||
|
||||
for (const suite of suites) {
|
||||
if (!testSuites.includes(suite)) {
|
||||
cleanup();
|
||||
throw new Error(`Unknown test suite: ${suite}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const suite of suites) {
|
||||
await runSuite(suite);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function runSuite(suite) {
|
||||
console.log(`\nRunning ${suite} tests...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(electron, ["--test", `dist/tests/${suite}/*.test.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"
|
||||
});
|
||||
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
rmSync(testHome, { force: true, recursive: true });
|
||||
}
|
||||
145
build/test.mjs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import esbuild from "esbuild";
|
||||
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
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, "packages", "ui", "src");
|
||||
const cliSourceRoot = path.join(projectRoot, "packages", "cli", "src");
|
||||
const coreSourceRoot = path.join(projectRoot, "packages", "core", "src");
|
||||
const testSuites = [
|
||||
{ name: "main", testDir: path.join(projectRoot, "tests", "main") },
|
||||
{ name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") }
|
||||
];
|
||||
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));
|
||||
|
||||
if (unknownSuites.length > 0) {
|
||||
throw new Error(`Unknown test suite: ${unknownSuites.join(", ")}`);
|
||||
}
|
||||
|
||||
rmSync(testsOutDir, { force: true, recursive: true });
|
||||
|
||||
for (const suite of selectedSuites) {
|
||||
const entryPoints = [
|
||||
...findTestFiles(suite.testDir),
|
||||
...runtimeEntryPointsForSuite(suite.name)
|
||||
];
|
||||
if (entryPoints.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints,
|
||||
external: [
|
||||
"better-sqlite3",
|
||||
"electron"
|
||||
],
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
loader: {
|
||||
".gif": "file",
|
||||
".ico": "file",
|
||||
".jpg": "file",
|
||||
".jpeg": "file",
|
||||
".png": "file",
|
||||
".svg": "file",
|
||||
".webp": "file"
|
||||
},
|
||||
logLevel: "info",
|
||||
outdir: path.join(testsOutDir, suite.name),
|
||||
platform: "node",
|
||||
plugins: [rendererAliasPlugin(), packageAliasPlugin()],
|
||||
target: "node22"
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeEntryPointsForSuite(suiteName) {
|
||||
if (suiteName !== "main") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
];
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const file = path.join(dir, name);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...findTestFiles(file));
|
||||
} else if (/\.(test|spec)\.(mjs|ts|tsx)$/.test(name)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function rendererAliasPlugin() {
|
||||
return {
|
||||
name: "renderer-test-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, (args) => {
|
||||
return { path: resolveRendererImport(args.path.slice(2)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function packageAliasPlugin() {
|
||||
return {
|
||||
name: "test-package-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@ccr\/cli\// }, (args) => {
|
||||
return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) };
|
||||
});
|
||||
build.onResolve({ filter: /^@ccr\/core\// }, (args) => {
|
||||
return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRendererImport(importPath) {
|
||||
return resolvePackageImport(rendererRoot, importPath);
|
||||
}
|
||||
|
||||
function resolvePackageImport(rootDir, importPath) {
|
||||
const basePath = path.resolve(rootDir, importPath);
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.jsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.json`,
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
path.join(basePath, "index.js")
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return basePath;
|
||||
}
|
||||
233
build/verify-packaged-app.cjs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const betterSqliteNativeRelativePath = path.join(
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"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;
|
||||
const arch = normalizeArch(context?.arch);
|
||||
const appOutDir = context?.appOutDir;
|
||||
|
||||
if (!platform || !appOutDir) {
|
||||
throw new Error("Packaged app verification did not receive electron-builder platform 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");
|
||||
|
||||
const nativeInfo = inspectNativeModule(nativeModule);
|
||||
if (nativeInfo.platform !== platform) {
|
||||
throw new Error(
|
||||
`Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` +
|
||||
`but electron-builder is packaging ${platform}/${arch}. Rebuild native dependencies for the target platform before packaging.`
|
||||
);
|
||||
}
|
||||
|
||||
if (arch && !nativeArchMatches(nativeInfo, arch)) {
|
||||
throw new Error(
|
||||
`Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` +
|
||||
`but electron-builder is packaging ${platform}/${arch}. Run npm run rebuild:sqlite3 on the target platform before packaging.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const appBundle = fs.readdirSync(appOutDir)
|
||||
.find((entry) => entry.endsWith(".app") && fs.statSync(path.join(appOutDir, entry)).isDirectory());
|
||||
if (!appBundle) {
|
||||
throw new Error(`Could not find a .app bundle in ${appOutDir}`);
|
||||
}
|
||||
return path.join(appOutDir, appBundle, "Contents", "Resources");
|
||||
}
|
||||
|
||||
function assertFile(file, label) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
} catch {
|
||||
throw new Error(`${label} is missing: ${file}`);
|
||||
}
|
||||
|
||||
if (!stat.isFile() || stat.size === 0) {
|
||||
throw new Error(`${label} is empty or not a file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function inspectNativeModule(file) {
|
||||
const buffer = fs.readFileSync(file);
|
||||
if (buffer.length < 32) {
|
||||
throw new Error(`Native module is too small to identify: ${file}`);
|
||||
}
|
||||
|
||||
if (buffer[0] === 0x4d && buffer[1] === 0x5a) {
|
||||
return inspectPe(buffer, file);
|
||||
}
|
||||
if (buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46) {
|
||||
return inspectElf(buffer);
|
||||
}
|
||||
|
||||
const magicLe = buffer.readUInt32LE(0);
|
||||
const magicBe = buffer.readUInt32BE(0);
|
||||
if (magicLe === 0xfeedface || magicLe === 0xfeedfacf || magicLe === 0xcefaedfe || magicLe === 0xcffaedfe) {
|
||||
return inspectMachO(buffer, magicLe);
|
||||
}
|
||||
if (magicBe === 0xcafebabe || magicBe === 0xcafebabf) {
|
||||
return inspectFatMachO(buffer, magicBe);
|
||||
}
|
||||
|
||||
throw new Error(`Native module has an unknown binary format: ${file}`);
|
||||
}
|
||||
|
||||
function inspectPe(buffer, file) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peOffset + 6 >= buffer.length || buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") {
|
||||
throw new Error(`Native module has an invalid PE header: ${file}`);
|
||||
}
|
||||
const machine = buffer.readUInt16LE(peOffset + 4);
|
||||
return {
|
||||
arch: peMachineArch(machine),
|
||||
platform: "win32"
|
||||
};
|
||||
}
|
||||
|
||||
function inspectElf(buffer) {
|
||||
const machine = buffer.readUInt16LE(18);
|
||||
return {
|
||||
arch: elfMachineArch(machine),
|
||||
platform: "linux"
|
||||
};
|
||||
}
|
||||
|
||||
function inspectMachO(buffer, magicLe) {
|
||||
const bigEndian = magicLe === 0xcefaedfe || magicLe === 0xcffaedfe;
|
||||
const cpuType = bigEndian ? buffer.readUInt32BE(4) : buffer.readUInt32LE(4);
|
||||
return {
|
||||
arch: machCpuArch(cpuType),
|
||||
platform: "darwin"
|
||||
};
|
||||
}
|
||||
|
||||
function inspectFatMachO(buffer, magicBe) {
|
||||
const archs = [];
|
||||
const count = buffer.readUInt32BE(4);
|
||||
const entrySize = magicBe === 0xcafebabf ? 32 : 20;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = 8 + index * entrySize;
|
||||
if (offset + 8 > buffer.length) {
|
||||
break;
|
||||
}
|
||||
archs.push(machCpuArch(buffer.readUInt32BE(offset)));
|
||||
}
|
||||
return {
|
||||
arch: "universal",
|
||||
archs,
|
||||
platform: "darwin"
|
||||
};
|
||||
}
|
||||
|
||||
function peMachineArch(machine) {
|
||||
if (machine === 0x8664) {
|
||||
return "x64";
|
||||
}
|
||||
if (machine === 0xaa64) {
|
||||
return "arm64";
|
||||
}
|
||||
if (machine === 0x014c) {
|
||||
return "ia32";
|
||||
}
|
||||
return `unknown-pe-${machine.toString(16)}`;
|
||||
}
|
||||
|
||||
function elfMachineArch(machine) {
|
||||
if (machine === 0x3e) {
|
||||
return "x64";
|
||||
}
|
||||
if (machine === 0xb7) {
|
||||
return "arm64";
|
||||
}
|
||||
if (machine === 0x03) {
|
||||
return "ia32";
|
||||
}
|
||||
if (machine === 0x28) {
|
||||
return "armv7l";
|
||||
}
|
||||
return `unknown-elf-${machine.toString(16)}`;
|
||||
}
|
||||
|
||||
function machCpuArch(cpuType) {
|
||||
if (cpuType === 0x01000007) {
|
||||
return "x64";
|
||||
}
|
||||
if (cpuType === 0x0100000c) {
|
||||
return "arm64";
|
||||
}
|
||||
if (cpuType === 0x00000007) {
|
||||
return "ia32";
|
||||
}
|
||||
return `unknown-macho-${cpuType.toString(16)}`;
|
||||
}
|
||||
|
||||
function normalizeArch(arch) {
|
||||
if (typeof arch === "string") {
|
||||
return arch;
|
||||
}
|
||||
const electronBuilderArchNames = new Map([
|
||||
[0, "ia32"],
|
||||
[1, "x64"],
|
||||
[2, "armv7l"],
|
||||
[3, "arm64"],
|
||||
[4, "universal"]
|
||||
]);
|
||||
return electronBuilderArchNames.get(arch);
|
||||
}
|
||||
|
||||
function nativeArchMatches(info, arch) {
|
||||
if (info.arch === arch) {
|
||||
return true;
|
||||
}
|
||||
if (info.arch === "universal") {
|
||||
return arch === "universal" || Boolean(info.archs?.includes(arch));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatNativeInfo(info) {
|
||||
return info.arch === "universal" && info.archs?.length
|
||||
? `${info.platform}/${info.arch} (${info.archs.join(", ")})`
|
||||
: `${info.platform}/${info.arch}`;
|
||||
}
|
||||
63
cdn/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# CCR CDN
|
||||
|
||||
This directory contains the static assets for the embeddable CCR provider import button.
|
||||
|
||||
Default Cloudflare Pages project:
|
||||
|
||||
```text
|
||||
claude-code-router-cdn
|
||||
```
|
||||
|
||||
Default CDN URLs:
|
||||
|
||||
```text
|
||||
https://cdn.ccrdesk.top/ccr-provider-buttons.js
|
||||
https://cdn.ccrdesk.top/ccr-icon.png
|
||||
```
|
||||
|
||||
Cloudflare Pages custom domain:
|
||||
|
||||
```text
|
||||
cdn.ccrdesk.top
|
||||
```
|
||||
|
||||
The Pages custom domain must have a CNAME record pointing to the Pages project:
|
||||
|
||||
```text
|
||||
cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev
|
||||
```
|
||||
|
||||
The documentation site uses `ccrdesk.top` through GitHub Pages. Configure DNS like this:
|
||||
|
||||
```text
|
||||
ccrdesk.top A 185.199.108.153
|
||||
ccrdesk.top A 185.199.109.153
|
||||
ccrdesk.top A 185.199.110.153
|
||||
ccrdesk.top A 185.199.111.153
|
||||
ccrdesk.top AAAA 2606:50c0:8000::153
|
||||
ccrdesk.top AAAA 2606:50c0:8001::153
|
||||
ccrdesk.top AAAA 2606:50c0:8002::153
|
||||
ccrdesk.top AAAA 2606:50c0:8003::153
|
||||
cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev
|
||||
```
|
||||
|
||||
If `ccrdesk.top` is delegated to Cloudflare, use these Cloudflare nameservers at the registrar:
|
||||
|
||||
```text
|
||||
benedict.ns.cloudflare.com
|
||||
evangeline.ns.cloudflare.com
|
||||
```
|
||||
|
||||
Deploy manually:
|
||||
|
||||
```sh
|
||||
cd cdn
|
||||
npx wrangler pages deploy public --project-name=claude-code-router-cdn
|
||||
```
|
||||
|
||||
The GitHub Actions workflow requires these repository secrets:
|
||||
|
||||
```text
|
||||
CLOUDFLARE_API_TOKEN
|
||||
CLOUDFLARE_ACCOUNT_ID
|
||||
```
|
||||
11
cdn/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "claude-code-router-cdn",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"deploy": "wrangler pages deploy public --project-name=claude-code-router-cdn",
|
||||
"deploy:preview": "wrangler pages deploy public --project-name=claude-code-router-cdn --branch=preview"
|
||||
}
|
||||
}
|
||||
10
cdn/public/_headers
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
Access-Control-Allow-Origin: *
|
||||
X-Content-Type-Options: nosniff
|
||||
|
||||
/ccr-provider-buttons.js
|
||||
Cache-Control: public, max-age=300, s-maxage=86400
|
||||
Content-Type: application/javascript; charset=utf-8
|
||||
|
||||
/ccr-icon.png
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
BIN
cdn/public/ccr-icon.png
Normal file
|
After Width: | Height: | Size: 761 KiB |
302
cdn/public/ccr-provider-buttons.js
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
(function () {
|
||||
"use strict";
|
||||
|
||||
var VERSION = "0.2.0";
|
||||
var STYLE_ID = "ccr-provider-buttons-style";
|
||||
var OFFICIAL_CCR_ICON_URL = "https://cdn.ccrdesk.top/ccr-icon.png";
|
||||
|
||||
var LINK_PARAMS = [
|
||||
"name",
|
||||
"base_url",
|
||||
"api_key",
|
||||
"protocol",
|
||||
"icon",
|
||||
"source",
|
||||
"payload",
|
||||
"usage_url",
|
||||
"usage_method",
|
||||
"usage_headers",
|
||||
"usage_body",
|
||||
"balance",
|
||||
"balance_unit",
|
||||
"subscription",
|
||||
"subscription_limit",
|
||||
"subscription_reset",
|
||||
"subscription_unit",
|
||||
"subscription_window"
|
||||
];
|
||||
var ELEMENT_ATTRIBUTES = LINK_PARAMS.concat(["manifest", "models", "fetch_usage", "description", "color", "color_2", "color_3"]);
|
||||
|
||||
var CSS = [
|
||||
".ccr-provider-button{--ccrpb-brand:#17201c;--ccrpb-brand-2:#36624d;--ccrpb-brand-3:#dff8eb;position:relative;isolation:isolate;display:grid;grid-template-columns:132px minmax(0,1fr);align-items:center;min-height:86px;width:100%;max-width:420px;padding:13px 16px 13px 12px;overflow:hidden;border:1px solid color-mix(in srgb,var(--ccrpb-brand-2) 42%,transparent);border-radius:12px;background:radial-gradient(circle at 14% 22%,color-mix(in srgb,var(--ccrpb-brand-3) 34%,transparent) 0,transparent 30%),radial-gradient(circle at 96% 96%,color-mix(in srgb,var(--ccrpb-brand-2) 44%,transparent) 0,transparent 46%),linear-gradient(135deg,var(--ccrpb-brand),color-mix(in srgb,var(--ccrpb-brand-2) 88%,#111 12%));box-shadow:0 10px 26px color-mix(in srgb,var(--ccrpb-brand) 22%,transparent),inset 0 1px 0 rgba(255,255,255,.16);box-sizing:border-box;color:#fff;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;text-decoration:none;transform:translateY(0);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}",
|
||||
".ccr-provider-button::before{content:\"\";position:absolute;inset:-40% auto -40% -70%;z-index:-1;width:58%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.26),transparent);transform:skewX(-18deg);transition:transform 620ms ease;}",
|
||||
".ccr-provider-button::after{content:\"\";position:absolute;right:-38px;bottom:-52px;z-index:-2;width:132px;height:132px;border-radius:999px;background:radial-gradient(circle,color-mix(in srgb,var(--ccrpb-brand-3) 38%,transparent),transparent 68%);opacity:.82;transform:scale(.96);transition:opacity 220ms ease,transform 220ms ease;}",
|
||||
".ccr-provider-button:hover,.ccr-provider-button:focus-visible{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 68%,rgba(255,255,255,.34));box-shadow:0 16px 34px color-mix(in srgb,var(--ccrpb-brand) 30%,transparent),0 0 0 1px color-mix(in srgb,var(--ccrpb-brand-3) 26%,transparent),inset 0 1px 0 rgba(255,255,255,.22);color:#fff;text-decoration:none;transform:translateY(-3px);}",
|
||||
".ccr-provider-button:hover::before,.ccr-provider-button:focus-visible::before{transform:translateX(320%) skewX(-18deg);}",
|
||||
".ccr-provider-button:hover::after,.ccr-provider-button:focus-visible::after{opacity:1;transform:scale(1.08);}",
|
||||
".ccr-provider-button:active{transform:translateY(-1px) scale(.992);}",
|
||||
".ccrpb-provider-icon{position:relative;z-index:1;grid-column:1;grid-row:1;justify-self:start;display:grid;place-items:center;width:48px;height:48px;overflow:hidden;border:1px solid rgba(255,255,255,.46);border-radius:12px;background:linear-gradient(135deg,color-mix(in srgb,var(--ccrpb-brand-3) 34%,rgba(255,255,255,.9)),color-mix(in srgb,var(--ccrpb-brand-2) 64%,#111 36%));box-shadow:0 8px 18px rgba(0,0,0,.14),inset 0 1px 0 rgba(255,255,255,.42);color:#fff;font-size:15px;font-weight:820;letter-spacing:0;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.34);transform:rotate(0) scale(1);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}",
|
||||
".ccrpb-provider-icon img{box-sizing:border-box;display:block;width:100%;height:100%;background:color-mix(in srgb,#fff 84%,var(--ccrpb-brand-3));object-fit:contain;filter:drop-shadow(0 1px 1px rgba(0,0,0,.12));}",
|
||||
".ccrpb-provider-mark{display:block;max-width:42px;overflow:hidden;text-align:center;text-overflow:ellipsis;white-space:nowrap;}",
|
||||
".ccrpb-flow{display:inline-flex;grid-column:1;grid-row:1;align-items:center;justify-self:end;justify-content:flex-end;gap:8px;width:72px;height:48px;padding:0;border:0;background:transparent;color:rgba(255,255,255,.86);font-size:14px;font-weight:760;line-height:1;box-shadow:none;transform:translateX(0);transition:transform 220ms ease;}",
|
||||
".ccrpb-ccr-icon{box-sizing:border-box;display:grid;flex:0 0 auto;place-items:center;width:48px;height:48px;overflow:hidden;border:0;border-radius:12px;background:transparent;box-shadow:none;transition:transform 220ms ease;}",
|
||||
".ccrpb-ccr-icon img{box-sizing:border-box;display:block;width:48px;height:48px;border-radius:12px;}",
|
||||
".ccrpb-arrow{display:inline-block;order:-1;line-height:1;transform:translateX(0);transition:transform 220ms ease;}",
|
||||
".ccrpb-copy{display:grid;min-width:0;gap:2px;padding:0 0 0 6px;}",
|
||||
".ccrpb-name{display:block;overflow:hidden;color:#fff;font-size:15px;font-weight:780;line-height:1.25;text-overflow:ellipsis;white-space:nowrap;}",
|
||||
".ccrpb-description{display:block;overflow:hidden;color:rgba(255,255,255,.74);font-size:11px;font-weight:650;letter-spacing:0;line-height:1.35;text-overflow:ellipsis;white-space:nowrap;}",
|
||||
".ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 74%,rgba(255,255,255,.54));box-shadow:0 12px 24px rgba(0,0,0,.18),0 0 0 4px color-mix(in srgb,var(--ccrpb-brand-3) 16%,transparent),inset 0 1px 0 rgba(255,255,255,.52);transform:rotate(-3deg) scale(1.06);}",
|
||||
".ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow{transform:translateX(3px);}",
|
||||
".ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon{transform:scale(1.04);}",
|
||||
".ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:translateX(2px);}",
|
||||
"@media (max-width:520px){.ccr-provider-button{grid-template-columns:118px minmax(0,1fr);min-height:92px;padding:12px}.ccrpb-provider-icon,.ccrpb-provider-icon img,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{width:44px;height:44px}.ccrpb-provider-icon img,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{border-radius:11px}.ccrpb-flow{width:66px;justify-self:end}}",
|
||||
"@media (prefers-reduced-motion:reduce){.ccr-provider-button,.ccr-provider-button::before,.ccr-provider-button::after,.ccrpb-provider-icon,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-arrow{transition:none}.ccr-provider-button:hover,.ccr-provider-button:focus-visible,.ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon,.ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow,.ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon,.ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:none}.ccr-provider-button::before{display:none}}"
|
||||
].join("");
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
var style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function isPresent(value) {
|
||||
return value !== undefined && value !== null && String(value).trim() !== "";
|
||||
}
|
||||
|
||||
function stringifyValue(value) {
|
||||
if (value === true) {
|
||||
return "1";
|
||||
}
|
||||
if (value === false) {
|
||||
return "0";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function appendParam(params, key, value) {
|
||||
if (!isPresent(value)) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(function (item) {
|
||||
if (isPresent(item)) {
|
||||
params.append(key, stringifyValue(item));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
params.append(key, stringifyValue(value));
|
||||
}
|
||||
|
||||
function providerUrl(options) {
|
||||
options = options || {};
|
||||
var params = new URLSearchParams();
|
||||
if (isPresent(options.manifest)) {
|
||||
params.set("manifest", stringifyValue(options.manifest));
|
||||
return "ccr://provider?" + params.toString();
|
||||
}
|
||||
LINK_PARAMS.forEach(function (key) {
|
||||
appendParam(params, key, options[key]);
|
||||
});
|
||||
appendParam(params, "models", options.models);
|
||||
appendParam(params, "fetch_usage", options.fetch_usage);
|
||||
return "ccr://provider?" + params.toString();
|
||||
}
|
||||
|
||||
function createImage(src, alt) {
|
||||
var img = document.createElement("img");
|
||||
img.src = src;
|
||||
img.alt = alt || "";
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
return img;
|
||||
}
|
||||
|
||||
function providerInitials(name) {
|
||||
var words = String(name || "")
|
||||
.replace(/[^A-Za-z0-9\u4e00-\u9fff]+/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (words.length === 0) {
|
||||
return "AI";
|
||||
}
|
||||
if (words.length === 1) {
|
||||
return words[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return (words[0].slice(0, 1) + words[1].slice(0, 1)).toUpperCase();
|
||||
}
|
||||
|
||||
function protocolDescription(options) {
|
||||
if (options.description) {
|
||||
return options.description;
|
||||
}
|
||||
if (options.manifest) {
|
||||
return "Remote provider manifest";
|
||||
}
|
||||
if (options.protocol) {
|
||||
return options.protocol.replace(/_/g, " ");
|
||||
}
|
||||
return "Import provider to CCR";
|
||||
}
|
||||
|
||||
function providerName(options) {
|
||||
return options.name || (options.manifest ? "Provider manifest" : "Provider");
|
||||
}
|
||||
|
||||
function providerId(options) {
|
||||
return providerName(options).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "provider";
|
||||
}
|
||||
|
||||
function applyColors(element, options) {
|
||||
element.style.setProperty("--ccrpb-brand", options.color || "#17201c");
|
||||
element.style.setProperty("--ccrpb-brand-2", options.color_2 || "#36624d");
|
||||
element.style.setProperty("--ccrpb-brand-3", options.color_3 || "#dff8eb");
|
||||
}
|
||||
|
||||
function createProviderIcon(options) {
|
||||
var shell = document.createElement("span");
|
||||
shell.className = "ccrpb-provider-icon";
|
||||
if (options.icon) {
|
||||
shell.appendChild(createImage(options.icon, providerName(options)));
|
||||
return shell;
|
||||
}
|
||||
var mark = document.createElement("span");
|
||||
mark.className = "ccrpb-provider-mark";
|
||||
mark.textContent = providerInitials(providerName(options));
|
||||
shell.appendChild(mark);
|
||||
return shell;
|
||||
}
|
||||
|
||||
function createButton(options) {
|
||||
options = options || {};
|
||||
var button = document.createElement("a");
|
||||
button.className = "ccr-provider-button ccr-provider-" + providerId(options);
|
||||
button.href = providerUrl(options);
|
||||
button.dataset.ccrProviderName = providerName(options);
|
||||
button.setAttribute("aria-label", "Import " + providerName(options) + " provider to CCR");
|
||||
applyColors(button, options);
|
||||
|
||||
var providerIcon = createProviderIcon(options);
|
||||
|
||||
var flow = document.createElement("span");
|
||||
flow.className = "ccrpb-flow";
|
||||
|
||||
var ccrIconShell = document.createElement("span");
|
||||
ccrIconShell.className = "ccrpb-ccr-icon";
|
||||
ccrIconShell.appendChild(createImage(OFFICIAL_CCR_ICON_URL, "CCR"));
|
||||
|
||||
var arrow = document.createElement("span");
|
||||
arrow.className = "ccrpb-arrow";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
arrow.textContent = "→";
|
||||
|
||||
flow.appendChild(ccrIconShell);
|
||||
flow.appendChild(arrow);
|
||||
|
||||
var copy = document.createElement("span");
|
||||
copy.className = "ccrpb-copy";
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "ccrpb-name";
|
||||
name.textContent = providerName(options);
|
||||
|
||||
var description = document.createElement("span");
|
||||
description.className = "ccrpb-description";
|
||||
description.textContent = protocolDescription(options);
|
||||
|
||||
copy.appendChild(name);
|
||||
copy.appendChild(description);
|
||||
|
||||
button.appendChild(providerIcon);
|
||||
button.appendChild(flow);
|
||||
button.appendChild(copy);
|
||||
return button;
|
||||
}
|
||||
|
||||
function render(target, options) {
|
||||
options = options || {};
|
||||
var root = typeof target === "string" ? document.querySelector(target) : target;
|
||||
if (!root) {
|
||||
throw new Error("CCRProviderButtons.render target not found");
|
||||
}
|
||||
injectStyles();
|
||||
var button = createButton(options);
|
||||
if (options.clear !== false) {
|
||||
root.textContent = "";
|
||||
}
|
||||
root.appendChild(button);
|
||||
return {
|
||||
element: button,
|
||||
href: button.href,
|
||||
destroy: function () {
|
||||
if (button.parentNode) {
|
||||
button.parentNode.removeChild(button);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function attributeOptions(element) {
|
||||
var options = {};
|
||||
ELEMENT_ATTRIBUTES.forEach(function (name) {
|
||||
if (!element.hasAttribute(name)) {
|
||||
return;
|
||||
}
|
||||
var value = element.getAttribute(name);
|
||||
options[name] = name === "fetch_usage" && value === "" ? true : value;
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
function defineElement(name) {
|
||||
if (!("customElements" in window) || customElements.get(name)) {
|
||||
return;
|
||||
}
|
||||
customElements.define(name, class extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ELEMENT_ATTRIBUTES;
|
||||
}
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
attributeChangedCallback() {
|
||||
if (this.isConnected) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
render(this, attributeOptions(this));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function defineElements() {
|
||||
defineElement("ccr-provider-button");
|
||||
defineElement("ccr-provider-buttons");
|
||||
}
|
||||
|
||||
window.CCRProviderButtons = {
|
||||
version: VERSION,
|
||||
render: render,
|
||||
renderButton: render,
|
||||
createButton: function (options) {
|
||||
injectStyles();
|
||||
return createButton(options || {});
|
||||
},
|
||||
createProviderUrl: providerUrl
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", defineElements, { once: true });
|
||||
} else {
|
||||
defineElements();
|
||||
}
|
||||
}());
|
||||
3
cdn/wrangler.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name = "claude-code-router-cdn"
|
||||
compatibility_date = "2026-06-28"
|
||||
pages_build_output_dir = "./public"
|
||||
|
|
@ -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:
|
||||
91
docker/README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Docker deployment
|
||||
|
||||
This image runs the core server package with PM2 and serves the built UI package
|
||||
through Nginx. Nginx is the only published entrypoint: it serves the UI, proxies
|
||||
management API calls to the internal core server, and proxies gateway API calls
|
||||
to the internal gateway listener.
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- Web UI: <http://localhost:3458>
|
||||
- Gateway endpoint: <http://localhost:3458>
|
||||
|
||||
`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image
|
||||
runs separate container-private listeners for management RPC, API gateway
|
||||
routing, and the core gateway runtime. They are implementation details and are
|
||||
not published or configured by the default Compose file.
|
||||
|
||||
To use a different host port, change the Compose port mapping and keep the
|
||||
public router endpoint in sync:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "8088:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
|
||||
```
|
||||
|
||||
The container stores config and SQLite databases under `/data`, backed by the
|
||||
`ccr-data` volume in `docker-compose.yml`.
|
||||
|
||||
On a fresh data volume, the Web UI starts immediately. The gateway endpoint is
|
||||
available through the same Nginx entrypoint, but the gateway only starts after at
|
||||
least one provider and model are configured.
|
||||
|
||||
## Image scripts
|
||||
|
||||
```sh
|
||||
npm run docker:build
|
||||
npm run docker:run
|
||||
```
|
||||
|
||||
## Smoke test
|
||||
|
||||
```sh
|
||||
npm run test:docker
|
||||
```
|
||||
|
||||
The smoke test builds the image, starts an isolated temporary container with a
|
||||
special-character `CCR_WEB_AUTH_TOKEN`, verifies that only the Nginx port is
|
||||
published, checks UI and RPC authentication, confirms legacy Docker config is
|
||||
migrated to the public Nginx router endpoint, and removes its temporary
|
||||
container and volume. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an already
|
||||
built image.
|
||||
|
||||
The Dockerfile uses `node:22-bookworm` for build and native SQLite dependency
|
||||
installation, then copies the production dependencies into a smaller
|
||||
`node:22-bookworm-slim` runtime image. To use different base images:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE=node:22-bookworm \
|
||||
--build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
|
||||
-t claude-code-router:local .
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`,
|
||||
and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not
|
||||
`http://127.0.0.1:3458`.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. |
|
||||
| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. |
|
||||
| `CCR_DATA_DIR` | `/data` | Container data root. |
|
||||
| `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. |
|
||||
| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. |
|
||||
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Sync existing Docker config to the Nginx public router endpoint on startup. |
|
||||
|
||||
The first-run bootstrap writes a minimal legacy `config.json` only when neither
|
||||
`config.json` nor `config.sqlite` exists in the mounted data directory. Once the
|
||||
UI saves settings into SQLite, existing persisted configuration takes priority.
|
||||
207
docker/entrypoint.sh
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -18,10 +18,10 @@ The local development server runs from this `docs` directory.
|
|||
Docs are deployed from `.github/workflows/docs.yml` on pushes to `main` that change `docs/**` or the workflow itself. The default public URL is:
|
||||
|
||||
```text
|
||||
https://musistudio.github.io/claude-code-router/
|
||||
https://ccrdesk.top/
|
||||
```
|
||||
|
||||
The Astro build reads `ASTRO_SITE` and `ASTRO_BASE`, defaulting to `https://musistudio.github.io` and `/claude-code-router`.
|
||||
The Astro build reads `ASTRO_SITE` and `ASTRO_BASE`, defaulting to `https://ccrdesk.top` and `/`.
|
||||
|
||||
## Content
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineConfig } from "astro/config";
|
||||
|
||||
const site = process.env.ASTRO_SITE ?? "https://musistudio.github.io";
|
||||
const base = process.env.ASTRO_BASE ?? "/claude-code-router";
|
||||
const site = process.env.ASTRO_SITE ?? "https://ccrdesk.top";
|
||||
const base = process.env.ASTRO_BASE ?? "/";
|
||||
|
||||
export default defineConfig({
|
||||
site,
|
||||
|
|
@ -9,7 +9,11 @@ export default defineConfig({
|
|||
output: "static",
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
theme: "github-light",
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
defaultColor: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
1
docs/package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
|||
"": {
|
||||
"name": "claude-code-router-docs",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"astro": "7.0.0",
|
||||
"lucide-astro": "^0.556.0"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"name": "claude-code-router-docs",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
|
|
|
|||
1
docs/public/CNAME
Normal file
|
|
@ -0,0 +1 @@
|
|||
ccrdesk.top
|
||||
BIN
docs/public/ccr-icon.png
Normal file
|
After Width: | Height: | Size: 761 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
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 |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
docs/public/provider-icons/runapi.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 358 B After Width: | Height: | Size: 358 B |
BIN
docs/public/provider-icons/teamorouter.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 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,23 +2,37 @@
|
|||
title: Claude Code Router Detailed Configuration
|
||||
pageTitle: Detailed Configuration
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Configure providers, routing, Agent Config, Fusion, Bots, and the config file 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 |
|
||||
| Provider Config | Upstream services, protocol, Base URL, model list, and credentials |
|
||||
| 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 |
|
||||
| One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries |
|
||||
| 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 |
|
||||
| Config File Location | Config file location maintained by the desktop app |
|
||||
| Config Database Location | SQLite config database location maintained by the desktop app |
|
||||
| Tray Configuration | Tray icon, balance progress, and tray window widgets |
|
||||
|
||||
## Content Relationships
|
||||
|
||||
Providers and routing determine 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. Bots cover IM platform relay.
|
||||
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.
|
||||
|
|
|
|||
46
docs/src/content/docs/en/configuration/api-keys.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
title: API Keys
|
||||
pageTitle: API Keys
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Manage API keys that clients use to access the CCR gateway, with expiration and local limits.
|
||||
---
|
||||
|
||||
## List Fields
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Search API keys | Filters the list by key name or key value. |
|
||||
| Add API key | Opens the create dialog and generates a new client access key. |
|
||||
| Name | Display name for the key. Use it to identify a client, team, purpose, or automation. |
|
||||
| Key | Masked access key. Use `Copy API key` to copy the full key. |
|
||||
| Expires | Expiration time. After expiration, clients can no longer use the key to access CCR. |
|
||||
| Limits | Local limit summary. Shows `No limits configured` when no limits are set. |
|
||||
| Edit API key | Edits expiration and limits. The key value itself is not shown again. |
|
||||
| Remove API key | Deletes the client access key. Deleted keys stop working immediately. |
|
||||
|
||||
## Create And Edit
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Name | Display name for the new key. Examples: `Claude Code - laptop`, `CI`, or a team name. |
|
||||
| Expiration | Selects the validity period: `Never`, `7 days`, `30 days`, `90 days`, or `Custom`. |
|
||||
| Expires at | Appears for `Custom` expiration and sets the exact date and time. |
|
||||
| API key created | Confirmation dialog after creation. It displays the full key. |
|
||||
| Copy this key now. It may not be shown again. | Reminder to copy the key immediately because CCR will not show it again after the dialog closes. |
|
||||
|
||||
## Advanced Settings
|
||||
|
||||
`Advanced settings` adds local limits to a client key. When a limit is reached, requests using that key are rejected or limited; provider-side quota is not changed.
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Advanced settings | Expands or collapses limit editing. |
|
||||
| No limits configured | The key has no local limits. |
|
||||
| Requests | Limits by request count. |
|
||||
| Tokens | Limits by token count. |
|
||||
| Images | Limits by image count. |
|
||||
| per minute | Uses a 1-minute limit window. |
|
||||
| per hour | Uses a 1-hour limit window. |
|
||||
| per day | Uses a 1-day limit window. |
|
||||
| Add limit | Adds one limit rule. |
|
||||
| Remove limit | Removes the current limit rule. |
|
||||
|
|
@ -5,10 +5,6 @@ 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.
|
||||
|
||||
## Common Modes
|
||||
|
||||
- **Forward agent messages**: mirror agent messages into IM.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
---
|
||||
title: Config File Location
|
||||
pageTitle: Config File Location
|
||||
title: Config Database Location
|
||||
pageTitle: Config Database Location
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Locate the JSON configuration file maintained by the CCR desktop app.
|
||||
lead: Locate the SQLite configuration database maintained by the CCR desktop app.
|
||||
---
|
||||
|
||||
## Default Locations
|
||||
|
||||
- **macOS**: `~/Library/Application Support/claude-code-router/config.json`
|
||||
- **Windows**: `%APPDATA%/claude-code-router/config.json`
|
||||
- **Linux**: `~/.config/claude-code-router/config.json`
|
||||
- **macOS/Linux**: `~/.claude-code-router/config.sqlite`
|
||||
- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite`
|
||||
|
||||
## Applying Changes
|
||||
|
||||
Restart CCR after editing the file directly.
|
||||
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once as a migration source when no SQLite config exists; after migration, editing `config.json` does not affect the current configuration.
|
||||
|
||||
Use the desktop UI to change configuration, or export a backup from **Settings**. Do not edit `config.sqlite` directly while CCR is running; SQLite also maintains companion `config.sqlite-wal` and `config.sqlite-shm` files in the same directory.
|
||||
|
|
|
|||
281
docs/src/content/docs/en/configuration/extensions.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
---
|
||||
title: Extension Mechanism
|
||||
pageTitle: Extension Mechanism
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Learn how CCR extensions are loaded, what they can register, and how to create, install, and debug your own extension.
|
||||
---
|
||||
|
||||
## Extension Types
|
||||
|
||||
CCR has two extension layers:
|
||||
|
||||
| Type | Config location | Runtime | Good for |
|
||||
| --- | --- | --- | --- |
|
||||
| Wrapper plugin | `plugins` | CCR Desktop's Electron wrapper process | Local HTTP routes, local backends, proxy capture routing, built-in browser entries, provider account meters |
|
||||
| Core gateway plugin | `providerPlugins` or `plugins[].coreGateway.providerPlugins` | core gateway runtime | Provider, auth, or internal core gateway behavior |
|
||||
|
||||
Most custom extensions should start as a Wrapper plugin. It receives CCR config, a private data directory, a logger, and registration helpers through `ctx`.
|
||||
|
||||
## Loading Flow
|
||||
|
||||
When the gateway starts, CCR reads the `plugins` array and processes each plugin whose `enabled !== false`:
|
||||
|
||||
1. It first applies `apps`, `proxy.routes`, `coreGateway.providerPlugins`, `coreGateway.virtualModelProfiles`, and `coreGateway.config` declared in config.
|
||||
2. It then loads the plugin module. `module` can be an absolute path, a `~/` path, a `./...` path relative to the CCR config directory, or a Node-resolvable package name.
|
||||
3. If `module` is missing, CCR tries to match the plugin `id` with built-in marketplace plugins such as `claude-design` and `cursor-proxy`.
|
||||
4. A module can export a function or an object with `setup(ctx)` or `activate(ctx)`.
|
||||
5. On stop, CCR runs `stop` and `onStop` hooks in reverse order, then closes HTTP backends and SQLite stores registered by the plugin.
|
||||
|
||||
Common module shape:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("extension loaded");
|
||||
},
|
||||
async stop() {
|
||||
// Optional: release resources owned by the extension.
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
You can also export the setup function directly:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = async function setup(ctx) {
|
||||
ctx.logger.info(`loaded ${ctx.pluginId}`);
|
||||
};
|
||||
```
|
||||
|
||||
`setup(ctx)` or `activate(ctx)` can call `ctx.register...` methods directly, or return a registration object. Returned registrations support `apps`, `gatewayRoutes`, `proxyRoutes`, `providerAccountConnectors`, `coreGateway`, `virtualModelProfiles`, `stop`, and `onStop`.
|
||||
|
||||
## ctx Reference
|
||||
|
||||
`setup(ctx)` receives these common fields and helpers:
|
||||
|
||||
| Field or method | Description |
|
||||
| --- | --- |
|
||||
| `ctx.pluginId` | Current plugin ID |
|
||||
| `ctx.pluginConfig` | Custom value from `plugins[].config` |
|
||||
| `ctx.config` | Current CCR AppConfig snapshot |
|
||||
| `ctx.logger` | `debug/info/warn/error` logger prefixed with `[plugin:<id>]` |
|
||||
| `ctx.paths.configDir` | CCR config directory |
|
||||
| `ctx.paths.dataDir` | CCR data directory |
|
||||
| `ctx.paths.pluginDataDir` | Private data directory for this plugin |
|
||||
| `ctx.registerGatewayRoute(route)` | Register a local HTTP route on the CCR gateway |
|
||||
| `ctx.registerHttpBackend(backend)` | Start a local HTTP backend and return `{ url, host, port }` |
|
||||
| `ctx.registerProxyRoute(route)` | Route proxy-captured host/path traffic to a plugin backend or another upstream |
|
||||
| `ctx.registerApp(app)` | Add an entry to the built-in browser app list |
|
||||
| `ctx.openSqliteStore(options)` | Open a SQLite store under the plugin data directory |
|
||||
| `ctx.registerProviderAccountConnector(connector)` | Register a provider balance or quota connector |
|
||||
| `ctx.registerCoreGatewayProviderPlugin(plugin)` | Inject a provider plugin into the core gateway |
|
||||
| `ctx.registerCoreGatewayVirtualModelProfile(profile)` | Inject a virtual model profile into the core gateway |
|
||||
|
||||
Gateway route handlers also receive helper functions:
|
||||
|
||||
| Helper | Description |
|
||||
| --- | --- |
|
||||
| `helpers.readBody(request)` | Read request body as a `Buffer` |
|
||||
| `helpers.readJson(request)` | Read and parse JSON request body |
|
||||
| `helpers.sendJson(response, statusCode, body)` | Send a JSON response |
|
||||
|
||||
`registerGatewayRoute` defaults to `auth: "gateway"`. If CCR has API keys configured, requests must include `Authorization: Bearer <key>` or `x-api-key: <key>`. Use `auth: "none"` only for debugging or local public status routes.
|
||||
|
||||
## Create Your First Extension
|
||||
|
||||
Create a directory such as `~/ccr-extensions/hello-extension`:
|
||||
|
||||
```text
|
||||
hello-extension/
|
||||
plugin.json
|
||||
index.cjs
|
||||
```
|
||||
|
||||
`plugin.json` lets CCR's local extension picker discover the extension ID, name, and entrypoint:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hello-extension",
|
||||
"name": "Hello Extension",
|
||||
"module": "index.cjs",
|
||||
"apps": [
|
||||
{
|
||||
"id": "hello-status",
|
||||
"name": "Hello Status",
|
||||
"url": "http://127.0.0.1:3456/plugins/hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`index.cjs` registers a status route, an echo backend, and one proxy route:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async setup(ctx) {
|
||||
ctx.registerGatewayRoute({
|
||||
auth: "none",
|
||||
id: "hello-status",
|
||||
method: "GET",
|
||||
path: "/plugins/hello",
|
||||
handler(_request, response, helpers) {
|
||||
helpers.sendJson(response, 200, {
|
||||
ok: true,
|
||||
plugin: ctx.pluginId,
|
||||
message: ctx.pluginConfig?.message || "hello from CCR"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const backend = await ctx.registerHttpBackend({
|
||||
id: "hello-echo",
|
||||
async handler(request, response, helpers) {
|
||||
const body = request.method === "POST"
|
||||
? (await helpers.readBody(request)).toString("utf8")
|
||||
: "";
|
||||
|
||||
helpers.sendJson(response, 200, {
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
body
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.registerProxyRoute({
|
||||
host: "api.example.local",
|
||||
id: "hello-example-api",
|
||||
paths: ["/v1"],
|
||||
preserveHost: true,
|
||||
upstream: backend.url
|
||||
});
|
||||
|
||||
ctx.logger.info(`hello backend listening at ${backend.url}`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This exposes:
|
||||
|
||||
- `GET /plugins/hello`: a route mounted directly on the CCR gateway to verify that the plugin loaded.
|
||||
- A local echo backend: CCR assigns a free port automatically.
|
||||
- A proxy rule: proxy-captured `api.example.local/v1...` traffic is forwarded to the echo backend.
|
||||
|
||||
## Install The Extension
|
||||
|
||||
The recommended flow is through the desktop UI:
|
||||
|
||||
1. Open **Extensions**.
|
||||
2. Add an extension and choose a local extension directory.
|
||||
3. Select the `hello-extension` directory.
|
||||
4. Save the config.
|
||||
5. Open **Server** and restart the gateway.
|
||||
|
||||
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
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "hello-extension",
|
||||
"enabled": true,
|
||||
"module": "/Users/you/ccr-extensions/hello-extension/index.cjs",
|
||||
"config": {
|
||||
"message": "hello from my config"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway after saving the extension config. See [Config Database Location](/en/configuration/configuration-file/).
|
||||
|
||||
The local directory picker recognizes entry metadata from:
|
||||
|
||||
- `plugin.json`
|
||||
- `ccr-plugin.json`
|
||||
- `.ccr-plugin/plugin.json`
|
||||
- `.codex-plugin/plugin.json`
|
||||
- `main`, `ccr.module`, or `ccrPlugin.module` in `package.json`
|
||||
|
||||
If no entrypoint is declared, CCR tries `index.cjs`, `index.mjs`, `index.js`, `plugin.cjs`, `plugin.mjs`, or `plugin.js` in the selected directory.
|
||||
|
||||
## Debug Extensions
|
||||
|
||||
### 1. Check syntax first
|
||||
|
||||
For CommonJS extensions:
|
||||
|
||||
```bash
|
||||
node --check ~/ccr-extensions/hello-extension/index.cjs
|
||||
```
|
||||
|
||||
If the extension depends on npm packages, install them in the extension directory and make sure Node can resolve the entrypoint.
|
||||
|
||||
### 2. Start CCR from source
|
||||
|
||||
From the CCR repository root:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`ctx.logger.info/warn/error` output appears in the terminal that started CCR, with a prefix such as `[plugin:hello-extension]`.
|
||||
|
||||
### 3. Verify the Gateway route
|
||||
|
||||
After the gateway starts, request the status route:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
If the route uses the default `auth: "gateway"` and CCR has API keys configured:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
You can also use:
|
||||
|
||||
```bash
|
||||
curl -H "x-api-key: <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
### 4. Verify the HTTP backend and proxy route
|
||||
|
||||
`registerHttpBackend` returns `backend.url`, which the example writes to logs. Request that URL directly first, then enable proxy mode and verify whether the target host/path hits `registerProxyRoute`.
|
||||
|
||||
Proxy route matching rules:
|
||||
|
||||
- `host` must match the target hostname. Exact host, `.example.com` suffix, and `*.example.com` wildcard patterns are supported.
|
||||
- Empty `paths` matches all paths for that host.
|
||||
- When multiple paths match, CCR chooses the longest path prefix.
|
||||
- `stripPathPrefix` removes the matched prefix from the forwarded path.
|
||||
- `rewritePathPrefix` replaces the matched prefix with a configured prefix.
|
||||
|
||||
### 5. Common Issues
|
||||
|
||||
| Symptom | What to check |
|
||||
| --- | --- |
|
||||
| Extension does not load | Check `plugins[].enabled`, `plugins[].module`, and terminal errors prefixed with `[plugin:<id>]` |
|
||||
| `GET /plugins/hello` returns 404 | Restart the gateway and confirm `path` or `pathPrefix` starts with `/` |
|
||||
| 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 misses requests | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Use `auth: "none"` only for status pages, health checks, or local debugging routes.
|
||||
- Do not log API keys, OAuth tokens, cookies, or complete request headers.
|
||||
- Prefer `ctx.paths.pluginDataDir` for files written by your extension.
|
||||
- Validate all external input returned by `readJson`.
|
||||
- When proxying to external upstreams, handle headers explicitly so local auth material is not forwarded to untrusted services.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
131
docs/src/content/docs/en/configuration/overview.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
---
|
||||
title: Overview Dashboard
|
||||
pageTitle: Overview Dashboard
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Customize the CCR home dashboard to inspect system status, account balance, requests, tokens, cost, and model distribution.
|
||||
---
|
||||
|
||||
## When To Use It
|
||||
|
||||
| Scenario | What to inspect |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
|
||||
## Time Range
|
||||
|
||||
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.
|
||||
|
||||
| Option | Window |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
|
||||
The account balance widget does not use this time range. It shows the latest snapshot returned by provider account connectors.
|
||||
|
||||
## Edit Layout
|
||||
|
||||
Click the pencil button in the upper-right corner to enter editing mode. Editing mode has three columns:
|
||||
|
||||
| Area | Purpose |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
|
||||
Common operations:
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
|
|
@ -5,11 +5,16 @@ eyebrow: Detailed Configuration
|
|||
lead: Create reusable launch configurations for Claude Code, Codex, 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, or ZCode setup you open directly from the system.
|
||||
|
||||
## Multi-Instance Mechanism
|
||||
|
||||
|
|
@ -26,20 +31,80 @@ This lets you create multiple configs for the same agent, such as "Claude Code -
|
|||
|
||||
## Common Options
|
||||
|
||||
| Option | Description |
|
||||
| Option | Applies to | Description |
|
||||
| --- | --- | --- |
|
||||
| Agent | All | Claude Code, Codex, or ZCode. ZCode supports App only. |
|
||||
| Config name | All | Identifies the config in CCR and can be used as the `ccr <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 | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. |
|
||||
| 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. |
|
||||
|
||||
### 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 Codex App. Claude Code-specific model discovery variables are not passed to Codex. |
|
||||
| Bot | Applies only to the Codex App entry. |
|
||||
|
||||
After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr "Codex - Work"`. Use the play button to open Codex App. CCR generates `config.toml`, a model catalog file, and a middleware launcher so Codex CLI and Codex App use the same CCR model and provider information.
|
||||
|
||||
### 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 <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, side-by-side instances, Bot forwarding, handoff | Uses a separate user-data directory per Agent Config; reopening the same config activates the existing window, while different configs can run in parallel. |
|
||||
| CLI & APP | One config exposes both CLI and App entry points | Reusing the same model config in both terminal and desktop App workflows | Both entries share the config name, model, effect scope, and environment variables, but launch differently. |
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
|
|||
295
docs/src/content/docs/en/configuration/provider-deeplink.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
---
|
||||
title: One click import
|
||||
pageTitle: One click import
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Quickly add common model providers, review the details, and save them without filling everything in by hand.
|
||||
---
|
||||
|
||||
## One-Click Import
|
||||
|
||||
Choose a provider below to get started. CCR shows what will be added before saving it; when using a custom entry point, make sure the source is one you trust.
|
||||
|
||||
<div class="provider-import-grid" aria-label="Preset provider import buttons">
|
||||
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&base_url=https%3A%2F%2Fapi.openai.com%2Fv1&protocol=openai_responses&models=gpt-5.5%2Cgpt-5.5-pro%2Cgpt-5.5-instant%2Cgpt-5.4-mini" aria-label="Import OpenAI provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/openai.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">OpenAI</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-anthropic" href="ccr://provider?name=Anthropic&base_url=https%3A%2F%2Fapi.anthropic.com&protocol=anthropic_messages&models=claude-fable-5%2Cclaude-opus-4-8%2Cclaude-sonnet-4-6%2Cclaude-haiku-4-5" aria-label="Import Anthropic provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/anthropic.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Anthropic</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-gemini" href="ccr://provider?name=Google+Gemini&base_url=https%3A%2F%2Fgenerativelanguage.googleapis.com&protocol=gemini_generate_content&models=gemini-3.5-flash%2Cgemini-3.1-pro-preview%2Cgemini-3-flash-preview" aria-label="Import Google Gemini provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/gemini.svg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Google Gemini</span><span class="provider-import-meta">Gemini Generate Content</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-openrouter" href="ccr://provider?name=OpenRouter&base_url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&protocol=openai_chat_completions&models=%7Eopenai%2Fgpt-latest%2C%7Eanthropic%2Fclaude-opus-latest%2C%7Eanthropic%2Fclaude-sonnet-latest%2Cgoogle%2Fgemini-3.5-flash%2Cz-ai%2Fglm-5.2" aria-label="Import OpenRouter provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/openrouter.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">OpenRouter</span><span class="provider-import-meta">OpenAI compatible gateway</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-deepseek" href="ccr://provider?name=DeepSeek&base_url=https%3A%2F%2Fapi.deepseek.com&protocol=openai_chat_completions&models=deepseek-v4-pro%2Cdeepseek-v4-flash%2Cdeepseek-v3.2%2Cdeepseek-reasoner%2Cdeepseek-chat" aria-label="Import DeepSeek provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/deepseek.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">DeepSeek</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zhipu-coding" href="ccr://provider?name=Zhipu+AI+%28China%29+-+Coding+Plan&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5-turbo%2Cglm-5v-turbo%2Cglm-4.7" aria-label="Import Zhipu AI China Coding Plan provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zhipu-cn-coding.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Zhipu Coding</span><span class="provider-import-meta">China Coding Plan</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zhipu-general" href="ccr://provider?name=Zhipu+AI+%28China%29+-+General+Endpoint&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5%2Cglm-5v-turbo%2Cglm-4.7" aria-label="Import Zhipu AI China General Endpoint provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zhipu-cn-general.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Zhipu General</span><span class="provider-import-meta">China General Endpoint</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zai-coding" href="ccr://provider?name=Z.ai+%28Global%29+-+Coding+Plan&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5-turbo%2Cglm-5v-turbo%2Cglm-4.7" aria-label="Import Z.ai Global Coding Plan provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zai-global-coding.svg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Z.ai Coding</span><span class="provider-import-meta">Global Coding Plan</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zai-general" href="ccr://provider?name=Z.ai+%28Global%29+-+General+Endpoint&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5%2Cglm-5v-turbo%2Cglm-4.7" aria-label="Import Z.ai Global General Endpoint provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/zai-global-general.svg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Z.ai General</span><span class="provider-import-meta">Global General Endpoint</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-mistral" href="ccr://provider?name=Mistral&base_url=https%3A%2F%2Fapi.mistral.ai%2Fv1&protocol=openai_chat_completions&models=mistral-medium-3-5%2Cmistral-large-3%2Cministral-3-14b-instruct-2512%2Cdevstral-2512" aria-label="Import Mistral provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Mistral</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Kimi+API+%28China%29&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Kimi API China provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API (China)</span><span class="provider-import-meta">China platform</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot-global" href="ccr://provider?name=Kimi+API+%28Global%29&base_url=https%3A%2F%2Fapi.moonshot.ai%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Kimi API Global provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API (Global)</span><span class="provider-import-meta">Global platform</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-kimi-coding" href="ccr://provider?name=Kimi+Code+-+Coding+Plan&base_url=https%3A%2F%2Fapi.kimi.com%2Fcoding%2Fv1&protocol=openai_chat_completions&models=kimi-for-coding" aria-label="Import Kimi Code Coding Plan provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi Code</span><span class="provider-import-meta">Coding Plan</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-bailian" href="ccr://provider?name=Alibaba+Bailian&base_url=https%3A%2F%2Fdashscope.aliyuncs.com%2Fcompatible-mode%2Fv1&protocol=openai_chat_completions&models=qwen3.7-max%2Cqwen3.7-plus%2Cqwen3.6-max-preview%2Cqwen3-coder-plus%2Cqwen3-max" aria-label="Import Alibaba Bailian provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/bailian.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Alibaba Bailian</span><span class="provider-import-meta">DashScope compatible</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-siliconflow" href="ccr://provider?name=SiliconFlow&base_url=https%3A%2F%2Fapi.siliconflow.cn%2Fv1&protocol=openai_chat_completions&models=zai-org%2FGLM-5.2%2Cdeepseek-ai%2Fdeepseek-v4-pro%2Cdeepseek-ai%2Fdeepseek-v4-flash%2Czai-org%2FGLM-5.1%2Cdeepseek-ai%2FDeepSeek-V3.2" aria-label="Import SiliconFlow provider">
|
||||
<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-code0" href="ccr://provider?name=code0.ai&base_url=https%3A%2F%2Fconsole.code0.ai&protocol=anthropic_messages&source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="Import code0.ai provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/code0.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&base_url=https%3A%2F%2Fgw.claudeapi.com&protocol=anthropic_messages&source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="Import claudeapi provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Embeddable Button Component
|
||||
|
||||
CCR also ships a framework-free button script that providers can embed on their own webpages so users can import that provider into CCR with one click. The script registers Web Components automatically.
|
||||
|
||||
### HTML
|
||||
|
||||
```html
|
||||
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
|
||||
|
||||
<ccr-provider-button
|
||||
name="Example AI"
|
||||
base_url="https://api.example.com/v1"
|
||||
protocol="openai_chat_completions"
|
||||
models="example-chat,example-coder"
|
||||
icon="https://example.com/icon.png"
|
||||
source="https://example.com"
|
||||
></ccr-provider-button>
|
||||
```
|
||||
|
||||
For larger configs, pass a manifest:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
|
||||
|
||||
<ccr-provider-button
|
||||
name="Example AI"
|
||||
manifest="https://example.com/.well-known/ccr-provider.json"
|
||||
></ccr-provider-button>
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```html
|
||||
<div id="ccr-buttons"></div>
|
||||
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js"></script>
|
||||
<script>
|
||||
CCRProviderButtons.render("#ccr-buttons", {
|
||||
name: "Example AI",
|
||||
base_url: "https://api.example.com/v1",
|
||||
api_key: "sk-user-key",
|
||||
protocol: "openai_chat_completions",
|
||||
models: ["example-chat", "example-coder"],
|
||||
icon: "https://example.com/icon.png",
|
||||
source: "https://example.com"
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Render Parameters
|
||||
|
||||
`CCRProviderButtons.render(target, options)` and `<ccr-provider-button>` support the same parameter set. Parameter names match the `ccr://provider` protocol:
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `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`, `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 |
|
||||
| `manifest` | Remote manifest URL. When present, the button creates a manifest import link |
|
||||
| `payload` | JSON or base64url JSON config. JavaScript may pass an object |
|
||||
| `usage_url` | Optional account usage endpoint |
|
||||
| `fetch_usage` | Whether account usage fetching is enabled |
|
||||
| `usage_method` | Usage request method, `GET` or `POST` |
|
||||
| `usage_headers` | Usage request headers. JavaScript may pass an object; HTML must pass a JSON string |
|
||||
| `usage_body` | Usage request body. JavaScript may pass an object; HTML must pass a JSON string |
|
||||
| `balance` | Balance field path |
|
||||
| `balance_unit` | Balance unit |
|
||||
| `subscription` | Subscription remaining field path |
|
||||
| `subscription_limit` | Subscription limit field path |
|
||||
| `subscription_reset` | Subscription reset time field path |
|
||||
| `subscription_unit` | Subscription unit |
|
||||
| `subscription_window` | Subscription window, such as `monthly` |
|
||||
|
||||
## URL Format
|
||||
|
||||
CCR supports two URL shapes. The host form is recommended:
|
||||
|
||||
```text
|
||||
ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&protocol=openai_chat_completions&models=example-chat%2Cexample-coder
|
||||
```
|
||||
|
||||
The path form is also recognized:
|
||||
|
||||
```text
|
||||
ccr:///provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1
|
||||
```
|
||||
|
||||
For larger configs, put JSON in `payload`. The value can be URL-encoded JSON or base64url JSON:
|
||||
|
||||
```text
|
||||
ccr://provider?payload=%7B%22name%22%3A%22Example%20AI%22%2C%22base_url%22%3A%22https%3A%2F%2Fapi.example.com%2Fv1%22%2C%22models%22%3A%5B%22example-chat%22%5D%7D
|
||||
```
|
||||
|
||||
## Manifest Import
|
||||
|
||||
Providers can also pass a manifest URL:
|
||||
|
||||
```text
|
||||
ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr-provider.json
|
||||
```
|
||||
|
||||
The manifest must use HTTPS, return JSON, avoid local or private network hosts, and stay under 128 KB. CCR fetches the manifest inside the app, shows a confirmation dialog, and writes config only after user approval.
|
||||
|
||||
The manifest can put provider information in a top-level `provider` object:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `provider.name` | Provider display name |
|
||||
| `provider.base_url` | Provider API Base URL, required |
|
||||
| `provider.protocol` | Protocol type |
|
||||
| `provider.models` | Model list as a string array |
|
||||
| `provider.icon` | Provider icon URL |
|
||||
| `provider.source` | Provider website or config source |
|
||||
| `provider.account.enabled` | Whether account usage fetching is enabled |
|
||||
| `provider.account.refreshIntervalMs` | Usage refresh interval in milliseconds |
|
||||
| `provider.account.connectors` | Usage connector list |
|
||||
| `provider.account.connectors[].type` | Connector type, commonly `http-json` |
|
||||
| `provider.account.connectors[].auth` | Auth mode, commonly `provider-api-key` |
|
||||
| `provider.account.connectors[].endpoint` | Usage endpoint URL |
|
||||
| `provider.account.connectors[].method` | Request method, `GET` or `POST` |
|
||||
| `provider.account.connectors[].headers` | Request headers, without sensitive auth headers |
|
||||
| `provider.account.connectors[].body` | Optional request body |
|
||||
| `provider.account.connectors[].mapping.meters` | Usage meter mappings |
|
||||
|
||||
Complete manifest example:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"name": "Example AI",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"protocol": "openai_chat_completions",
|
||||
"models": ["example-chat", "example-coder"],
|
||||
"icon": "https://example.com/icon.png",
|
||||
"source": "https://example.com",
|
||||
"account": {
|
||||
"enabled": true,
|
||||
"refreshIntervalMs": 300000,
|
||||
"connectors": [
|
||||
{
|
||||
"type": "http-json",
|
||||
"auth": "provider-api-key",
|
||||
"endpoint": "https://api.example.com/v1/account/usage",
|
||||
"method": "GET",
|
||||
"headers": {
|
||||
"accept": "application/json"
|
||||
},
|
||||
"mapping": {
|
||||
"meters": [
|
||||
{
|
||||
"id": "balance",
|
||||
"kind": "balance",
|
||||
"label": "Balance",
|
||||
"remaining": "data.balance.remaining",
|
||||
"unit": "USD"
|
||||
},
|
||||
{
|
||||
"id": "subscription",
|
||||
"kind": "subscription",
|
||||
"label": "Monthly quota",
|
||||
"remaining": "data.quota.remaining",
|
||||
"limit": "data.quota.limit",
|
||||
"resetAt": "data.quota.reset_at",
|
||||
"unit": "tokens",
|
||||
"window": "monthly"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `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`, `gemini_interactions` |
|
||||
| `models` | Model list, comma-separated, newline-separated, or repeated |
|
||||
| `icon` | Provider icon URL |
|
||||
| `source` | Provider website or config source |
|
||||
| `manifest` | Remote manifest URL |
|
||||
| `payload` | JSON or base64url JSON config |
|
||||
| `usage_url` | Optional account usage endpoint |
|
||||
| `fetch_usage` | Whether account usage fetching is enabled |
|
||||
| `usage_method` | Usage request method, `GET` or `POST` |
|
||||
| `usage_headers` | Usage request headers as a JSON string |
|
||||
| `usage_body` | Usage request body as a JSON string |
|
||||
| `balance` | Balance field path |
|
||||
| `balance_unit` | Balance unit |
|
||||
| `subscription` | Subscription remaining field path |
|
||||
| `subscription_limit` | Subscription limit field path |
|
||||
| `subscription_reset` | Subscription reset time field path |
|
||||
| `subscription_unit` | Subscription unit |
|
||||
| `subscription_window` | Subscription window, such as `monthly` |
|
||||
|
||||
Parameter names and protocol values must use the exact names above. Aliases such as `baseUrl`, `apiKey`, `model`, `type`, or `openai` are not accepted.
|
||||
|
|
@ -5,22 +5,166 @@ 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. CCR needs at least one provider with a valid protocol, Base URL, model list, and credential.
|
||||
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.
|
||||
|
||||
### Claude Code
|
||||
|
||||
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. |
|
||||
| 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. |
|
||||
| Models | Model IDs exposed by CCR. Routing rules, profile model selectors, the model catalog, and client `/models` responses all use this list. |
|
||||
| Search models / All / Clear | When CCR can discover models from the upstream or catalog, you can search, select all, clear, and choose models. Selected models are saved to the provider. |
|
||||
| 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. 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. 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
|
||||
|
||||
Credentials store API keys. Multiple credentials can rotate by priority and weight, and can switch when a key hits a limit or fails.
|
||||
`API key` is the simplest single-key setup. For multiple upstream keys, expand `Credential pool` in Advanced settings.
|
||||
|
||||
Use recognizable labels so failed keys are easy to identify in Logs.
|
||||
|
||||
## Provider Options
|
||||
|
||||
| Field | Description |
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Name | Internal display name in CCR; keep it short and recognizable |
|
||||
| Base URL | Upstream service address; custom providers should include the correct API path |
|
||||
| Protocol | OpenAI, Anthropic, Gemini, or compatible protocol |
|
||||
| Models | Model list exposed to CCR selectors |
|
||||
| Request headers | Extra headers required by some compatible services |
|
||||
| Show credential settings | Expands or collapses credential pool editing. Collapsing does not remove saved credentials. |
|
||||
| Import JSON | Imports credentials from a JSON file. CCR accepts a top-level array, or an object with a `credentials`, `keys`, or `apiKeys` array. |
|
||||
| Add key | Adds one upstream API key row. |
|
||||
| Enable | Controls whether this credential participates in request forwarding and usage fetching. Disabled credentials are kept but not selected. |
|
||||
| Name | Display name for the credential. It appears in account usage, logs, and diagnostics, so use a recognizable purpose or quota source. |
|
||||
| API key | The actual key sent to the upstream for this credential. When a credential pool is configured, CCR expands enabled credentials into internal upstream targets and prefers the pool over the main form API key for model requests. |
|
||||
| Remove | Deletes the credential row. |
|
||||
| Advanced key options | Expands per-key scheduling and limit fields. |
|
||||
| Priority | Credential priority. Lower numbers are tried first. If omitted, the row order is used. |
|
||||
| Weight | Tie-break weight among credentials with the same priority and similar usage. Higher numbers are preferred. Defaults to `1`. |
|
||||
| Limits JSON | Local limit rules for this key. CCR tracks request, token, or image usage windows and skips a key once it would exceed its limit, then tries another key on the same provider. |
|
||||
|
||||
Common `Limits JSON` fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `rpm` / `rph` / `rpd` | Max requests per minute / hour / day |
|
||||
| `tpm` / `tph` / `tpd` | Max tokens per minute / hour / day |
|
||||
| `ipm` / `iph` / `ipd` | Max images per minute / hour / day |
|
||||
| `maxRequests` + `windowMs` | Max requests in a custom time window |
|
||||
| `maxTokens` + `quotaWindowMs` | Max tokens in a custom time window |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"rpm": 60,
|
||||
"tpm": 100000
|
||||
}
|
||||
```
|
||||
|
||||
The credential pool is an upstream provider key pool. It is separate from the client access keys configured on the API Keys page.
|
||||
|
||||
## Usage Fetching
|
||||
|
||||
`Fetch usage` lets CCR show balance, subscription quota, status, and messages in the provider list, tray, and account panels. It does not affect whether models can be requested.
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Fetch usage | Enables or disables account usage fetching for this provider. |
|
||||
| Usage mode | Usage connector mode. `Standard usage endpoint` uses CCR standard account endpoints; `HTTP JSON request` maps a custom JSON endpoint; `Raw connector JSON` edits the connector array directly. |
|
||||
| Refresh interval ms | Usage refresh interval in milliseconds. Empty uses the default interval. The minimum effective interval is 30000ms. |
|
||||
|
||||
### Standard Usage Endpoint
|
||||
|
||||
This mode tries provider-hosted CCR account endpoints such as `/.well-known/ccr/account` and `/v1/account/limits`. It is best for providers or presets that already implement CCR's standard account format.
|
||||
|
||||
### HTTP JSON Request
|
||||
|
||||
Use this mode when the provider has a balance or quota endpoint that returns a custom JSON shape.
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Method | Usage request method, `GET` or `POST`. |
|
||||
| Usage request URL | Usage endpoint URL. It can be a full URL. The request includes the provider API key unless changed through raw connector JSON. |
|
||||
| Headers | Extra headers for the usage endpoint. Avoid hard-coding sensitive auth headers here; prefer provider API key auth. |
|
||||
| Body | `POST` request body. Must be valid JSON. |
|
||||
| Balance remaining field | JSON path for remaining balance. |
|
||||
| Balance total field | JSON path for total balance or total credits. |
|
||||
| Balance used field | JSON path for used balance. |
|
||||
| Balance unit | Balance unit, such as `USD`, `CNY`, or `%`. |
|
||||
| Subscription remaining field | JSON path for remaining subscription, token, quota, or package amount. |
|
||||
| Subscription limit field | JSON path for subscription, token, quota, or package limit. |
|
||||
| Subscription reset field | JSON path for reset time. It may resolve to an ISO string, seconds timestamp, or milliseconds timestamp. |
|
||||
| Subscription unit | Subscription unit, such as `tokens`, `requests`, or `hours`. |
|
||||
| Status field | JSON path for account status. Supported values are `ok`, `warning`, `critical`, `error`, and `unsupported`. |
|
||||
| Message field | JSON path for account message. Useful for provider errors, plan notes, or risk-control messages. |
|
||||
| Test usage request | Requests and parses the usage endpoint before saving. |
|
||||
| Response fields | Lists selectable paths from the response. Buttons such as `Balance rem`, `Balance total`, `Balance used`, `Sub rem`, `Sub limit`, and `Reset` fill the matching field. |
|
||||
|
||||
Field paths use CCR's lightweight JSONPath syntax:
|
||||
|
||||
| Syntax | Meaning |
|
||||
| --- | --- |
|
||||
| `$` | Whole response object |
|
||||
| `$.balance.remaining` | Object field |
|
||||
| `$.items[0].value` | Array index |
|
||||
| `$["weird-key"]` | Field name with special characters |
|
||||
| `$.limits[?(@.type=="TOKENS")].remaining` | First array item matching simple equality filters |
|
||||
| `100 - $.data.percentage` | Numeric subtraction expression, often used to convert used percent into remaining percent |
|
||||
|
||||
### Raw Connector JSON
|
||||
|
||||
`Connectors JSON` edits the `account.connectors` array directly for more complex provider setups.
|
||||
|
||||
| Connector type | Capability |
|
||||
| --- | --- |
|
||||
| `standard` | Uses CCR standard account endpoints. |
|
||||
| `http-json` | Requests a JSON endpoint and maps balance, subscription, status, and message fields. |
|
||||
| `plugin` | Calls an account usage connector registered by an installed plugin. |
|
||||
| `local-estimate` | Shows estimated quota from local time-window config without a remote request. |
|
||||
|
||||
`Insert example` fills an example connector array containing `standard`, `http-json`, `plugin`, and `local-estimate` connectors.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
28
docs/src/content/docs/en/configuration/server.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: Server
|
||||
pageTitle: Server
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception and proxying into CCR.
|
||||
---
|
||||
|
||||
## Main Fields
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
|
||||
## 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.
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Proxy mode | Enables Proxy mode. CCR can receive client traffic as an HTTP/HTTPS proxy and use MITM interception to proxy model requests into CCR. |
|
||||
| System proxy | Points the system proxy at CCR so apps that honor system proxy settings can go through CCR automatically. |
|
||||
| Capture network | Stores network requests that pass through proxy mode so the Networking page can show request and response details. |
|
||||
| CA certificate | Trust status of the current proxy CA certificate. |
|
||||
| Install CA | Installs the CCR proxy CA into the system or user trust store. Installation differs by OS. |
|
||||
| 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. |
|
||||
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.
|
||||
41
docs/src/content/docs/en/configuration/tray.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: Tray Configuration
|
||||
pageTitle: Tray Configuration
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Configure the CCR system tray icon, balance progress, and tray window widgets.
|
||||
---
|
||||
|
||||
## Top Fields
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Tray mascot | Selects the tray icon style. Options include `Random`, `Auralis`, `Solara`, `Vesper`, and `Balance progress`. |
|
||||
| Balance progress | Uses provider account usage as tray icon progress. Requires `Fetch usage` on a provider first. |
|
||||
| Account | Selects the provider account used for balance progress. |
|
||||
| Data | Selects the balance, subscription, or quota meter used as the progress source. |
|
||||
|
||||
If no account data is available, the page shows `No account data is available. Enable account monitoring on a provider first.` This usually means no provider has `Fetch usage` enabled, or usage fetching has not succeeded yet.
|
||||
|
||||
## Tray Window Layout
|
||||
|
||||
| Area | Capability |
|
||||
| --- | --- |
|
||||
| Components | Left-side component palette for adding or enabling tray window widgets. |
|
||||
| Preview | Middle preview area showing the current tray window layout. Widgets can be dragged to reorder. |
|
||||
| Component properties | Right-side editor for the selected widget's `Style`, or for removing the widget. |
|
||||
|
||||
## Component Types
|
||||
|
||||
| Component | Capability |
|
||||
| --- | --- |
|
||||
| Provider component | Shows `Provider tabs` for switching provider data in the tray window. It is a singleton component and can be enabled only once. |
|
||||
| Header component | Shows `Title and status`. It is a singleton component and can be enabled only once. |
|
||||
| Account component | Shows `Account balance`. Multiple account components can be added with different styles. |
|
||||
| Trend component | Shows `Token flow chart`. |
|
||||
| Activity component | Shows `Token activity`. |
|
||||
| Metric component | Shows `Token stats`. |
|
||||
| Breakdown component | Shows `Token mix`, `Circular metrics`, or `Model share`. |
|
||||
|
||||
## Styles
|
||||
|
||||
Different components support different `Style` options. Common styles include `Cards`, `Compact`, `List`, `Pills`, `Line`, `Area`, `Bar`, `Ring`, `Donut`, `Gauges`, `Sparkline`, and `Stacked`. Style changes only affect the tray display; they do not change routing, providers, or usage stats.
|
||||
|
|
@ -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:
|
||||
|
|
@ -26,7 +13,7 @@ The top navigation is split into four standalone pages:
|
|||
| --- | --- |
|
||||
| [Documentation](./) | Product positioning, architecture overview, and reading path |
|
||||
| [Quick Start](guides/) | From installation and provider setup to connecting an agent |
|
||||
| [Detailed Configuration](configuration/) | Providers, routing, Agent Config, Fusion, Bots, and config file location |
|
||||
| [Detailed Configuration](configuration/overview/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location |
|
||||
| [Q&A](troubleshooting/) | Request logs, observability panel, and common questions |
|
||||
|
||||
Bot platform guides are child pages under Detailed Configuration. Each platform has its own page so platform dashboard fields, callback URLs, signatures, and FAQs can be expanded independently.
|
||||
|
|
@ -37,5 +24,5 @@ If this is your first time using CCR:
|
|||
|
||||
1. Start with [Quick Start](guides/) to connect a provider and Agent Config.
|
||||
2. Use the app's request logs to confirm whether requests are passing through CCR.
|
||||
3. Open [Detailed Configuration](configuration/) for vision, web search, MCP tools, and IM relay.
|
||||
3. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay.
|
||||
4. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ LINE 适合把 Agent 消息接入已有的 LINE 好友、群聊或 LINE Official
|
|||
|
||||
## 你会用到哪些字段
|
||||
|
||||
CCR 里 LINE 的认证方式叫 **Bot Token**,但填的不是 Telegram 那种 token,而是下面这两个 channel 字段:
|
||||
CCR 里 LINE 的认证方式叫 **Bot Token**,这里需要填写下面这两个 channel 字段:
|
||||
|
||||
| LINE 后台里的名字 | CCR 字段 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
|
|
|
|||
|
|
@ -2,22 +2,37 @@
|
|||
title: Claude Code Router 详细配置
|
||||
pageTitle: 详细配置
|
||||
eyebrow: 详细配置
|
||||
lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置文件位置。这里是按功能查字段和扩展能力的地方。
|
||||
lead: 按应用中的实际顺序,将主页页面和设置页分开说明:主页覆盖概览、供应商、Agent配置、路由、Fusion、API 密钥、日志&观测、服务和扩展;设置页覆盖 ToolHub、Bot、数据和托盘。
|
||||
---
|
||||
|
||||
## 页面结构
|
||||
|
||||
详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。
|
||||
详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。主页页面跟随应用左侧主导航顺序;设置页单独分组,并按设置弹窗顺序排列。
|
||||
|
||||
## 主页页面
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| 概览仪表盘 | 系统状态、账户余额、用量组件、布局编辑和分享卡片 |
|
||||
| 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 |
|
||||
| 路由配置 | 默认路由、条件规则、fallback 和请求改写 |
|
||||
| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 |
|
||||
| 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 |
|
||||
| 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 维护的配置文件位置 |
|
||||
| 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 |
|
||||
| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 |
|
||||
|
||||
## 内容关系
|
||||
|
||||
供应商和路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;Bot 页面覆盖 IM 平台接力。
|
||||
概览仪表盘用于查看系统状态和用量;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;路由决定模型请求的上游去向;Fusion 页面覆盖图像、搜索和 MCP 工具;API 密钥控制客户端访问 CCR;日志&观测覆盖请求日志和 Agent 执行链路;服务配置控制本地网关监听和代理能力;扩展机制覆盖本地插件的创建、安装和调试。ToolHub、Bot、配置数据库位置和托盘配置对应设置弹窗中的同名配置页。
|
||||
|
|
|
|||
46
docs/src/content/docs/zh/configuration/api-keys.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
title: API 密钥
|
||||
pageTitle: API 密钥
|
||||
eyebrow: 详细配置
|
||||
lead: 管理客户端访问 CCR 网关时使用的 API Key,并为每个 Key 设置过期时间和本地限额。
|
||||
---
|
||||
|
||||
## 列表字段
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 搜索 API 密钥 | 按名称或 Key 内容过滤列表。 |
|
||||
| 添加 API 密钥 | 打开创建弹窗,生成新的客户端访问 Key。 |
|
||||
| 名称 | API Key 的显示名,用于区分客户端、团队、用途或自动化来源。 |
|
||||
| Key | 脱敏后的访问 Key。可以点击 `复制 API 密钥` 复制完整 Key。 |
|
||||
| 过期 | 当前 Key 的过期时间。过期后客户端不能继续使用这个 Key 访问 CCR。 |
|
||||
| 限制 | 当前 Key 的本地限额摘要。没有配置限额时显示“未配置限制”。 |
|
||||
| 编辑 API 密钥 | 修改过期时间和限额。出于安全原因,已创建的 Key 本身不会重新明文展示。 |
|
||||
| 移除 API 密钥 | 删除当前客户端访问 Key。删除后立即不能再用于请求。 |
|
||||
|
||||
## 创建和编辑
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 名称 | 新 Key 的显示名。建议写成客户端或用途,例如 `Claude Code - laptop`、`CI` 或团队名。 |
|
||||
| 过期时间 | 选择 Key 的有效期:`永不`、`7 天`、`30 天`、`90 天` 或 `自定义`。 |
|
||||
| 过期于 | 选择 `自定义` 时出现,用于填写精确的过期日期和时间。 |
|
||||
| API 密钥已创建 | 创建成功后的确认弹窗。这里会显示完整 Key。 |
|
||||
| 请现在复制保存这个密钥,之后可能不会再次完整显示。 | 提醒你立即复制 Key。关闭弹窗后,CCR 不会再次展示完整 Key。 |
|
||||
|
||||
## 高级设置
|
||||
|
||||
`高级设置` 用于给单个客户端 Key 添加本地限额。限额命中后,使用这个 Key 的客户端请求会被拒绝或限制;它不会修改供应商侧额度。
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 高级设置 | 展开或收起限额编辑区。 |
|
||||
| 未配置限制 | 当前 Key 没有任何本地限额。 |
|
||||
| 请求 | 按请求次数限制。 |
|
||||
| 令牌 | 按 token 数量限制。 |
|
||||
| 图片 | 按图片数量限制。 |
|
||||
| 每分钟 | 限额窗口为 1 分钟。 |
|
||||
| 每小时 | 限额窗口为 1 小时。 |
|
||||
| 每天 | 限额窗口为 1 天。 |
|
||||
| 添加限制 | 新增一条限额规则。 |
|
||||
| 移除限制 | 删除当前限额规则。 |
|
||||
|
|
@ -5,10 +5,6 @@ eyebrow: 详细配置
|
|||
lead: 通过 IM Bot 转发 Agent 消息,或在桌面空闲后把任务接力到手机。
|
||||
---
|
||||
|
||||
## 能做什么
|
||||
|
||||
Bot 能把 Agent 消息转发到 IM 平台,也可以在桌面空闲后把任务接力到手机上继续看。
|
||||
|
||||
## 常见模式
|
||||
|
||||
- **转发 Agent 消息**:把消息同步到 IM。
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
---
|
||||
title: 配置文件位置
|
||||
pageTitle: 配置文件位置
|
||||
title: 配置数据库位置
|
||||
pageTitle: 配置数据库位置
|
||||
eyebrow: 详细配置
|
||||
lead: 找到 CCR 桌面 App 默认维护的配置文件。
|
||||
lead: 找到 CCR 桌面 App 默认维护的 SQLite 配置数据库。
|
||||
---
|
||||
|
||||
## 默认位置
|
||||
|
||||
- macOS:`~/Library/Application Support/claude-code-router/config.json`
|
||||
- Windows:`%APPDATA%/claude-code-router/config.json`
|
||||
- Linux:`~/.config/claude-code-router/config.json`
|
||||
- macOS/Linux:`~/.claude-code-router/config.sqlite`
|
||||
- Windows:`%APPDATA%\Claude Code Router\config.sqlite`
|
||||
|
||||
## 生效方式
|
||||
|
||||
直接编辑配置文件后,需要重启 CCR。
|
||||
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次,迁移完成后继续编辑 `config.json` 不会影响当前配置。
|
||||
|
||||
建议通过桌面 UI 修改配置,或在 **Settings** 中导出备份。不要在 CCR 运行时直接编辑 `config.sqlite`;SQLite 还会维护同目录的 `config.sqlite-wal` 和 `config.sqlite-shm` 辅助文件。
|
||||
|
|
|
|||
281
docs/src/content/docs/zh/configuration/extensions.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
---
|
||||
title: 扩展机制
|
||||
pageTitle: 扩展机制
|
||||
eyebrow: 详细配置
|
||||
lead: 了解 CCR 扩展如何加载、能注册哪些能力,并从零创建、安装和调试自己的扩展。
|
||||
---
|
||||
|
||||
## 扩展类型
|
||||
|
||||
CCR 的扩展分为两层:
|
||||
|
||||
| 类型 | 配置位置 | 运行位置 | 适合做什么 |
|
||||
| --- | --- | --- | --- |
|
||||
| Wrapper plugin | `plugins` | CCR Desktop 的 Electron wrapper 进程 | 注册本地 HTTP 路由、启动本地后端、拦截代理流量、添加内置浏览器入口、连接 Provider 账号用量 |
|
||||
| Core gateway plugin | `providerPlugins` 或 `plugins[].coreGateway.providerPlugins` | core gateway runtime | 扩展上游 Provider、认证方式或 core gateway 内部能力 |
|
||||
|
||||
多数用户自定义扩展应从 Wrapper plugin 开始。它能拿到 CCR 配置、私有数据目录和日志对象,并通过 `ctx` 注册能力。
|
||||
|
||||
## 加载机制
|
||||
|
||||
启动网关时,CCR 会读取配置里的 `plugins` 数组,并按顺序处理每个 `enabled !== false` 的扩展:
|
||||
|
||||
1. 先应用配置中声明的 `apps`、`proxy.routes`、`coreGateway.providerPlugins`、`coreGateway.virtualModelProfiles` 和 `coreGateway.config`。
|
||||
2. 再加载扩展模块。`module` 可以是绝对路径、`~/` 开头路径、相对配置目录的 `./...` 路径,或 Node 可以解析到的包名。
|
||||
3. 如果没有配置 `module`,CCR 会尝试用扩展 `id` 匹配内置市场扩展,例如 `claude-design` 和 `cursor-proxy`。
|
||||
4. 模块可以导出函数,也可以导出包含 `setup(ctx)` 或 `activate(ctx)` 的对象。
|
||||
5. 扩展停止时,CCR 会反向执行 `stop`、`onStop` 钩子,并关闭该扩展注册的 HTTP 后端和 SQLite store。
|
||||
|
||||
扩展模块常见导出形式:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("extension loaded");
|
||||
},
|
||||
async stop() {
|
||||
// 可选:释放扩展自己持有的资源。
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
也可以直接导出函数:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = async function setup(ctx) {
|
||||
ctx.logger.info(`loaded ${ctx.pluginId}`);
|
||||
};
|
||||
```
|
||||
|
||||
`setup(ctx)` 或 `activate(ctx)` 可以直接调用 `ctx.register...` 方法,也可以返回注册对象。返回对象支持 `apps`、`gatewayRoutes`、`proxyRoutes`、`providerAccountConnectors`、`coreGateway`、`virtualModelProfiles`、`stop` 和 `onStop`。
|
||||
|
||||
## ctx 能力参考
|
||||
|
||||
`setup(ctx)` 的 `ctx` 包含这些常用字段和方法:
|
||||
|
||||
| 字段或方法 | 说明 |
|
||||
| --- | --- |
|
||||
| `ctx.pluginId` | 当前扩展 ID |
|
||||
| `ctx.pluginConfig` | `plugins[].config` 中的自定义配置 |
|
||||
| `ctx.config` | 当前 CCR AppConfig 快照 |
|
||||
| `ctx.logger` | 带 `[plugin:<id>]` 前缀的 `debug/info/warn/error` 日志 |
|
||||
| `ctx.paths.configDir` | CCR 配置目录 |
|
||||
| `ctx.paths.dataDir` | CCR 数据目录 |
|
||||
| `ctx.paths.pluginDataDir` | 当前扩展专属数据目录 |
|
||||
| `ctx.registerGatewayRoute(route)` | 在 CCR 网关上注册本地 HTTP 路由 |
|
||||
| `ctx.registerHttpBackend(backend)` | 启动一个本地 HTTP 后端,返回 `{ url, host, port }` |
|
||||
| `ctx.registerProxyRoute(route)` | 把代理模式捕获到的某个 host/path 转发到扩展后端或其他 upstream |
|
||||
| `ctx.registerApp(app)` | 在内置浏览器应用列表里添加入口 |
|
||||
| `ctx.openSqliteStore(options)` | 在扩展数据目录打开 SQLite store |
|
||||
| `ctx.registerProviderAccountConnector(connector)` | 注册 Provider 账号余额或额度读取器 |
|
||||
| `ctx.registerCoreGatewayProviderPlugin(plugin)` | 向 core gateway 注入 provider plugin |
|
||||
| `ctx.registerCoreGatewayVirtualModelProfile(profile)` | 向 core gateway 注入虚拟模型配置 |
|
||||
|
||||
Gateway route handler 会额外收到 helper:
|
||||
|
||||
| Helper | 说明 |
|
||||
| --- | --- |
|
||||
| `helpers.readBody(request)` | 读取请求 body,返回 `Buffer` |
|
||||
| `helpers.readJson(request)` | 读取并解析 JSON body |
|
||||
| `helpers.sendJson(response, statusCode, body)` | 返回 JSON 响应 |
|
||||
|
||||
`registerGatewayRoute` 默认使用 `auth: "gateway"`。如果 CCR 配置了 API Key,请求必须带 `Authorization: Bearer <key>` 或 `x-api-key: <key>`。仅调试或本地公开状态页建议使用 `auth: "none"`。
|
||||
|
||||
## 创建第一个扩展
|
||||
|
||||
创建一个目录,例如 `~/ccr-extensions/hello-extension`:
|
||||
|
||||
```text
|
||||
hello-extension/
|
||||
plugin.json
|
||||
index.cjs
|
||||
```
|
||||
|
||||
`plugin.json` 用于让 CCR 的本地扩展选择器识别扩展 ID、名称和入口文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hello-extension",
|
||||
"name": "Hello Extension",
|
||||
"module": "index.cjs",
|
||||
"apps": [
|
||||
{
|
||||
"id": "hello-status",
|
||||
"name": "Hello Status",
|
||||
"url": "http://127.0.0.1:3456/plugins/hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`index.cjs` 注册一个状态路由、一个 echo 后端,以及一个代理转发规则:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async setup(ctx) {
|
||||
ctx.registerGatewayRoute({
|
||||
auth: "none",
|
||||
id: "hello-status",
|
||||
method: "GET",
|
||||
path: "/plugins/hello",
|
||||
handler(_request, response, helpers) {
|
||||
helpers.sendJson(response, 200, {
|
||||
ok: true,
|
||||
plugin: ctx.pluginId,
|
||||
message: ctx.pluginConfig?.message || "hello from CCR"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const backend = await ctx.registerHttpBackend({
|
||||
id: "hello-echo",
|
||||
async handler(request, response, helpers) {
|
||||
const body = request.method === "POST"
|
||||
? (await helpers.readBody(request)).toString("utf8")
|
||||
: "";
|
||||
|
||||
helpers.sendJson(response, 200, {
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
body
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.registerProxyRoute({
|
||||
host: "api.example.local",
|
||||
id: "hello-example-api",
|
||||
paths: ["/v1"],
|
||||
preserveHost: true,
|
||||
upstream: backend.url
|
||||
});
|
||||
|
||||
ctx.logger.info(`hello backend listening at ${backend.url}`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
这个扩展会暴露:
|
||||
|
||||
- `GET /plugins/hello`:直接挂在 CCR 网关上,用来验证扩展是否加载。
|
||||
- 一个本地 echo 后端:由 CCR 自动分配端口。
|
||||
- 一个代理规则:当代理模式捕获到 `api.example.local/v1...` 时转发到 echo 后端。
|
||||
|
||||
## 安装扩展
|
||||
|
||||
推荐使用桌面 UI 安装本地扩展:
|
||||
|
||||
1. 打开 **Extensions** 页面。
|
||||
2. 点击添加扩展,选择本地扩展目录。
|
||||
3. 选择刚创建的 `hello-extension` 目录。
|
||||
4. 保存配置。
|
||||
5. 打开 **Server** 页面,重启网关。
|
||||
|
||||
CCR 的运行配置存储在 SQLite 中。请通过 UI 添加扩展;旧版 JSON 配置文件仅用于参考。扩展条目的配置结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "hello-extension",
|
||||
"enabled": true,
|
||||
"module": "/Users/you/ccr-extensions/hello-extension/index.cjs",
|
||||
"config": {
|
||||
"message": "hello from my config"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
保存扩展配置后需要重启网关。配置数据库位置见 [配置数据库位置](/configuration/config-file/)。
|
||||
|
||||
本地目录选择器会按顺序识别这些入口信息:
|
||||
|
||||
- `plugin.json`
|
||||
- `ccr-plugin.json`
|
||||
- `.ccr-plugin/plugin.json`
|
||||
- `.codex-plugin/plugin.json`
|
||||
- `package.json` 里的 `main`、`ccr.module` 或 `ccrPlugin.module`
|
||||
|
||||
如果没有显式入口文件,CCR 会尝试目录里的 `index.cjs`、`index.mjs`、`index.js`、`plugin.cjs`、`plugin.mjs` 或 `plugin.js`。
|
||||
|
||||
## 调试扩展
|
||||
|
||||
### 1. 先做语法检查
|
||||
|
||||
CommonJS 扩展可以运行:
|
||||
|
||||
```bash
|
||||
node --check ~/ccr-extensions/hello-extension/index.cjs
|
||||
```
|
||||
|
||||
如果扩展依赖 npm 包,先在扩展目录安装依赖,并确保入口文件能被 Node 解析。
|
||||
|
||||
### 2. 用源码模式启动 CCR
|
||||
|
||||
在 CCR 仓库根目录运行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
扩展里的 `ctx.logger.info/warn/error` 会出现在启动 CCR 的终端中,前缀类似 `[plugin:hello-extension]`。
|
||||
|
||||
### 3. 验证 Gateway route
|
||||
|
||||
启动网关后,请求状态路由:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
如果路由使用默认的 `auth: "gateway"`,并且 CCR 已配置 API Key:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
也可以使用:
|
||||
|
||||
```bash
|
||||
curl -H "x-api-key: <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
### 4. 验证 HTTP 后端和代理规则
|
||||
|
||||
`registerHttpBackend` 返回的 `backend.url` 会写入日志。先直接请求这个地址,确认后端工作正常;再开启代理模式,验证目标 host/path 是否被 `registerProxyRoute` 命中。
|
||||
|
||||
代理规则匹配逻辑:
|
||||
|
||||
- `host` 必须匹配目标 hostname,支持精确 host、`.example.com` 后缀和 `*.example.com` 通配。
|
||||
- `paths` 为空时匹配该 host 的所有路径。
|
||||
- 多个路径匹配时,CCR 会选最长的 path prefix。
|
||||
- `stripPathPrefix` 会从转发路径中移除匹配前缀。
|
||||
- `rewritePathPrefix` 会把匹配前缀替换成指定前缀。
|
||||
|
||||
### 5. 常见问题
|
||||
|
||||
| 现象 | 排查方向 |
|
||||
| --- | --- |
|
||||
| 扩展没有加载 | 检查 `plugins[].enabled`、`plugins[].module` 路径和终端里的 `[plugin:<id>]` 报错 |
|
||||
| `GET /plugins/hello` 返回 404 | 确认网关已重启,路由 `path` 或 `pathPrefix` 是否以 `/` 开头 |
|
||||
| 返回 401 | 路由默认需要 gateway API Key;调试路由可显式设置 `auth: "none"` |
|
||||
| 修改代码不生效 | Wrapper plugin 不会热重载,修改后需要重启网关或重启 CCR |
|
||||
| 端口被占用 | `registerHttpBackend` 不传 `port` 会自动分配端口;固定端口冲突时改回自动分配 |
|
||||
| 代理规则不命中 | 检查代理模式是否开启、证书是否安装、host 是否匹配真实请求的 hostname |
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 只有状态页、健康检查或本机调试路由才使用 `auth: "none"`。
|
||||
- 不要在日志里打印 API Key、OAuth token、Cookie 或完整请求头。
|
||||
- 扩展写入文件时优先使用 `ctx.paths.pluginDataDir`。
|
||||
- 对 `readJson` 得到的外部输入做类型校验。
|
||||
- 代理转发到外部 upstream 时,明确处理 header 白名单,避免把本地鉴权信息转发到不可信服务。
|
||||
|
|
@ -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 模型接入团队内部工具。
|
||||
|
||||
## 适用能力
|
||||
|
||||
|
|
|
|||
131
docs/src/content/docs/zh/configuration/overview.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
---
|
||||
title: 概览仪表盘
|
||||
pageTitle: 概览仪表盘
|
||||
eyebrow: 详细配置
|
||||
lead: 自定义 CCR 首页仪表盘,查看系统状态、账户余额、请求量、令牌、成本和模型分布。
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
| 场景 | 看什么 |
|
||||
| --- | --- |
|
||||
| 检查网关是否正常 | 系统状态、成功率、错误数、平均延迟 |
|
||||
| 估算今日或近期消耗 | 请求数、输入 / 输出 / 缓存令牌、估算成本 |
|
||||
| 比较上游使用情况 | 供应商分析、模型分布、客户端分析 |
|
||||
| 关注账户额度 | 账户余额、套餐额度、剩余额度、账户状态 |
|
||||
| 汇报或分享用量 | AI 用量年报、CCR 路由图、模型排行榜、消费小票等分享卡片 |
|
||||
|
||||
## 时间范围
|
||||
|
||||
顶部的 `按时间查看用量` 控制所有依赖用量统计的组件。切换后,请求、令牌、成本、趋势、分布和分享卡片会按新的窗口重新聚合。
|
||||
|
||||
| 选项 | 统计窗口 |
|
||||
| --- | --- |
|
||||
| `今天` | 当前本地日期从 00:00 到现在,按小时分桶。 |
|
||||
| `24 小时` | 最近 24 小时,按小时分桶。 |
|
||||
| `7 天` | 最近 7 天,按天分桶。 |
|
||||
| `30 天` | 最近 30 天,按天分桶。 |
|
||||
|
||||
账户余额组件不按这个时间窗口重算,它展示供应商账户连接器最近一次获取到的快照。
|
||||
|
||||
## 编辑布局
|
||||
|
||||
点击右上角铅笔按钮进入编辑模式。编辑模式分为三栏:
|
||||
|
||||
| 区域 | 作用 |
|
||||
| --- | --- |
|
||||
| 组件 | 左侧组件库,点击任一组件即可添加到当前仪表盘。 |
|
||||
| 预览 | 中间布局预览。可以拖拽组件排序,也可以点击组件选中它。 |
|
||||
| 组件属性 | 右侧属性面板,用于修改类型、数据、尺寸、样式或移除组件。 |
|
||||
|
||||
常用操作:
|
||||
|
||||
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` | 卡片 | 卡片 |
|
||||
|
||||
尺寸约束:
|
||||
|
||||
| 规则 | 原因 |
|
||||
| --- | --- |
|
||||
| 分享卡片最小高度是 `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 中重试,确保卡片可见且没有被缩到过小。 |
|
||||
|
|
@ -5,11 +5,16 @@ eyebrow: 详细配置
|
|||
lead: 为 Claude Code、Codex、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 或 ZCode。
|
||||
|
||||
## 多开机制
|
||||
|
||||
|
|
@ -26,20 +31,80 @@ Agent配置是桌面 App 中管理 Claude Code、Codex、ZCode 启动入口的
|
|||
|
||||
## 常用选项
|
||||
|
||||
| 选项 | 说明 |
|
||||
| 选项 | 适用范围 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 全部 | 选择 Claude Code、Codex 或 ZCode。ZCode 只支持 App。 |
|
||||
| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 |
|
||||
| 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 |
|
||||
| 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 |
|
||||
| 入口模式 | Claude Code、Codex | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。 |
|
||||
| 模型 | 全部 | 该 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 可识别的模型项,并用显示名称保留真实模型含义。 |
|
||||
|
||||
### 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 或 Codex App。Claude Code 专用的模型发现变量不会传给 Codex。 |
|
||||
| Bot | 只在 Codex App 入口生效。 |
|
||||
|
||||
保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。Codex App 使用播放图标打开。CCR 会生成 `config.toml`、模型目录文件和中间层启动器,让 Codex CLI 与 Codex App 都使用同一套 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 <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 |
|
||||
| App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、多实例并存、Bot 消息转发或接力 | 每个 Agent配置使用独立用户数据目录;同一配置重复打开会激活已有窗口,不同配置可以并行打开。 |
|
||||
| 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 实例。
|
||||
|
||||
|
|
|
|||
295
docs/src/content/docs/zh/configuration/provider-deeplink.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
---
|
||||
title: 一键导入供应商
|
||||
pageTitle: 一键导入供应商
|
||||
eyebrow: 详细配置
|
||||
lead: 快速添加常见模型供应商,确认无误后即可保存,减少手动配置的繁琐步骤。
|
||||
---
|
||||
|
||||
## 一键导入
|
||||
|
||||
选择下面的供应商即可开始添加。CCR 会先显示即将添加的内容,确认无误后再保存;使用自定义入口时,请确保来源可信。
|
||||
|
||||
<div class="provider-import-grid" aria-label="Preset provider import buttons">
|
||||
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&base_url=https%3A%2F%2Fapi.openai.com%2Fv1&protocol=openai_responses&models=gpt-5.5%2Cgpt-5.5-pro%2Cgpt-5.5-instant%2Cgpt-5.4-mini" aria-label="导入 OpenAI 官方供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/openai.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">OpenAI 官方</span><span class="provider-import-meta">Responses / Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-anthropic" href="ccr://provider?name=Anthropic&base_url=https%3A%2F%2Fapi.anthropic.com&protocol=anthropic_messages&models=claude-fable-5%2Cclaude-opus-4-8%2Cclaude-sonnet-4-6%2Cclaude-haiku-4-5" aria-label="导入 Anthropic 官方供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/anthropic.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Anthropic 官方</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-gemini" href="ccr://provider?name=Google+Gemini&base_url=https%3A%2F%2Fgenerativelanguage.googleapis.com&protocol=gemini_generate_content&models=gemini-3.5-flash%2Cgemini-3.1-pro-preview%2Cgemini-3-flash-preview" aria-label="导入谷歌 Gemini 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/gemini.svg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">谷歌 Gemini</span><span class="provider-import-meta">Gemini Generate Content</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-openrouter" href="ccr://provider?name=OpenRouter&base_url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&protocol=openai_chat_completions&models=%7Eopenai%2Fgpt-latest%2C%7Eanthropic%2Fclaude-opus-latest%2C%7Eanthropic%2Fclaude-sonnet-latest%2Cgoogle%2Fgemini-3.5-flash%2Cz-ai%2Fglm-5.2" aria-label="导入 OpenRouter 路由供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/openrouter.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">OpenRouter 路由</span><span class="provider-import-meta">OpenAI compatible gateway</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-deepseek" href="ccr://provider?name=DeepSeek&base_url=https%3A%2F%2Fapi.deepseek.com&protocol=openai_chat_completions&models=deepseek-v4-pro%2Cdeepseek-v4-flash%2Cdeepseek-v3.2%2Cdeepseek-reasoner%2Cdeepseek-chat" aria-label="导入 DeepSeek 深度求索供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/deepseek.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">DeepSeek 深度求索</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zhipu-coding" href="ccr://provider?name=Zhipu+AI+%28China%29+-+Coding+Plan&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5-turbo%2Cglm-5v-turbo%2Cglm-4.7" aria-label="导入智谱 Coding 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/zhipu-cn-coding.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">智谱 Coding</span><span class="provider-import-meta">中国 Coding 计划</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zhipu-general" href="ccr://provider?name=Zhipu+AI+%28China%29+-+General+Endpoint&base_url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5%2Cglm-5v-turbo%2Cglm-4.7" aria-label="导入智谱通用供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/zhipu-cn-general.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">智谱通用</span><span class="provider-import-meta">中国通用端点</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zai-coding" href="ccr://provider?name=Z.ai+%28Global%29+-+Coding+Plan&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fcoding%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5-turbo%2Cglm-5v-turbo%2Cglm-4.7" aria-label="导入智谱国际 Coding 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/zai-global-coding.svg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">智谱国际 Coding</span><span class="provider-import-meta">全球 Coding 计划</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-zai-general" href="ccr://provider?name=Z.ai+%28Global%29+-+General+Endpoint&base_url=https%3A%2F%2Fapi.z.ai%2Fapi%2Fpaas%2Fv4&protocol=openai_chat_completions&models=glm-5.2%2Cglm-5.1%2Cglm-5%2Cglm-5v-turbo%2Cglm-4.7" aria-label="导入智谱国际通用供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/zai-global-general.svg" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">智谱国际通用</span><span class="provider-import-meta">全球通用端点</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-mistral" href="ccr://provider?name=Mistral&base_url=https%3A%2F%2Fapi.mistral.ai%2Fv1&protocol=openai_chat_completions&models=mistral-medium-3-5%2Cmistral-large-3%2Cministral-3-14b-instruct-2512%2Cdevstral-2512" aria-label="导入 Mistral 官方供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Mistral 官方</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Kimi+API+%28China%29&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入 Kimi API 国内供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API(国内)</span><span class="provider-import-meta">国内平台</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot-global" href="ccr://provider?name=Kimi+API+%28Global%29&base_url=https%3A%2F%2Fapi.moonshot.ai%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入 Kimi API 海外供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API(海外)</span><span class="provider-import-meta">海外平台</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-kimi-coding" href="ccr://provider?name=Kimi+Code+-+Coding+Plan&base_url=https%3A%2F%2Fapi.kimi.com%2Fcoding%2Fv1&protocol=openai_chat_completions&models=kimi-for-coding" aria-label="导入 Kimi Code Coding Plan 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi Code</span><span class="provider-import-meta">Coding Plan</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-bailian" href="ccr://provider?name=Alibaba+Bailian&base_url=https%3A%2F%2Fdashscope.aliyuncs.com%2Fcompatible-mode%2Fv1&protocol=openai_chat_completions&models=qwen3.7-max%2Cqwen3.7-plus%2Cqwen3.6-max-preview%2Cqwen3-coder-plus%2Cqwen3-max" aria-label="导入阿里百炼供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/bailian.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">阿里百炼</span><span class="provider-import-meta">DashScope 兼容</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-siliconflow" href="ccr://provider?name=SiliconFlow&base_url=https%3A%2F%2Fapi.siliconflow.cn%2Fv1&protocol=openai_chat_completions&models=zai-org%2FGLM-5.2%2Cdeepseek-ai%2Fdeepseek-v4-pro%2Cdeepseek-ai%2Fdeepseek-v4-flash%2Czai-org%2FGLM-5.1%2Cdeepseek-ai%2FDeepSeek-V3.2" aria-label="导入硅基流动供应商">
|
||||
<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-code0" href="ccr://provider?name=code0.ai&base_url=https%3A%2F%2Fconsole.code0.ai&protocol=anthropic_messages&source=https%3A%2F%2Fcode0.ai%3Fsource%3Dclaudecoderouter" aria-label="导入 code0.ai 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/code0.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">code0.ai</span><span class="provider-import-meta">Anthropic / Chat / Responses</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-claudeapi" href="ccr://provider?name=claudeapi&base_url=https%3A%2F%2Fgw.claudeapi.com&protocol=anthropic_messages&source=https%3A%2F%2Fwww.claudeapi.com%3Fsource%3Dclaudecoderouter" aria-label="导入 claudeapi 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/claudeapi.png" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">claudeapi</span><span class="provider-import-meta">Anthropic Messages</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 嵌入式按钮组件
|
||||
|
||||
CCR 也提供了一个无框架的按钮脚本,供应商可以嵌入到自己的网页,让用户一键把该供应商导入 CCR。脚本会自动注册 Web Components。
|
||||
|
||||
### HTML 写法
|
||||
|
||||
```html
|
||||
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
|
||||
|
||||
<ccr-provider-button
|
||||
name="Example AI"
|
||||
base_url="https://api.example.com/v1"
|
||||
protocol="openai_chat_completions"
|
||||
models="example-chat,example-coder"
|
||||
icon="https://example.com/icon.png"
|
||||
source="https://example.com"
|
||||
></ccr-provider-button>
|
||||
```
|
||||
|
||||
如果配置较大,可以只传 manifest:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js" defer></script>
|
||||
|
||||
<ccr-provider-button
|
||||
name="Example AI"
|
||||
manifest="https://example.com/.well-known/ccr-provider.json"
|
||||
></ccr-provider-button>
|
||||
```
|
||||
|
||||
### JS 写法
|
||||
|
||||
```html
|
||||
<div id="ccr-buttons"></div>
|
||||
<script src="https://cdn.ccrdesk.top/ccr-provider-buttons.js"></script>
|
||||
<script>
|
||||
CCRProviderButtons.render("#ccr-buttons", {
|
||||
name: "Example AI",
|
||||
base_url: "https://api.example.com/v1",
|
||||
api_key: "sk-user-key",
|
||||
protocol: "openai_chat_completions",
|
||||
models: ["example-chat", "example-coder"],
|
||||
icon: "https://example.com/icon.png",
|
||||
source: "https://example.com"
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### render 参数
|
||||
|
||||
`CCRProviderButtons.render(target, options)` 和 `<ccr-provider-button>` 支持同一组参数,参数名与 `ccr://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`、`gemini_interactions` |
|
||||
| `models` | 模型列表。HTML 中用逗号或换行分隔,JS 中可传字符串或数组 |
|
||||
| `icon` | Provider 图标 URL |
|
||||
| `source` | Provider 官网或配置来源 |
|
||||
| `manifest` | 远程 manifest URL。传入后按钮会生成 manifest 导入链接 |
|
||||
| `payload` | JSON 或 base64url JSON 配置。JS 中也可以传对象 |
|
||||
| `usage_url` | 可选账号用量接口 |
|
||||
| `fetch_usage` | 是否启用账号用量读取 |
|
||||
| `usage_method` | 用量接口请求方法,`GET` 或 `POST` |
|
||||
| `usage_headers` | 用量接口请求头。JS 中可传对象,HTML 中传 JSON 字符串 |
|
||||
| `usage_body` | 用量接口请求体。JS 中可传对象,HTML 中传 JSON 字符串 |
|
||||
| `balance` | 余额字段路径 |
|
||||
| `balance_unit` | 余额单位 |
|
||||
| `subscription` | 订阅剩余额度字段路径 |
|
||||
| `subscription_limit` | 订阅总额度字段路径 |
|
||||
| `subscription_reset` | 订阅重置时间字段路径 |
|
||||
| `subscription_unit` | 订阅额度单位 |
|
||||
| `subscription_window` | 订阅窗口,例如 `monthly` |
|
||||
|
||||
## 协议格式
|
||||
|
||||
CCR 支持两种写法,推荐使用 host 写法:
|
||||
|
||||
```text
|
||||
ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&protocol=openai_chat_completions&models=example-chat%2Cexample-coder
|
||||
```
|
||||
|
||||
路径写法也可以被识别:
|
||||
|
||||
```text
|
||||
ccr:///provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1
|
||||
```
|
||||
|
||||
如果配置较大,可以把 JSON 放入 `payload` 参数。值可以是 URL 编码 JSON,也可以是 base64url JSON:
|
||||
|
||||
```text
|
||||
ccr://provider?payload=%7B%22name%22%3A%22Example%20AI%22%2C%22base_url%22%3A%22https%3A%2F%2Fapi.example.com%2Fv1%22%2C%22models%22%3A%5B%22example-chat%22%5D%7D
|
||||
```
|
||||
|
||||
## Manifest 导入
|
||||
|
||||
供应商也可以只传一个 manifest URL:
|
||||
|
||||
```text
|
||||
ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr-provider.json
|
||||
```
|
||||
|
||||
Manifest 必须使用 HTTPS,返回 JSON,不能指向本地或内网地址,体积不能超过 128 KB。CCR 会在 App 内拉取 manifest,展示确认页,然后再写入配置。
|
||||
|
||||
Manifest 可以把供应商信息放在顶层 `provider` 对象中:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `provider.name` | Provider 展示名称 |
|
||||
| `provider.base_url` | Provider API Base URL,必填 |
|
||||
| `provider.protocol` | 协议类型 |
|
||||
| `provider.models` | 模型列表,字符串数组 |
|
||||
| `provider.icon` | Provider 图标 URL |
|
||||
| `provider.source` | Provider 官网或配置来源 |
|
||||
| `provider.account.enabled` | 是否启用账号用量读取 |
|
||||
| `provider.account.refreshIntervalMs` | 用量刷新间隔,单位毫秒 |
|
||||
| `provider.account.connectors` | 用量读取 connector 列表 |
|
||||
| `provider.account.connectors[].type` | Connector 类型,常用 `http-json` |
|
||||
| `provider.account.connectors[].auth` | 认证方式,常用 `provider-api-key` |
|
||||
| `provider.account.connectors[].endpoint` | 用量接口 URL |
|
||||
| `provider.account.connectors[].method` | 请求方法,`GET` 或 `POST` |
|
||||
| `provider.account.connectors[].headers` | 请求头,不能包含敏感认证头 |
|
||||
| `provider.account.connectors[].body` | 可选请求体 |
|
||||
| `provider.account.connectors[].mapping.meters` | 用量指标映射列表 |
|
||||
|
||||
完整 manifest 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"name": "Example AI",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"protocol": "openai_chat_completions",
|
||||
"models": ["example-chat", "example-coder"],
|
||||
"icon": "https://example.com/icon.png",
|
||||
"source": "https://example.com",
|
||||
"account": {
|
||||
"enabled": true,
|
||||
"refreshIntervalMs": 300000,
|
||||
"connectors": [
|
||||
{
|
||||
"type": "http-json",
|
||||
"auth": "provider-api-key",
|
||||
"endpoint": "https://api.example.com/v1/account/usage",
|
||||
"method": "GET",
|
||||
"headers": {
|
||||
"accept": "application/json"
|
||||
},
|
||||
"mapping": {
|
||||
"meters": [
|
||||
{
|
||||
"id": "balance",
|
||||
"kind": "balance",
|
||||
"label": "Balance",
|
||||
"remaining": "data.balance.remaining",
|
||||
"unit": "USD"
|
||||
},
|
||||
{
|
||||
"id": "subscription",
|
||||
"kind": "subscription",
|
||||
"label": "Monthly quota",
|
||||
"remaining": "data.quota.remaining",
|
||||
"limit": "data.quota.limit",
|
||||
"resetAt": "data.quota.reset_at",
|
||||
"unit": "tokens",
|
||||
"window": "monthly"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `name` | Provider 展示名称 |
|
||||
| `base_url` | Provider API Base URL,必填 |
|
||||
| `api_key` | 可选 Provider API Key |
|
||||
| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content`、`gemini_interactions` |
|
||||
| `models` | 模型列表,支持逗号或换行分隔,也可以重复传入 |
|
||||
| `icon` | Provider 图标 URL |
|
||||
| `source` | Provider 官网或配置来源 |
|
||||
| `manifest` | 远程 manifest URL |
|
||||
| `payload` | JSON 或 base64url JSON 配置 |
|
||||
| `usage_url` | 可选账号用量接口 |
|
||||
| `fetch_usage` | 是否启用账号用量读取 |
|
||||
| `usage_method` | 用量接口请求方法,`GET` 或 `POST` |
|
||||
| `usage_headers` | 用量接口请求头,JSON 字符串 |
|
||||
| `usage_body` | 用量接口请求体,JSON 字符串 |
|
||||
| `balance` | 余额字段路径 |
|
||||
| `balance_unit` | 余额单位 |
|
||||
| `subscription` | 订阅剩余额度字段路径 |
|
||||
| `subscription_limit` | 订阅总额度字段路径 |
|
||||
| `subscription_reset` | 订阅重置时间字段路径 |
|
||||
| `subscription_unit` | 订阅额度单位 |
|
||||
| `subscription_window` | 订阅窗口,例如 `monthly` |
|
||||
|
||||
参数名和协议值必须使用上表中的完整规范名。不再接受 `baseUrl`、`apiKey`、`model`、`type` 或 `openai` 等别名。
|
||||
|
|
@ -5,22 +5,166 @@ eyebrow: 详细配置
|
|||
lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列表和凭据。
|
||||
---
|
||||
|
||||
## 基本概念
|
||||
## 导入本机 Agent 登录态
|
||||
|
||||
供应商是 CCR 转发请求的上游模型服务。每个供应商至少需要协议、基础 URL、模型列表和一条可用凭据。
|
||||
添加供应商时,CCR 会扫描本机已有的 Agent 登录状态。检测到可复用凭据后,添加弹窗会显示对应导入入口。导入会创建一个普通供应商和配套 provider plugin,让 CCR 复用本机 Agent 授权访问上游服务,不需要再手动粘贴常规 API Key。
|
||||
|
||||
### Claude Code
|
||||
|
||||
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 协议的上游服务。 |
|
||||
| 名称 | CCR 内部显示名,也是路由、模型选择、日志和配置中识别供应商的名字。名称必须唯一,建议短且稳定。 |
|
||||
| API 地址 | 上游 API Base URL。它决定请求实际发往哪里,也用于协议探测、模型列表探测、图标探测和安全校验。预设供应商添加时默认隐藏该字段,可在高级设置里覆盖。自定义供应商必须填写。 |
|
||||
| API 密钥 | 默认供应商凭据。没有配置凭据池时,模型请求会使用这条 Key;协议探测、模型探测、连通性检测和默认用量读取也会使用它。只填写由当前 API 地址对应供应商签发的 Key。 |
|
||||
| 模型 | 暴露给 CCR 的模型 ID 列表。路由规则、Profile 模型选择、模型目录和客户端 `/models` 响应都会基于这里的模型。 |
|
||||
| 搜索模型 / 全部 / 清除 | 当 CCR 能从上游或模型目录拿到模型列表时,可以搜索、全选、清除并勾选模型。勾选结果会保存到供应商的模型列表。 |
|
||||
| 自定义模型 | 手动添加没有被探测出来的模型 ID。适合供应商没有 `/models` 接口,或新模型还未进入模型目录的情况。 |
|
||||
| 检测连通性 | 用当前 API 地址、API 密钥、协议和所选模型发送真实测试请求。它可以验证 Key、模型名和协议是否真的可调用。 |
|
||||
| 要检测的模型 | 连接检查确认弹窗中的模型选择。用于控制只测试部分模型,避免一次性检查全部模型造成额外消耗。 |
|
||||
| 检测结果 | 展示每个模型是否可用、命中的协议和上游返回的诊断信息。可用结果不会自动增加模型,仍以弹窗主表单中的模型选择为准。 |
|
||||
|
||||
## 连通性检测
|
||||
|
||||
`检测连通性` 会对你选择的模型发送真实的模型请求,用来确认 API 地址、API 密钥、协议和模型 ID 是否可用。检测请求会限制输出长度,但仍然可能产生额外 token 消耗或计入供应商侧请求次数。
|
||||
|
||||
如果供应商按请求、输入 token 或输出 token 计费,建议只勾选需要确认的模型,不要一次性检查全部模型。检测结果只用于诊断连通性,不会自动修改模型列表或用量读取配置。
|
||||
|
||||
## 凭据
|
||||
|
||||
凭据用来保存 API Key。多条凭据可以按优先级和权重轮换,也可以在某条 Key 触发限额或失败时自动切换。
|
||||
`API 密钥` 是最简单的单 Key 配置。需要管理多条上游 Key 时,展开“高级设置”中的“凭据池”。
|
||||
|
||||
建议给每条 Key 设置容易识别的名称,方便在请求日志里定位问题。
|
||||
|
||||
## 供应商选项
|
||||
|
||||
| 字段 | 说明 |
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 名称 | CCR 内部显示名,建议短且可识别 |
|
||||
| 基础 URL | 上游服务地址,自定义供应商要确认包含正确 API 路径 |
|
||||
| 协议 | OpenAI / Anthropic / Gemini 等协议 |
|
||||
| 模型 | 暴露给 CCR 的模型列表 |
|
||||
| 请求头 | 少数兼容服务需要的额外请求头 |
|
||||
| 显示凭据配置 | 展开或收起凭据池配置。未展开不影响已保存的凭据,只是隐藏编辑区域。 |
|
||||
| 导入 JSON | 从 JSON 文件批量导入凭据。支持顶层数组,或对象中的 `credentials`、`keys`、`apiKeys` 数组。 |
|
||||
| 添加 Key | 新增一条上游 API Key。 |
|
||||
| 启用 | 控制单条凭据是否参与请求转发和用量读取。关闭后保留配置,但不会被选中。 |
|
||||
| 名称 | 单条凭据的显示名。会出现在账号用量、日志和内部诊断里,建议写成可识别的用途或额度来源。 |
|
||||
| API 密钥 | 该凭据实际发送给上游的 Key。配置了凭据池后,CCR 会把启用的凭据展开成多个内部上游目标,并优先使用凭据池中的 Key。主表单的默认 `API 密钥` 只作为单 Key 配置使用。 |
|
||||
| 移除 | 删除当前凭据行。 |
|
||||
| Key 高级选项 | 展开单条凭据的调度和限额字段。 |
|
||||
| 优先级 | 凭据优先级,数字越小越优先。未填写时按凭据行顺序作为优先级。 |
|
||||
| 权重 | 同优先级、相近使用率下的排序权重,数字越大越优先。未填写时为 `1`。 |
|
||||
| 限制 JSON | 该 Key 的本地限额规则。CCR 会按请求、tokens 或图片数量统计窗口使用量,达到上限后自动跳过该 Key,尝试同供应商的其他可用 Key。 |
|
||||
|
||||
`限制 JSON` 支持的常用字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `rpm` / `rph` / `rpd` | 每分钟 / 每小时 / 每天最多请求数 |
|
||||
| `tpm` / `tph` / `tpd` | 每分钟 / 每小时 / 每天最多 tokens |
|
||||
| `ipm` / `iph` / `ipd` | 每分钟 / 每小时 / 每天最多图片数 |
|
||||
| `maxRequests` + `windowMs` | 自定义时间窗口内最多请求数 |
|
||||
| `maxTokens` + `quotaWindowMs` | 自定义时间窗口内最多 tokens |
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"rpm": 60,
|
||||
"tpm": 100000
|
||||
}
|
||||
```
|
||||
|
||||
凭据池的作用是“上游 Key 池”,不同于“API 密钥”页面里的 CCR 客户端访问 Key。前者控制 CCR 调用供应商时使用哪个 Key,后者控制客户端访问 CCR 时使用哪个 Key。
|
||||
|
||||
## 用量读取
|
||||
|
||||
“获取用量”用于让 CCR 在供应商列表、托盘或账号面板中展示余额、套餐额度、状态和错误信息。它不会影响模型是否能请求,只影响账号用量展示。
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 获取用量 | 启用或关闭该供应商的账号用量读取。关闭后不请求用量接口。 |
|
||||
| 用量模式 | 用量读取方式。`标准用量端点` 使用 CCR 标准账号端点;`HTTP JSON 请求` 手动配置一个 JSON 接口;`原始连接器 JSON` 直接编辑 connector 数组。 |
|
||||
| 刷新间隔(毫秒) | 用量刷新间隔,单位毫秒。未填写时使用默认刷新间隔,最小有效间隔为 30000ms。 |
|
||||
|
||||
### 标准用量端点
|
||||
|
||||
该模式会尝试供应商侧的 CCR 标准账号端点,例如 `/.well-known/ccr/account` 和 `/v1/account/limits`。适合已经适配 CCR 标准格式的供应商或内置预设。
|
||||
|
||||
### HTTP JSON 请求
|
||||
|
||||
该模式适合供应商已有自己的余额或额度接口,且返回自定义 JSON 格式的情况。
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 方法 | 用量请求方法,支持 `GET` 或 `POST`。 |
|
||||
| 用量请求 URL | 用量接口地址。可以是完整 URL。请求会附带供应商 API Key,除非在 raw connector 中改成其他认证方式。 |
|
||||
| 请求头 | 用量接口需要的额外请求头。不要在这里写固定的敏感认证头,优先使用供应商 API Key 认证。 |
|
||||
| 请求体 | `POST` 请求体,必须是合法 JSON。 |
|
||||
| 余额剩余字段 | 余额剩余值在响应 JSON 中的路径。 |
|
||||
| 余额总额字段 | 余额总量或充值总额在响应 JSON 中的路径。 |
|
||||
| 余额已用字段 | 已用余额在响应 JSON 中的路径。 |
|
||||
| 余额单位 | 余额单位,例如 `USD`、`CNY` 或 `%`。 |
|
||||
| 订阅剩余字段 | 套餐、订阅、tokens 或配额剩余量路径。 |
|
||||
| 订阅上限字段 | 套餐、订阅、tokens 或配额总量路径。 |
|
||||
| 订阅重置字段 | 套餐重置时间路径。可以返回 ISO 时间,也可以返回秒级或毫秒级时间戳。 |
|
||||
| 订阅单位 | 套餐单位,例如 `tokens`、`requests`、`hours`。 |
|
||||
| 状态字段 | 账号状态路径。支持 `ok`、`warning`、`critical`、`error`、`unsupported`。 |
|
||||
| 消息字段 | 账号提示信息路径。适合展示供应商返回的错误、套餐说明或风控提示。 |
|
||||
| 测试用量请求 | 立即请求用量接口并解析映射结果,方便在保存前验证字段路径。 |
|
||||
| 响应字段 | 测试后列出响应 JSON 中可选字段。点击 `余额剩余`、`余额总额`、`余额已用`、`订阅剩余`、`订阅上限`、`重置时间` 可以把该路径快速填入对应字段。 |
|
||||
|
||||
字段路径支持 CCR 的轻量 JSONPath 语法:
|
||||
|
||||
| 写法 | 说明 |
|
||||
| --- | --- |
|
||||
| `$` | 整个响应对象 |
|
||||
| `$.balance.remaining` | 读取对象字段 |
|
||||
| `$.items[0].value` | 读取数组下标 |
|
||||
| `$["weird-key"]` | 读取包含特殊字符的字段名 |
|
||||
| `$.limits[?(@.type=="TOKENS")].remaining` | 在数组中查找第一个满足简单等值条件的对象 |
|
||||
| `100 - $.data.percentage` | 数值字段支持简单减法表达式,常用于把“已用百分比”转换成“剩余百分比” |
|
||||
|
||||
### 原始连接器 JSON
|
||||
|
||||
`连接器 JSON` 允许直接编辑 `account.connectors` 数组,适合需要更复杂能力的供应商。
|
||||
|
||||
| 连接器类型 | 能力 |
|
||||
| --- | --- |
|
||||
| `standard` | 使用 CCR 标准账号端点。 |
|
||||
| `http-json` | 请求一个 JSON 接口,并用 mapping 字段映射余额、套餐、状态和消息。 |
|
||||
| `plugin` | 调用已安装插件注册的账号用量 connector。 |
|
||||
| `local-estimate` | 不请求远程接口,基于本地窗口配置展示估算额度。 |
|
||||
|
||||
点击 `插入示例` 会填入一个包含 `standard`、`http-json`、`plugin` 和 `local-estimate` 的示例 connector 数组。
|
||||
|
|
|
|||
|
|
@ -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 的自动路由不是靠 `x-claude-code-agent-id` 之类的 Header 决定模型,而是靠 prompt 标签。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 模型。这样路由可以把特定请求导向视觉、搜索或工具增强模型。
|
||||
|
||||
## 验证方式
|
||||
|
||||
保存后发一次请求,到请求日志里检查:
|
||||
|
|
|
|||
28
docs/src/content/docs/zh/configuration/server.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: 服务配置
|
||||
pageTitle: 服务配置
|
||||
eyebrow: 详细配置
|
||||
lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MITM 劫持并代理到 CCR 的能力。
|
||||
---
|
||||
|
||||
## 主字段
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| Host | CCR 网关监听的主机地址。常见值是 `127.0.0.1` 或 `0.0.0.0`。 |
|
||||
| Port | CCR 网关监听端口。客户端需要把 API Base URL 指向这个端口。 |
|
||||
|
||||
## 代理模式
|
||||
|
||||
代理模式是本地代理能力。开启后,客户端可以把 HTTP/HTTPS 流量交给 CCR;CCR 会通过 MITM 劫持识别和解密 HTTPS 请求,并把可处理的模型请求代理到 CCR 网关链路。
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 代理模式 | 启用本地代理能力。开启后 CCR 可以作为 HTTP/HTTPS 代理接收客户端流量,并通过 MITM 劫持把模型请求代理到 CCR。 |
|
||||
| 系统代理 | 将系统代理指向 CCR。适合让支持系统代理的应用自动经过 CCR。 |
|
||||
| 捕获网络 | 保存代理模式下经过 CCR 的网络请求,用于“网络”页面查看请求和响应详情。 |
|
||||
| CA 证书 | 当前代理 CA 证书的信任状态。 |
|
||||
| 安装 CA | 将 CCR 代理 CA 安装到系统或当前用户信任存储中。不同系统的安装方式不同。 |
|
||||
| 检查信任 | 重新检测代理 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。
|
||||
41
docs/src/content/docs/zh/configuration/tray.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: 托盘配置
|
||||
pageTitle: 托盘配置
|
||||
eyebrow: 详细配置
|
||||
lead: 配置 CCR 系统托盘图标、余额进度条和托盘窗口组件。
|
||||
---
|
||||
|
||||
## 顶部字段
|
||||
|
||||
| 字段 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 托盘小精灵 | 选择托盘图标样式。可选“随机”“晴岚”“暖阳”“星澜”和“余额进度条”。 |
|
||||
| 余额进度条 | 使用供应商账户用量作为托盘图标进度。需要先为供应商启用“获取用量”。 |
|
||||
| 账户 | 选择用于余额进度条的供应商账户。 |
|
||||
| 数据 | 选择账户里的余额、套餐或额度数据作为进度来源。 |
|
||||
|
||||
如果没有可用账户数据,页面会提示“暂无可用账户数据,请先为供应商启用账户监控。”这通常意味着还没有供应商开启“获取用量”,或用量读取尚未成功。
|
||||
|
||||
## 托盘窗口布局
|
||||
|
||||
| 区域 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 组件区 | 左侧组件库,用于添加或启用托盘窗口组件。 |
|
||||
| 预览 | 中间预览区,展示当前托盘窗口布局。组件可以拖拽排序。 |
|
||||
| 组件属性 | 右侧配置区,用于调整选中组件的“样式”,或移除当前组件。 |
|
||||
|
||||
## 组件类型
|
||||
|
||||
| 组件 | 代表的能力 |
|
||||
| --- | --- |
|
||||
| 供应商组件 | 显示“供应商切换”,用于在托盘窗口中按供应商查看数据。该组件是单例组件,只能启用一次。 |
|
||||
| 标题组件 | 显示“标题和状态”。该组件是单例组件,只能启用一次。 |
|
||||
| 账户组件 | 显示“账户余额”。可以添加多个账户组件,并选择不同样式。 |
|
||||
| 趋势组件 | 显示“Token 趋势图”。 |
|
||||
| 活跃度组件 | 显示“Token 活跃度”。 |
|
||||
| 指标组件 | 显示“Token 指标”。 |
|
||||
| 构成组件 | 显示“Token 构成”“环形指标”或“模型占比”。 |
|
||||
|
||||
## 样式
|
||||
|
||||
不同组件支持不同“样式”。常见样式包括“卡片”“紧凑”“列表”“胶囊”“折线图”“面积图”“柱状图”“圆环”“环形图”“仪表盘”“迷你折线”“堆叠”等。样式只影响托盘窗口展示方式,不改变请求路由、供应商配置或用量统计。
|
||||
|
|
@ -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 接管并按路由规则转发到上游供应商。
|
||||
|
||||
## 文档结构
|
||||
|
||||
顶部栏现在对应四个独立页面:
|
||||
|
|
@ -26,7 +13,7 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个
|
|||
| --- | --- |
|
||||
| [文档](./) | 产品定位、架构概览、阅读路径 |
|
||||
| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 |
|
||||
| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置文件位置 |
|
||||
| [详细配置](configuration/overview/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 |
|
||||
| [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 |
|
||||
|
||||
Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。
|
||||
|
|
@ -37,7 +24,7 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独
|
|||
|
||||
1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。
|
||||
2. App 的请求日志页面展示请求是否经过 CCR。
|
||||
3. [详细配置](configuration/) 覆盖图像、联网搜索、MCP 工具和 IM 接力。
|
||||
3. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。
|
||||
4. [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/", pageKey: "configuration" },
|
||||
{ label: "详细配置", href: "/configuration/overview/", pageKey: "configuration" },
|
||||
{ label: "Q&A", href: "/troubleshooting/", pageKey: "troubleshooting" },
|
||||
],
|
||||
pages: {
|
||||
|
|
@ -60,18 +60,32 @@ export const docsContent = {
|
|||
configuration: {
|
||||
sidebarGroups: [
|
||||
{
|
||||
label: "详细配置",
|
||||
label: "主页页面",
|
||||
icon: "wand",
|
||||
items: [
|
||||
"概览仪表盘",
|
||||
"供应商配置",
|
||||
"路由配置",
|
||||
"日志&观测",
|
||||
"Fusion 组合模型",
|
||||
"一键导入供应商",
|
||||
"Agent配置",
|
||||
"Bot 与 IM 接力 Agent",
|
||||
"配置文件位置",
|
||||
"路由配置",
|
||||
"Fusion 组合模型",
|
||||
"API 密钥",
|
||||
"日志&观测",
|
||||
"服务配置",
|
||||
"扩展机制",
|
||||
],
|
||||
active: "供应商配置",
|
||||
active: "概览仪表盘",
|
||||
},
|
||||
{
|
||||
label: "设置页",
|
||||
icon: "book",
|
||||
items: [
|
||||
"ToolHub",
|
||||
"Bot 与 IM 接力 Agent",
|
||||
"配置数据库位置",
|
||||
"托盘配置",
|
||||
],
|
||||
active: "",
|
||||
},
|
||||
],
|
||||
expandableSidebarItems: ["Fusion 组合模型", "Bot 与 IM 接力 Agent"],
|
||||
|
|
@ -90,14 +104,21 @@ export const docsContent = {
|
|||
],
|
||||
},
|
||||
sidebarLinks: {
|
||||
概览仪表盘: "/configuration/overview/",
|
||||
供应商配置: "/configuration/provider/",
|
||||
"一键导入供应商": "/configuration/provider-deeplink/",
|
||||
路由配置: "/configuration/routing/",
|
||||
"日志&观测": "/configuration/observability/",
|
||||
"Fusion 组合模型": "/configuration/fusion/",
|
||||
内置图像能力: "/configuration/fusion-vision/",
|
||||
内置联网搜索: "/configuration/fusion-web-search/",
|
||||
"自定义 MCP 工具": "/configuration/fusion-mcp-tool/",
|
||||
ToolHub: "/configuration/toolhub/",
|
||||
Agent配置: "/configuration/profile/",
|
||||
"API 密钥": "/configuration/api-keys/",
|
||||
服务配置: "/configuration/server/",
|
||||
托盘配置: "/configuration/tray/",
|
||||
扩展机制: "/configuration/extensions/",
|
||||
"Bot 与 IM 接力 Agent": "/configuration/bot-relay/",
|
||||
配置步骤: "/configuration/bot-setup/",
|
||||
Slack: "/bot-与-im-接力-agent/slack/",
|
||||
|
|
@ -108,7 +129,7 @@ export const docsContent = {
|
|||
企业微信: "/bot-与-im-接力-agent/wecom/",
|
||||
飞书: "/bot-与-im-接力-agent/feishu/",
|
||||
钉钉: "/bot-与-im-接力-agent/dingtalk/",
|
||||
配置文件位置: "/configuration/config-file/",
|
||||
配置数据库位置: "/configuration/config-file/",
|
||||
},
|
||||
sidebarTargets: {},
|
||||
},
|
||||
|
|
@ -151,7 +172,7 @@ export const docsContent = {
|
|||
navItems: [
|
||||
{ label: "Documentation", href: "/en/", pageKey: "documentation" },
|
||||
{ label: "Quick Start", href: "/en/guides/", pageKey: "guides" },
|
||||
{ label: "Detailed Configuration", href: "/en/configuration/", pageKey: "configuration" },
|
||||
{ label: "Detailed Configuration", href: "/en/configuration/overview/", pageKey: "configuration" },
|
||||
{ label: "Q&A", href: "/en/troubleshooting/", pageKey: "troubleshooting" },
|
||||
],
|
||||
pages: {
|
||||
|
|
@ -196,18 +217,32 @@ export const docsContent = {
|
|||
configuration: {
|
||||
sidebarGroups: [
|
||||
{
|
||||
label: "Detailed Configuration",
|
||||
label: "Main Pages",
|
||||
icon: "wand",
|
||||
items: [
|
||||
"Overview Dashboard",
|
||||
"Provider Config",
|
||||
"Routing Config",
|
||||
"Logs & Observability",
|
||||
"Fusion Models",
|
||||
"One click import",
|
||||
"Agent Config",
|
||||
"Bots And IM Agent Relay",
|
||||
"Config File Location",
|
||||
"Routing Config",
|
||||
"Fusion Models",
|
||||
"API Keys",
|
||||
"Logs & Observability",
|
||||
"Server",
|
||||
"Extension Mechanism",
|
||||
],
|
||||
active: "Provider Config",
|
||||
active: "Overview Dashboard",
|
||||
},
|
||||
{
|
||||
label: "Settings Pages",
|
||||
icon: "book",
|
||||
items: [
|
||||
"ToolHub",
|
||||
"Bots And IM Agent Relay",
|
||||
"Config Database Location",
|
||||
"Tray Configuration",
|
||||
],
|
||||
active: "",
|
||||
},
|
||||
],
|
||||
expandableSidebarItems: ["Fusion Models", "Bots And IM Agent Relay"],
|
||||
|
|
@ -216,14 +251,21 @@ export const docsContent = {
|
|||
"Bots And IM Agent Relay": ["Setup", "Slack", "Discord", "Telegram", "LINE", "Weixin", "WeCom", "Feishu", "DingTalk"],
|
||||
},
|
||||
sidebarLinks: {
|
||||
"Overview Dashboard": "/en/configuration/overview/",
|
||||
"Provider Config": "/en/configuration/providers/",
|
||||
"One click import": "/en/configuration/provider-deeplink/",
|
||||
"Routing Config": "/en/configuration/routing/",
|
||||
"Logs & Observability": "/en/configuration/observability/",
|
||||
"Fusion Models": "/en/configuration/fusion-models/",
|
||||
"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/",
|
||||
"Tray Configuration": "/en/configuration/tray/",
|
||||
"Extension Mechanism": "/en/configuration/extensions/",
|
||||
"Bots And IM Agent Relay": "/en/configuration/bots/",
|
||||
Setup: "/en/configuration/bot-setup/",
|
||||
Slack: "/en/relay-agents-in-im-with-bots/slack/",
|
||||
|
|
@ -234,7 +276,7 @@ export const docsContent = {
|
|||
WeCom: "/en/relay-agents-in-im-with-bots/wecom/",
|
||||
Feishu: "/en/relay-agents-in-im-with-bots/feishu/",
|
||||
DingTalk: "/en/relay-agents-in-im-with-bots/dingtalk/",
|
||||
"Config File Location": "/en/configuration/configuration-file/",
|
||||
"Config Database Location": "/en/configuration/configuration-file/",
|
||||
},
|
||||
sidebarTargets: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ const resolvedNavItems = navItems.map((item, index) => {
|
|||
};
|
||||
});
|
||||
const homeHref = withBase(locale === "en" ? "/en/" : "/");
|
||||
const faviconHref = withBase("/favicon.svg");
|
||||
const faviconHref = withBase("/ccr-icon.png");
|
||||
const logoSrc = withBase("/logo.png");
|
||||
const sidebarIcons = {
|
||||
rocket: Rocket,
|
||||
|
|
@ -177,7 +177,7 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" href={faviconHref} type="image/svg+xml" />
|
||||
<link rel="icon" href={faviconHref} type="image/png" />
|
||||
{
|
||||
resolvedLanguageOptions.map((option) => (
|
||||
<link
|
||||
|
|
@ -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,6 +1,17 @@
|
|||
---
|
||||
import DocPage from "../components/DocPage.astro";
|
||||
import * as doc from "../content/docs/zh/configuration.md";
|
||||
const target = "/configuration/overview/";
|
||||
---
|
||||
|
||||
<DocPage locale="zh" pageKey="configuration" doc={doc} />
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="refresh" content={`0;url=${target}`} />
|
||||
<link rel="canonical" href={target} />
|
||||
<script is:inline>
|
||||
window.location.replace("/configuration/overview/");
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href={target}>概览仪表盘</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { configurationSlugFromPath, zhConfigurationDocs } from "../../configurat
|
|||
|
||||
export function getStaticPaths() {
|
||||
const activeLabels: Record<string, string> = {
|
||||
"api-keys": "API 密钥",
|
||||
overview: "概览仪表盘",
|
||||
provider: "供应商配置",
|
||||
"provider-deeplink": "一键导入供应商",
|
||||
routing: "路由配置",
|
||||
profile: "Agent配置",
|
||||
observability: "日志&观测",
|
||||
|
|
@ -12,9 +15,13 @@ export function getStaticPaths() {
|
|||
"fusion-vision": "内置图像能力",
|
||||
"fusion-web-search": "内置联网搜索",
|
||||
"fusion-mcp-tool": "自定义 MCP 工具",
|
||||
toolhub: "ToolHub",
|
||||
extensions: "扩展机制",
|
||||
server: "服务配置",
|
||||
tray: "托盘配置",
|
||||
"bot-relay": "Bot 与 IM 接力 Agent",
|
||||
"bot-setup": "配置步骤",
|
||||
"config-file": "配置文件位置",
|
||||
"config-file": "配置数据库位置",
|
||||
};
|
||||
|
||||
return Object.entries(zhConfigurationDocs).map(([filePath, mod]) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
---
|
||||
import DocPage from "../../components/DocPage.astro";
|
||||
import * as doc from "../../content/docs/en/configuration.md";
|
||||
const target = "/en/configuration/overview/";
|
||||
---
|
||||
|
||||
<DocPage locale="en" pageKey="configuration" doc={doc} />
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="refresh" content={`0;url=${target}`} />
|
||||
<link rel="canonical" href={target} />
|
||||
<script is:inline>
|
||||
window.location.replace("/en/configuration/overview/");
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href={target}>Overview Dashboard</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { configurationSlugFromPath, enConfigurationDocs } from "../../../configu
|
|||
|
||||
export function getStaticPaths() {
|
||||
const activeLabels: Record<string, string> = {
|
||||
"api-keys": "API Keys",
|
||||
overview: "Overview Dashboard",
|
||||
providers: "Provider Config",
|
||||
"provider-deeplink": "One click import",
|
||||
routing: "Routing Config",
|
||||
profiles: "Agent Config",
|
||||
observability: "Logs & Observability",
|
||||
|
|
@ -12,9 +15,13 @@ 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",
|
||||
bots: "Bots And IM Agent Relay",
|
||||
"bot-setup": "Setup",
|
||||
"configuration-file": "Config File Location",
|
||||
"configuration-file": "Config Database Location",
|
||||
};
|
||||
|
||||
return Object.entries(enConfigurationDocs).map(([filePath, mod]) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -913,8 +921,8 @@ h1 {
|
|||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.doc-article code,
|
||||
.doc-markdown code {
|
||||
.doc-article :not(pre) > code,
|
||||
.doc-markdown :not(pre) > code {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--code-border);
|
||||
border-radius: 6px;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1048,6 +1074,328 @@ h1 {
|
|||
color: var(--quote-text);
|
||||
}
|
||||
|
||||
.provider-import-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
margin: 24px 0 34px;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button {
|
||||
--provider-brand: #17201c;
|
||||
--provider-brand-2: #36624d;
|
||||
--provider-brand-3: #dff8eb;
|
||||
--provider-accent: rgba(255, 255, 255, 0.72);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
column-gap: 14px;
|
||||
align-items: center;
|
||||
min-height: 86px;
|
||||
padding: 13px 16px 13px 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--provider-brand-2) 42%, transparent);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
radial-gradient(circle at 14% 22%, color-mix(in srgb, var(--provider-brand-3) 34%, transparent) 0, transparent 30%),
|
||||
radial-gradient(circle at 96% 96%, color-mix(in srgb, var(--provider-brand-2) 44%, transparent) 0, transparent 46%),
|
||||
linear-gradient(135deg, var(--provider-brand), color-mix(in srgb, var(--provider-brand-2) 88%, #111 12%));
|
||||
box-shadow:
|
||||
0 10px 26px color-mix(in srgb, var(--provider-brand) 22%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
text-decoration-thickness: 0;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 220ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -40% auto -40% -70%;
|
||||
z-index: -1;
|
||||
width: 58%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent);
|
||||
transform: skewX(-18deg);
|
||||
transition: transform 620ms ease;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -38px;
|
||||
bottom: -52px;
|
||||
z-index: -2;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--provider-brand-3) 38%, transparent), transparent 68%);
|
||||
opacity: 0.82;
|
||||
transform: scale(0.96);
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button:hover,
|
||||
.doc-markdown a.provider-import-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--provider-brand-3) 68%, rgba(255, 255, 255, 0.34));
|
||||
box-shadow:
|
||||
0 16px 34px color-mix(in srgb, var(--provider-brand) 30%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--provider-brand-3) 26%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
transform: translateY(-3px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button:hover::before,
|
||||
.doc-markdown a.provider-import-button:focus-visible::before {
|
||||
transform: translateX(320%) skewX(-18deg);
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button:hover::after,
|
||||
.doc-markdown a.provider-import-button:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button:active {
|
||||
transform: translateY(-1px) scale(0.992);
|
||||
}
|
||||
|
||||
.provider-import-icon-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
justify-self: start;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transform: rotate(0) scale(1);
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
.provider-import-icon-shell::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.provider-import-icon-shell img {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.46);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, #ffffff 84%, var(--provider-brand-3));
|
||||
box-shadow:
|
||||
0 8px 18px rgba(0, 0, 0, 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.12));
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.provider-import-mark {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.46);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--provider-brand-3) 36%, rgba(255, 255, 255, 0.88)),
|
||||
color-mix(in srgb, var(--provider-brand-2) 72%, #111111 28%)
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 18px rgba(0, 0, 0, 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.52);
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.34);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button:hover .provider-import-icon-shell,
|
||||
.doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell {
|
||||
transform: rotate(-3deg) scale(1.06);
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button:hover .provider-import-icon-shell img,
|
||||
.doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell img,
|
||||
.doc-markdown a.provider-import-button:hover .provider-import-mark,
|
||||
.doc-markdown a.provider-import-button:focus-visible .provider-import-mark {
|
||||
border-color: color-mix(in srgb, var(--provider-brand-3) 74%, rgba(255, 255, 255, 0.54));
|
||||
box-shadow:
|
||||
0 12px 24px rgba(0, 0, 0, 0.18),
|
||||
0 0 0 4px color-mix(in srgb, var(--provider-brand-3) 16%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.provider-import-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.provider-import-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 780;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-import-meta {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-openai {
|
||||
--provider-brand: #0b0f0e;
|
||||
--provider-brand-2: #39413e;
|
||||
--provider-brand-3: #f2f5f1;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-anthropic {
|
||||
--provider-brand: #3f2118;
|
||||
--provider-brand-2: #c56a4b;
|
||||
--provider-brand-3: #ffd1bf;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-gemini {
|
||||
--provider-brand: #1a73e8;
|
||||
--provider-brand-2: #a142f4;
|
||||
--provider-brand-3: #fbbc04;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-openrouter {
|
||||
--provider-brand: #111722;
|
||||
--provider-brand-2: #66758c;
|
||||
--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-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;
|
||||
--provider-brand-3: #d6deff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-zhipu-coding {
|
||||
--provider-brand: #0d4fd7;
|
||||
--provider-brand-2: #22b8f0;
|
||||
--provider-brand-3: #d7f2ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-zhipu-general {
|
||||
--provider-brand: #1728a6;
|
||||
--provider-brand-2: #5f6dff;
|
||||
--provider-brand-3: #e0e4ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-zai-coding {
|
||||
--provider-brand: #111111;
|
||||
--provider-brand-2: #49515d;
|
||||
--provider-brand-3: #f2f5f8;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-zai-general {
|
||||
--provider-brand: #0d1424;
|
||||
--provider-brand-2: #3267ac;
|
||||
--provider-brand-3: #dce8ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-mistral {
|
||||
--provider-brand: #581900;
|
||||
--provider-brand-2: #ff8a00;
|
||||
--provider-brand-3: #ffd84f;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-moonshot {
|
||||
--provider-brand: #10133a;
|
||||
--provider-brand-2: #7465ff;
|
||||
--provider-brand-3: #e4ddff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-moonshot-global {
|
||||
--provider-brand: #0f1f42;
|
||||
--provider-brand-2: #4f7cff;
|
||||
--provider-brand-3: #dce7ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-kimi-coding {
|
||||
--provider-brand: #111237;
|
||||
--provider-brand-2: #5c6bff;
|
||||
--provider-brand-3: #dfe4ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-bailian {
|
||||
--provider-brand: #5a2300;
|
||||
--provider-brand-2: #ff6a00;
|
||||
--provider-brand-3: #ffd1a3;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-siliconflow {
|
||||
--provider-brand: #041f1b;
|
||||
--provider-brand-2: #00c2a8;
|
||||
--provider-brand-3: #b4fff4;
|
||||
}
|
||||
|
||||
.doc-markdown > pre,
|
||||
.doc-markdown > .code-panel {
|
||||
margin: 22px 0 32px;
|
||||
|
|
@ -1145,23 +1493,44 @@ pre,
|
|||
line-height: 1.7;
|
||||
}
|
||||
|
||||
pre.astro-code,
|
||||
pre.astro-code span {
|
||||
color: var(--shiki-light, var(--code-text));
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] pre.astro-code,
|
||||
:root[data-theme="dark"] pre.astro-code span {
|
||||
color: var(--code-text) !important;
|
||||
color: var(--shiki-dark, var(--code-text));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) pre.astro-code,
|
||||
:root:not([data-theme]) pre.astro-code span {
|
||||
color: var(--code-text) !important;
|
||||
color: var(--shiki-dark, var(--code-text));
|
||||
}
|
||||
}
|
||||
|
||||
.code-content code,
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.code-content code span,
|
||||
pre code span {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.steps {
|
||||
|
|
@ -1233,6 +1602,7 @@ pre code {
|
|||
}
|
||||
|
||||
.toc a {
|
||||
--toc-dot-left: 0px;
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 5px 0 5px 21px;
|
||||
|
|
@ -1240,6 +1610,25 @@ pre code {
|
|||
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;
|
||||
|
|
@ -1247,7 +1636,7 @@ pre code {
|
|||
|
||||
.toc a.active::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: var(--toc-dot-left);
|
||||
top: 13px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
|
|
@ -1431,3 +1820,46 @@ pre code {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.provider-import-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button {
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
column-gap: 12px;
|
||||
min-height: 92px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.provider-import-icon-shell {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.provider-import-icon-shell img,
|
||||
.provider-import-mark {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.doc-markdown a.provider-import-button,
|
||||
.doc-markdown a.provider-import-button::before,
|
||||
.doc-markdown a.provider-import-button::after,
|
||||
.provider-import-icon-shell {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button:hover,
|
||||
.doc-markdown a.provider-import-button:focus-visible,
|
||||
.doc-markdown a.provider-import-button:hover .provider-import-icon-shell,
|
||||
.doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,21 @@
|
|||
"asarUnpack": [
|
||||
"**/*.node"
|
||||
],
|
||||
"electronLanguages": ["en-US", "zh-CN", "zh-TW", "zh_CN", "zh_TW"],
|
||||
"npmRebuild": true,
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "musistudio",
|
||||
"repo": "claude-code-router"
|
||||
"repo": "claude-code-router",
|
||||
"releaseType": "release"
|
||||
}
|
||||
],
|
||||
"directories": {
|
||||
"app": "packages/electron",
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"afterPack": "build/verify-packaged-app.cjs",
|
||||
"afterAllArtifactBuild": "build/verify-update-metadata.cjs",
|
||||
"protocols": [
|
||||
{
|
||||
|
|
@ -25,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",
|
||||
|
|
@ -50,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);
|
||||
}
|
||||
1588
package-lock.json
generated
56
package.json
|
|
@ -1,36 +1,74 @@
|
|||
{
|
||||
"name": "claude-code-router",
|
||||
"name": "claude-code-router-monorepo",
|
||||
"version": "3.0.10",
|
||||
"private": true,
|
||||
"version": "3.0.1",
|
||||
"description": "Desktop scaffold for Claude Code Router.",
|
||||
"main": "dist/main/main.js",
|
||||
"bin": {
|
||||
"ccr": "dist/main/cli.js"
|
||||
"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": "packages/electron/dist/main/main.js",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"publishConfig": {
|
||||
"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",
|
||||
"build:app:win": "npm run build:assets && electron-builder --win",
|
||||
"prepack": "npm run build:assets",
|
||||
"prepublishOnly": "npm run typecheck",
|
||||
"preview": "npm run build:assets && electron .",
|
||||
"docker:build": "docker build -t claude-code-router:local .",
|
||||
"docker:run": "docker run --rm -p 3458:8080 -v ccr-data:/data claude-code-router:local",
|
||||
"test": "node build/test.mjs && node build/run-tests.mjs",
|
||||
"test:docker": "node tests/docker/docker-smoke.mjs",
|
||||
"test:e2e": "npm run build:assets && playwright test",
|
||||
"test:e2e:install": "playwright install chromium",
|
||||
"test:main": "node build/test.mjs main && node build/run-tests.mjs main",
|
||||
"test:renderer": "node build/test.mjs renderer && node build/run-tests.mjs renderer",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.1",
|
||||
"@the-next-ai/ai-gateway": "^1.0.6",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"electron-updater": "^6.8.9",
|
||||
"node-forge": "^1.4.0",
|
||||
"openai": "^6.27.0",
|
||||
"undici": "^7.27.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.10.2",
|
||||
|
|
|
|||