mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-19 00:46:15 +00:00
Merge branch 'dev/3.1'
This commit is contained in:
commit
bb1db63c49
134 changed files with 8752 additions and 2182 deletions
47
README.md
47
README.md
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
### Manage every agent and provider from one place.
|
||||
|
||||
Connect Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, and compatible API clients to the providers you choose—then route, fail over, extend, and observe every request from one app.
|
||||
Connect Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, WorkBuddy, and compatible API clients to the providers you choose—then route, fail over, extend, and observe every request from one app.
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases"><img alt="Download Desktop" src="https://img.shields.io/badge/Download-Desktop_App-2563EB?style=for-the-badge&logo=github&logoColor=white" /></a>
|
||||
|
|
@ -59,7 +59,7 @@ Connect Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCo
|
|||
|
||||
## Why use Claude Code Router?
|
||||
|
||||
Claude Code Router (CCR) is a local model gateway and control plane for coding agents. It gives Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, and compatible API clients **one stable local endpoint**, while you manage the providers, models, accounts, routing rules, and tools behind it from one place.
|
||||
Claude Code Router (CCR) is a local model gateway and control plane for coding agents. It gives Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, WorkBuddy, and compatible API clients **one stable local endpoint**, while you manage the providers, models, accounts, routing rules, and tools behind it from one place.
|
||||
|
||||
Use CCR to:
|
||||
|
||||
|
|
@ -78,71 +78,78 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten
|
|||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/anthropics/claude-code">
|
||||
<img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Code logo" />
|
||||
<br />
|
||||
<strong>Claude Code (CLI & APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/openai/codex">
|
||||
<img src="/packages/ui/src/assets/agent-logos/codex.png" width="44" height="44" alt="Codex logo" />
|
||||
<br />
|
||||
<strong>Codex (CLI & APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/xai-org/grok-build">
|
||||
<img src="/packages/ui/src/assets/agent-logos/grok.ico" width="44" height="44" alt="Grok CLI logo" />
|
||||
<br />
|
||||
<strong>Grok CLI (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/MoonshotAI/kimi-cli">
|
||||
<img src="/docs/public/provider-icons/moonshot.ico" width="44" height="44" alt="Kimi CLI logo" />
|
||||
<br />
|
||||
<strong>Kimi CLI (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://kilo.ai/">
|
||||
<img src="/packages/ui/src/assets/agent-logos/kilo.svg" width="44" height="44" alt="Kilo Code logo" />
|
||||
<br />
|
||||
<strong>Kilo Code (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/anomalyco/opencode">
|
||||
<img src="/packages/ui/src/assets/agent-logos/opencode.ico" width="44" height="44" alt="OpenCode logo" />
|
||||
<br />
|
||||
<strong>OpenCode (CLI & APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/earendil-works/pi">
|
||||
<img src="/packages/ui/src/assets/agent-logos/pi.svg" width="44" height="44" alt="Pi logo" />
|
||||
<br />
|
||||
<strong>Pi (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://zcode.z.ai/en">
|
||||
<img src="/packages/ui/src/assets/agent-logos/zcode.png" width="44" height="44" alt="ZCode logo" />
|
||||
<br />
|
||||
<strong>ZCode (APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://www.anthropic.com/news/claude-design-anthropic-labs">
|
||||
<img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Design logo" />
|
||||
<br />
|
||||
<strong>Claude Design (APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://www.workbuddy.ai/">
|
||||
<img src="/packages/ui/src/assets/agent-logos/workbuddy.png" width="44" height="44" alt="WorkBuddy logo" />
|
||||
<br />
|
||||
<strong>WorkBuddy (APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
@ -162,28 +169,28 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten
|
|||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20.exe">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21.exe">
|
||||
<img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows logo" />
|
||||
<br />
|
||||
<strong>Windows</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20.AppImage">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21.AppImage">
|
||||
<img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux logo" />
|
||||
<br />
|
||||
<strong>Linux</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20-mac-Apple-Silicon-arm64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21-mac-Apple-Silicon-arm64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
|
||||
<br />
|
||||
<strong>macOS (Apple Silicon)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20-mac-Intel-x64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21-mac-Intel-x64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
|
||||
<br />
|
||||
<strong>macOS (Intel)</strong>
|
||||
|
|
@ -198,7 +205,7 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten
|
|||
|
||||
2. Open **Providers → Add Provider**. Choose a built-in preset or a custom endpoint, enter the API key, select the protocol and models, then save.
|
||||
3. Open **Server** and click **Start**. The local model gateway listens on `http://127.0.0.1:3456` by default.
|
||||
4. Open **Agent Config**, choose Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, or ZCode, select a model, and apply the profile.
|
||||
4. Open **Agent Config**, choose Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, or WorkBuddy, select a model, and apply the profile.
|
||||
5. Start using your agent. Open **Logs** to confirm the resolved provider, model, status, tokens, latency, and errors.
|
||||
|
||||
Your agent is now connected to CCR. To add conditions, retries, request rewrites, or fallback models, open **Routing**.
|
||||
|
|
@ -236,7 +243,7 @@ Windows app packaging must run on Windows x64 because `better-sqlite3` ships a n
|
|||
## How it works
|
||||
|
||||
```text
|
||||
Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · Compatible API clients
|
||||
Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · WorkBuddy · Compatible API clients
|
||||
│
|
||||
▼
|
||||
Claude Code Router :3456
|
||||
|
|
@ -250,7 +257,7 @@ Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · Op
|
|||
|
||||
| Area | Highlights |
|
||||
| --- | --- |
|
||||
| **Agents** | Profiles for Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, and ZCode; model overrides; scopes; environment settings; CLI and app launch entries; multi-instance workflows |
|
||||
| **Agents** | Profiles for Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, and WorkBuddy; model overrides; scopes; environment settings; CLI and app launch entries; multi-instance workflows |
|
||||
| **Providers** | Presets and custom endpoints; protocol probing; model discovery; connectivity checks; local login import where supported; single keys and credential pools |
|
||||
| **Models & routing** | Searchable catalog; model descriptions for task selection; conditions on headers and bodies; prefixes; rewrites; retries; ordered fallbacks |
|
||||
| **Tools & extensions** | Fusion models; ToolHub; built-in browser automation; Chrome login-state import; wrapper and core gateway plugins; local routes and virtual models |
|
||||
|
|
|
|||
47
README_zh.md
47
README_zh.md
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
### 在一个地方,管理你所有的 Agent 与 Provider
|
||||
|
||||
让 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 和兼容 API 客户端连接你选择的供应商,并在一个应用里完成每次请求的路由、降级、增强与观测。
|
||||
让 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode、WorkBuddy 和兼容 API 客户端连接你选择的供应商,并在一个应用里完成每次请求的路由、降级、增强与观测。
|
||||
|
||||
<p>
|
||||
<a href="#桌面端推荐"><img alt="下载桌面端" src="https://img.shields.io/badge/%E7%AB%8B%E5%8D%B3%E4%B8%8B%E8%BD%BD-%E6%A1%8C%E9%9D%A2%E5%AE%A2%E6%88%B7%E7%AB%AF-2563EB?style=for-the-badge&logo=github&logoColor=white" /></a>
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
|
||||
## 为什么使用 Claude Code Router?
|
||||
|
||||
Claude Code Router(CCR)是面向编程 Agent 的本地模型网关与控制平面。它为 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 和兼容 API 客户端提供**一个稳定的本地入口**,让你在一个地方管理入口背后的供应商、模型、账号、路由规则与工具。
|
||||
Claude Code Router(CCR)是面向编程 Agent 的本地模型网关与控制平面。它为 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode、WorkBuddy 和兼容 API 客户端提供**一个稳定的本地入口**,让你在一个地方管理入口背后的供应商、模型、账号、路由规则与工具。
|
||||
|
||||
你可以使用 CCR:
|
||||
|
||||
|
|
@ -78,71 +78,78 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten
|
|||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/anthropics/claude-code">
|
||||
<img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Code 图标" />
|
||||
<br />
|
||||
<strong>Claude Code (CLI & APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/openai/codex">
|
||||
<img src="/packages/ui/src/assets/agent-logos/codex.png" width="44" height="44" alt="Codex 图标" />
|
||||
<br />
|
||||
<strong>Codex (CLI & APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/xai-org/grok-build">
|
||||
<img src="/packages/ui/src/assets/agent-logos/grok.ico" width="44" height="44" alt="Grok CLI 图标" />
|
||||
<br />
|
||||
<strong>Grok CLI (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/MoonshotAI/kimi-cli">
|
||||
<img src="/docs/public/provider-icons/moonshot.ico" width="44" height="44" alt="Kimi CLI 图标" />
|
||||
<br />
|
||||
<strong>Kimi CLI (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://kilo.ai/">
|
||||
<img src="/packages/ui/src/assets/agent-logos/kilo.svg" width="44" height="44" alt="Kilo Code 图标" />
|
||||
<br />
|
||||
<strong>Kilo Code (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/anomalyco/opencode">
|
||||
<img src="/packages/ui/src/assets/agent-logos/opencode.ico" width="44" height="44" alt="OpenCode 图标" />
|
||||
<br />
|
||||
<strong>OpenCode (CLI & APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://github.com/earendil-works/pi">
|
||||
<img src="/packages/ui/src/assets/agent-logos/pi.svg" width="44" height="44" alt="Pi 图标" />
|
||||
<br />
|
||||
<strong>Pi (CLI)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://zcode.z.ai/cn">
|
||||
<img src="/packages/ui/src/assets/agent-logos/zcode.png" width="44" height="44" alt="ZCode 图标" />
|
||||
<br />
|
||||
<strong>ZCode (APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://www.anthropic.com/news/claude-design-anthropic-labs">
|
||||
<img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Design 图标" />
|
||||
<br />
|
||||
<strong>Claude Design (APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="20%">
|
||||
<a href="https://www.workbuddy.ai/">
|
||||
<img src="/packages/ui/src/assets/agent-logos/workbuddy.png" width="44" height="44" alt="WorkBuddy 图标" />
|
||||
<br />
|
||||
<strong>WorkBuddy (APP)</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
@ -162,28 +169,28 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten
|
|||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20.exe">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21.exe">
|
||||
<img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows 图标" />
|
||||
<br />
|
||||
<strong>Windows</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20.AppImage">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21.AppImage">
|
||||
<img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux 图标" />
|
||||
<br />
|
||||
<strong>Linux</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20-mac-Apple-Silicon-arm64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21-mac-Apple-Silicon-arm64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
|
||||
<br />
|
||||
<strong>macOS (Apple Silicon)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.20/Claude-Code-Router_3.0.20-mac-Intel-x64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.21/Claude-Code-Router_3.0.21-mac-Intel-x64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
|
||||
<br />
|
||||
<strong>macOS (Intel)</strong>
|
||||
|
|
@ -198,7 +205,7 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten
|
|||
|
||||
2. 打开 **供应商 → 添加供应商**。选择内置预设或自定义端点,填写 API Key,选择协议与模型,然后保存。
|
||||
3. 打开 **服务** 并点击 **启动**。本地模型网关默认监听 `http://127.0.0.1:3456`。
|
||||
4. 打开 **Agent配置**,选择 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi 或 ZCode,指定模型并应用配置档案。
|
||||
4. 打开 **Agent配置**,选择 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 或 WorkBuddy,指定模型并应用配置档案。
|
||||
5. 开始使用 Agent。在 **日志** 中确认最终供应商、模型、状态、Token、耗时与错误。
|
||||
|
||||
现在 Agent 已经连接到 CCR。如需增加条件规则、自动重试、请求改写或 Fallback 模型,请打开 **路由**。
|
||||
|
|
@ -236,7 +243,7 @@ Windows App 打包必须在 Windows x64 上运行,因为 `better-sqlite3` 包
|
|||
## 工作方式
|
||||
|
||||
```text
|
||||
Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · 兼容 API 客户端
|
||||
Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · WorkBuddy · 兼容 API 客户端
|
||||
│
|
||||
▼
|
||||
Claude Code Router :3456
|
||||
|
|
@ -250,7 +257,7 @@ Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · Op
|
|||
|
||||
| 能力领域 | 功能亮点 |
|
||||
| --- | --- |
|
||||
| **Agent** | Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi 和 ZCode 配置档案;模型覆盖;作用范围;环境变量;CLI / App 启动入口;多开工作流 |
|
||||
| **Agent** | Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 和 WorkBuddy 配置档案;模型覆盖;作用范围;环境变量;CLI / App 启动入口;多开工作流 |
|
||||
| **供应商** | 内置预设和自定义端点;协议探测;模型发现;连通性检测;按支持情况导入本机登录态;单 Key 与凭据池 |
|
||||
| **模型与路由** | 可搜索模型目录;用于任务选择的模型描述;Header / Body 条件;模型前缀;请求改写;重试;有序 Fallback |
|
||||
| **工具与扩展** | Fusion 模型;ToolHub;内置浏览器自动化;Chrome 登录态导入;wrapper / core gateway plugin;本地路由与虚拟模型 |
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ lead: "AgentClaw connects an agent running locally under CCR to your IM apps: th
|
|||
|
||||
AgentClaw is CCR's agent relay capability. A local agent managed by CCR keeps its workspace, login state, config files, model routing, and tool permissions; AgentClaw exposes a remote entry point for it through an IM bot. From Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, or DingTalk, you can watch agent output, continue the conversation, answer permission requests, or take over after your computer locks.
|
||||
|
||||
Handoff means that after your screen locks and the idle threshold passes, further agent interaction moves to IM, where you can continue from your phone or another device. Handoff relies on a companion worker that follows the App lifecycle: opening an Agent App from CCR starts its worker, and closing the App stops the worker and the Bot connection. CLI-only agents can still route model requests through CCR, but they do not forward messages to IM.
|
||||
Handoff means that after your screen locks and the idle threshold passes, further agent interaction moves to IM, where you can continue from your phone or another device. Keep the Agent App open while using IM relay. CLI-only agents can still route model requests through CCR, but they do not forward messages to IM.
|
||||
|
||||
## When to use it
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ Handoff means that after your screen locks and the idle threshold passes, furthe
|
|||
| Session | An agent-native conversation inside a Project |
|
||||
| Companion worker | The relay process that follows the managed Agent App lifecycle and handles IM messages, Projects/Sessions, queueing, attachments, and diagnostics |
|
||||
|
||||
AgentClaw shares the lifecycle of the Agent App opened by CCR. Opening Claude, Codex, ZCode, or OpenCode App starts its companion worker; closing the App stops both the worker and the Bot connection. CLI-only agents can route model requests through CCR but do not forward Bot messages.
|
||||
AgentClaw follows the Agent App opened by CCR. Closing the App stops the Bot connection. CLI-only agents can route model requests through CCR but do not forward Bot messages.
|
||||
|
||||
## Support matrix
|
||||
|
||||
|
|
@ -39,9 +39,10 @@ AgentClaw shares the lifecycle of the Agent App opened by CCR. Opening Claude, C
|
|||
| Codex / ChatGPT App | Full Bot forwarding, handoff, Projects/Sessions, queueing, model settings, usage, and attachments |
|
||||
| OpenCode App | Full Bot forwarding, handoff, and Projects/Sessions; messages run through OpenCode CLI under the same config |
|
||||
| ZCode App | Full Bot forwarding, handoff, and native Session discovery |
|
||||
| WorkBuddy App | Full Bot forwarding, handoff, and WorkBuddy companion relay |
|
||||
| Claude Code, Codex CLI | Can use CCR model routing; Bot forwarding is not active yet |
|
||||
| Grok CLI, Kimi CLI | Can use CCR model routing; these are CLI-only and do not enter AgentClaw relay |
|
||||
| Other local agents | They need a CCR-managed App entry plus a companion worker before they can act as AgentClaw executors |
|
||||
| Other local agents | They need a CCR-managed App entry before they can act as AgentClaw executors |
|
||||
|
||||
## Three modes
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ AgentClaw exposes only `/project` and `/session` as public command domains. Othe
|
|||
| `/session name <label>` | Rename the current Session |
|
||||
| `/session archive <n>`, `restore <n>`, `delete <n> confirm` | Archive, restore, or permanently delete with confirmation |
|
||||
| `/session history [count]`, `usage` | Show recent history and token/cache/cost summaries |
|
||||
| `/session models`, `model`, `effort`, `mode` | Inspect or change this conversation's Session runtime settings |
|
||||
| `/session models`, `model`, `effort`, `mode` | Inspect or change this conversation's model settings |
|
||||
| `/session memory ...`, `skills`, `skill`, `shortcut ...` | Manage persistent context, Agent Skills, and shortcuts |
|
||||
| `/session doctor`, `deliveries` | Show connection, outbox, recent-delivery, and redacted-error diagnostics |
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ lead: "The full setup flow: create an IM bot, bind it to an Agent Config, and ch
|
|||
|
||||
1. CCR Desktop is running and the gateway service is available.
|
||||
2. A usable model is configured in **Provider Config**, or the Fusion model you want is ready.
|
||||
3. An **Agent Config** exists whose **entry mode** includes App. Full AgentClaw relay currently supports Claude App, Codex/ChatGPT App, OpenCode App, and ZCode App; CLI-only agents do not forward Bot messages.
|
||||
3. An **Agent Config** exists whose **entry mode** includes App. Full AgentClaw relay currently supports Claude App, Codex/ChatGPT App, OpenCode App, ZCode App, and WorkBuddy App; CLI-only agents do not forward Bot messages.
|
||||
4. The target IM platform has Bot credentials, app permissions, or QR login ready.
|
||||
|
||||
## Configuration flow
|
||||
|
|
@ -22,7 +22,7 @@ lead: "The full setup flow: create an IM bot, bind it to an Agent Config, and ch
|
|||
6. Turn on **Bot** and select the Bot you saved.
|
||||
7. Configure forwarding, handoff, language, timeout, attachments, streaming, and **Allow Agent shell tools** as needed.
|
||||
8. Save the Agent Config.
|
||||
9. Reopen Claude App, Codex/ChatGPT App, OpenCode App, or ZCode App from CCR. AgentClaw is online only while the managed App is alive.
|
||||
9. Reopen Claude App, Codex/ChatGPT App, OpenCode App, ZCode App, or WorkBuddy App from CCR. AgentClaw is online only while the managed App is alive.
|
||||
|
||||
## Choose a mode
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
title: Claude Code setup and configuration
|
||||
pageTitle: Claude Code
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect Claude Code (CLI and App) to CCR so every request runs through your providers, routing, and Fusion models. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect Claude Code (CLI and App) to CCR so every request runs through your providers, routing, and Fusion models."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
Claude Code is Anthropic's coding agent. CCR supports both of its surfaces:
|
||||
|
||||
- **Claude Code CLI** — the terminal agent. CCR rewrites its settings so it talks to the CCR gateway, and opens it through a launch wrapper.
|
||||
- **Claude App** — the desktop app. CCR opens it with a zero-config gateway, API key, model discovery list, and an isolated user-data directory.
|
||||
- **Claude Code CLI** — the terminal agent.
|
||||
- **Claude App** — the desktop app.
|
||||
|
||||
Use this page when you want to route Claude Code to a non-Anthropic provider, pin a specific model, run several isolated Claude Code instances, or attach an IM bot.
|
||||
Use this page when you want to route Claude Code to a non-Anthropic provider, pin a specific model, run several separate Claude Code instances, or attach an IM bot.
|
||||
|
||||
> New to CCR? Add a provider and model first, then come back here. See [Add a provider](/en/guides/provider/) and the [Agent Config overview](/en/configuration/profiles/).
|
||||
|
||||
|
|
@ -22,27 +22,13 @@ Use this page when you want to route Claude Code to a non-Anthropic provider, pi
|
|||
2. Claude Code is installed and logged in on this machine, or you will open it from CCR.
|
||||
3. You are on the **Agent Config** page and click **Add profile**.
|
||||
|
||||
## How CCR connects Claude Code
|
||||
|
||||
When you save (apply) a Claude Code profile, CCR writes a managed block into the Claude Code **settings file**:
|
||||
|
||||
- Sets `apiKeyHelper` to a CCR-generated helper script that returns this profile's CCR API key.
|
||||
- Sets the gateway endpoint into `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_BASE_URL`, and `CLAUDE_AGENT_API_BASE_URL`.
|
||||
- Removes any conflicting `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY` so the key comes from CCR, not a stored token.
|
||||
- Writes your selected model and model aliases (see below).
|
||||
- Generates a launch **wrapper** that sets the entry mode, remote-sync (for App), bot environment, and tool/MCP config, then runs Claude Code.
|
||||
|
||||
With **Only opened from CCR**, these files live in an isolated CCR-managed directory keyed by the profile `id`, so the Claude Code you open directly from the system is untouched. With **System default**, CCR writes the real `~/.claude/settings.json`.
|
||||
|
||||
Claude App is **zero-config**: opening it from CCR automatically writes the App gateway config, API key, model discovery list, and a separate user-data directory. If Claude App is already running, reopen it from CCR when prompted.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **Claude Code**.
|
||||
2. Enter a **Config name** (for example `Claude Code - Work`).
|
||||
3. Choose **Effect scope** and **Entry mode**.
|
||||
4. Select a **Model** (or leave it empty to keep the Claude Code default).
|
||||
5. Optionally fill in model aliases, the settings file, and environment variables.
|
||||
5. Optionally fill in model aliases and advanced settings.
|
||||
6. If the entry mode includes App and you use AgentClaw, bind a **Bot**.
|
||||
7. **Save**, then open Claude Code from CCR (terminal button for CLI, play button for App).
|
||||
|
||||
|
|
@ -50,57 +36,52 @@ Claude App is **zero-config**: opening it from CCR automatically writes the App
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **Claude Code** | Tells CCR to apply the Claude Code settings-file mechanism. |
|
||||
| Config name | Free text, e.g. `Claude Code - Work` | Identifies the profile in CCR and is used in the `ccr-app "<name>"` command. Spaces are allowed and auto-quoted. |
|
||||
| Agent | Choose **Claude Code** | Creates Claude Code launch entries in CCR. |
|
||||
| Config name | Free text, e.g. `Claude Code - Work` | Identifies the profile in CCR. Desktop commands use `ccr-app "<name>"`; CLI commands use `ccr "<name>"`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and are not offered as launch entries. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | `Only opened from CCR` uses an isolated CCR-managed settings file; `System default` writes `~/.claude/settings.json`. Only one enabled system-default Claude Code profile is allowed. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Keeps changes limited to CCR launches, or makes this the system-default Claude Code profile. Only one enabled system-default Claude Code profile is allowed. |
|
||||
| Entry mode | `CLI & APP` / `CLI only` / `App only` | `CLI & APP` exposes both the terminal command and the App button; `CLI only` only generates a CLI command; `App only` only exposes the App. |
|
||||
| Model | A provider model or Fusion model, e.g. `Moonshot/kimi-k3` | Writes `ANTHROPIC_MODEL`. Leave it empty to keep Claude Code's own default model. |
|
||||
| Fable model | Optional model selector | Writes `ANTHROPIC_DEFAULT_FABLE_MODEL`, the model Claude Code uses for the Fable tier. |
|
||||
| Opus model | Optional model selector | Writes `ANTHROPIC_DEFAULT_OPUS_MODEL`. |
|
||||
| Sonnet model | Optional model selector | Writes `ANTHROPIC_DEFAULT_SONNET_MODEL`. |
|
||||
| Haiku model | Optional model selector | Writes `ANTHROPIC_DEFAULT_HAIKU_MODEL` (also the small/fast model). |
|
||||
| Settings file | Path, default `~/.claude/settings.json` | Used in **System default** scope. In **Only opened from CCR** this is ignored in favor of an isolated CCR-managed file. |
|
||||
| Model | A provider model or Fusion model, e.g. `Moonshot/kimi-k3` | Default model for this profile. Leave it empty to keep Claude Code's own default model. |
|
||||
| Fable model | Optional model selector | Overrides the model Claude Code uses for the Fable tier. |
|
||||
| Opus model | Optional model selector | Overrides the Opus tier. |
|
||||
| Sonnet model | Optional model selector | Overrides the Sonnet tier. |
|
||||
| Haiku model | Optional model selector | Overrides the small/fast tier. |
|
||||
| Settings file | Path | Used for the system-default Claude Code profile. |
|
||||
| CCR managed compact | Toggle | Lets CCR manage context compaction for this profile. |
|
||||
| Environment variables | Key/value rows | Merged into the Claude Code settings `env`. See below. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Select a saved Bot (App entry only) | Binds an AgentClaw IM bot to the Claude App entry. CLI does not forward bot messages. |
|
||||
|
||||
### Model aliases
|
||||
|
||||
Claude Code picks a model per internal tier. The **Model** field sets the default (`ANTHROPIC_MODEL`). The optional **Fable / Opus / Sonnet / Haiku model** fields override each tier individually, so you can, for example, run a strong provider model for Opus while keeping a cheaper one for Haiku. Leave a tier empty to let Claude Code choose it.
|
||||
Claude Code picks a model per tier. The **Model** field sets the default. The optional **Fable / Opus / Sonnet / Haiku model** fields override each tier individually, so you can, for example, run a strong provider model for Opus while keeping a cheaper one for Haiku. Leave a tier empty to let Claude Code choose it.
|
||||
|
||||
### CLI vs App model lists
|
||||
|
||||
| Entry | Model list source | Notes |
|
||||
| --- | --- | --- |
|
||||
| Claude Code CLI | CCR gateway model discovery | Use `/model` in the CLI to see and switch models exposed by CCR, including Fusion models. |
|
||||
| Claude App | CCR-generated Claude App inference models | Claude App needs Claude-compatible names, so CCR maps each `Provider/model` and Fusion model into an entry Claude App recognizes, while keeping the real meaning in the label. |
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` — included by default so `/model` can list gateway models. Keep it unless you have a reason to disable discovery.
|
||||
- `CCR_CLAUDE_CODE_BIN` — absolute path to the real `claude` executable, if it is not on `PATH` in the CCR Desktop process.
|
||||
- Any other rows are exported into the launch wrapper and merged into settings `env`.
|
||||
- In a China timezone, CCR also sets `TZ=UTC` automatically to avoid timezone-related tool failures; you do not configure this.
|
||||
|
||||
> CCR always writes the gateway endpoint, API key helper, and launch wrapper itself. Do not manually set `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` here — CCR manages them.
|
||||
| Claude App | CCR model list | Open Claude App from CCR to use the model list for this profile. |
|
||||
|
||||
## Open and use
|
||||
|
||||
- **CLI:** click the terminal button on the profile card and run the copied command:
|
||||
- **CLI:** click the terminal button on the desktop profile card and run the copied command:
|
||||
```text
|
||||
ccr-app "Claude Code - Work"
|
||||
```
|
||||
For CLI, run:
|
||||
```text
|
||||
ccr "Claude Code - Work"
|
||||
```
|
||||
Inside Claude Code, run `/model` to view and switch the models CCR exposes.
|
||||
- **App:** click the play button. CCR opens Claude App with the profile's gateway config and isolated user-data directory. Reopening the same profile activates the existing window.
|
||||
- **App:** click the play button. CCR opens Claude App with this profile. Reopening the same profile activates the existing window.
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id`. With **Only opened from CCR**, Claude Code CLI gets an isolated settings file and wrapper, and Claude App gets an isolated user-data directory, so multiple Claude Code profiles can run side by side with different models, scopes, or bots.
|
||||
Create separate Claude Code profiles when you want different models, scopes, or bots.
|
||||
|
||||
## AgentClaw (Bot)
|
||||
|
||||
If you bind a Bot and open Claude App from CCR, the companion worker exposes Projects/Sessions, streaming replies, attachments, Session usage, and native permission/Ask User requests to IM. The worker stops when the App closes. See [AgentClaw](/en/agentclaw/) for the full IM setup.
|
||||
If you bind a Bot and open Claude App from CCR, Claude App can relay conversations through the selected IM channel. See [AgentClaw](/en/agentclaw/) for the full IM setup.
|
||||
|
||||
## Verify
|
||||
|
||||
|
|
@ -112,6 +93,6 @@ If you bind a Bot and open Claude App from CCR, the companion worker exposes Pro
|
|||
## Common issues
|
||||
|
||||
- **Requests do not reach CCR:** confirm the profile is **Enabled** and you opened Claude Code from CCR (not from the system). A directly-opened Claude Code is not affected unless the scope is **System default**.
|
||||
- **`/model` shows no CCR models:** keep `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` in the profile env, and confirm a provider + model is configured.
|
||||
- **`/model` shows no CCR models:** confirm a provider + model is configured.
|
||||
- **Claude App did not pick up the config:** Claude App is already running — reopen it from CCR, or restart it when prompted.
|
||||
- **Model aliases have no effect:** aliases only change which provider model a Claude Code tier uses; the value must be a valid `Provider/model` or Fusion model that CCR can serve.
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
title: Claude Design setup and configuration
|
||||
pageTitle: Claude Design
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect Claude Design to CCR. Claude Design is App-only and managed entirely by CCR Desktop. This page covers the few fields that apply and how routing works for it."
|
||||
lead: "Connect Claude Design to CCR. Claude Design is App-only."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
Claude Design is Anthropic's design agent, run as a desktop app. In CCR it is **App only** and always scoped to CCR: it is opened from CCR Desktop, and CCR manages its runtime for you. Unlike the coding agents, Claude Design has no model/provider/config-file fields to fill — CCR handles those automatically.
|
||||
Claude Design is Anthropic's design agent, run as a desktop app. In CCR it is **App only** and is opened from CCR Desktop.
|
||||
|
||||
Use this page to register a Claude Design profile and, optionally, add routing rules.
|
||||
|
||||
|
|
@ -19,10 +19,6 @@ Use this page to register a Claude Design profile and, optionally, add routing r
|
|||
2. Claude Design is available through CCR Desktop.
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects Claude Design
|
||||
|
||||
Claude Design is **managed by CCR Desktop** — when you apply a profile, CCR does not write a separate agent config file the way it does for Claude Code or Codex. The profile is a launch entry that CCR Desktop uses to open and run Claude Design with the active gateway, providers, routing, and model selection. It can only be opened from CCR Desktop (not from a `ccr-app` terminal command).
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **Claude Design**.
|
||||
|
|
@ -41,15 +37,13 @@ Claude Design is fixed to **App only** and **Only opened from CCR**. Most agent
|
|||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Routing | Optional routing rules | Rules that affect how this profile's requests are routed. See [Routing](/en/routing/). |
|
||||
|
||||
> Model, provider, config file, environment variables, and entry mode are not exposed for Claude Design — CCR Desktop manages them.
|
||||
|
||||
## Routing
|
||||
|
||||
You can attach routing rules to a Claude Design profile to control which provider or model handles its requests (for example, to pin a specific provider or add failover). The enhanced-route toggle does not apply to Claude Design (it is always on); only explicit rules have an effect. See [Routing](/en/routing/) for how rules work.
|
||||
|
||||
## Open and use
|
||||
|
||||
Open Claude Design from CCR Desktop. It cannot be launched with a `ccr-app` terminal command.
|
||||
Open Claude Design from CCR Desktop. It cannot be launched with a terminal profile command.
|
||||
|
||||
## Verify
|
||||
|
||||
|
|
@ -59,6 +53,6 @@ Open Claude Design from CCR Desktop. It cannot be launched with a `ccr-app` term
|
|||
|
||||
## Common issues
|
||||
|
||||
- **Cannot open from the terminal:** Claude Design is App-only and is opened from CCR Desktop, not via `ccr-app`.
|
||||
- **Cannot open from the terminal:** Claude Design is App-only and is opened from CCR Desktop.
|
||||
- **Requests bypass CCR:** confirm the profile is **Enabled** and you opened Claude Design from CCR Desktop.
|
||||
- **Routing rules have no effect:** only explicit rules apply; the enhanced-route toggle is always on for Claude Design.
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
title: Codex setup and configuration
|
||||
pageTitle: Codex
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect Codex (CLI and the ChatGPT desktop app) to CCR. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect Codex (CLI and the ChatGPT desktop app) to CCR."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
Codex is OpenAI's coding agent. CCR supports both surfaces:
|
||||
|
||||
- **Codex CLI** — the terminal agent. CCR writes a managed provider into Codex's `config.toml` and opens the CLI through a middleware launcher.
|
||||
- **ChatGPT app** — the desktop app (the renamed Codex desktop app). CCR launches it with an isolated user-data directory and routes its app-server traffic through the CCR middleware.
|
||||
- **Codex CLI** — the terminal agent.
|
||||
- **ChatGPT app** — the desktop app (the renamed Codex desktop app).
|
||||
|
||||
Use this page to route Codex to a non-OpenAI provider, pin a model, or run isolated Codex/ChatGPT instances.
|
||||
Use this page to route Codex to a non-OpenAI provider, pin a model, or run separate Codex/ChatGPT instances.
|
||||
|
||||
> New to CCR? Add a provider and model first. See [Add a provider](/en/guides/provider/) and the [Agent Config overview](/en/configuration/profiles/).
|
||||
|
||||
|
|
@ -22,27 +22,13 @@ Use this page to route Codex to a non-OpenAI provider, pin a model, or run isola
|
|||
2. Codex CLI is installed (available as `codex` on `PATH`), or the ChatGPT desktop app is installed.
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects Codex
|
||||
|
||||
When you save a Codex profile, CCR writes managed blocks into the Codex **config file** (`config.toml`):
|
||||
|
||||
- `model_provider`, `model`, and a `model_catalog_json` pointer at the top level.
|
||||
- A `[model_providers.<providerId>]` table with the CCR gateway `/v1` base URL, a bearer token, and `wire_api = "responses"`.
|
||||
- A separate `<providerId>.config.toml` profile file (Codex's separate-profile-files format).
|
||||
- A model catalog file (`ccr-model-catalog.json`) the native app-server reads for `model/list`.
|
||||
- A CLI **middleware launcher** that sets `CODEX_HOME`, the provider/model, and the entry mode, then runs Codex.
|
||||
|
||||
With **Only opened from CCR**, these files live in an isolated CCR-managed directory keyed by the profile `id`. With **System default**, CCR writes `~/.codex/config.toml`.
|
||||
|
||||
For the **ChatGPT app**, CCR starts the Electron executable inside the app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display; it does not synthesize model or plugin listings.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **Codex**.
|
||||
2. Enter a **Config name** (for example `Codex - Work`).
|
||||
3. Choose **Effect scope** and **Entry mode**.
|
||||
4. Confirm **Provider ID**, **Provider name**, and **Codex model**.
|
||||
5. Adjust **Show all sessions**, **Config file**, and environment variables as needed.
|
||||
5. Adjust advanced settings only if your local setup needs them.
|
||||
6. If the entry mode includes App and you use AgentClaw, bind a **Bot**.
|
||||
7. **Save**, then open Codex from CCR (terminal button for CLI, play button for ChatGPT).
|
||||
|
||||
|
|
@ -50,46 +36,42 @@ For the **ChatGPT app**, CCR starts the Electron executable inside the app bundl
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **Codex** | Tells CCR to apply the Codex `config.toml` mechanism. |
|
||||
| Config name | Free text, e.g. `Codex - Work` | Identifies the profile and is used in `ccr-app "<name>"`. |
|
||||
| Agent | Choose **Codex** | Creates Codex launch entries in CCR. |
|
||||
| Config name | Free text, e.g. `Codex - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>"`; CLI commands use `ccr "<name>"`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Isolated CCR-managed files vs. the real `~/.codex/config.toml`. Only one enabled system-default Codex profile is allowed. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Keeps changes limited to CCR launches, or makes this the system-default Codex profile. Only one enabled system-default Codex profile is allowed. |
|
||||
| Entry mode | `CLI & APP` / `CLI only` / `App only` | Which launch entries (terminal command and/or ChatGPT app) are exposed. |
|
||||
| Provider ID | Alphanumerics, `.`, `_`, `-`; default `claude-code-router` | Writes Codex `model_provider` and the provider table key. Keep it stable. |
|
||||
| Provider ID | Alphanumerics, `.`, `_`, `-`; default `claude-code-router` | Provider reference for this Codex profile. Keep it stable. |
|
||||
| Provider name | Free text; default `Claude Code Router` | Display name shown in Codex. |
|
||||
| Codex model | Provider model or Fusion model | Default Codex model. If left empty, CCR uses the first available default model. |
|
||||
| Show all sessions | Toggle | Writes `show_all_sessions` so Codex lists all sessions. |
|
||||
| Config file | Path, default `~/.codex/config.toml` | Used in **System default** scope; **Only opened from CCR** writes into CCR-managed directories. |
|
||||
| Codex CLI path | Optional absolute path to the `codex` binary | Used by the middleware launcher. Fill only when Codex is not on `PATH`. |
|
||||
| Config file | Path | Used for the system-default Codex profile. |
|
||||
| Codex CLI path | Optional absolute path to the `codex` binary | Fill only when Codex is not on `PATH`. |
|
||||
| Codex home | Optional directory | Sets `CODEX_HOME`. Fill only when you need a specific home directory. |
|
||||
| Remote frontend mode | `app` / `cli` / `claude-code` | How the middleware presents the Codex frontend. Leave default unless you have a specific reason. |
|
||||
| Remote frontend mode | `app` / `cli` / `claude-code` | Leave default unless you have a specific reason. |
|
||||
| CCR managed compact | Toggle | Lets CCR manage context compaction for this profile. |
|
||||
| Environment variables | Key/value rows | Injected into Codex CLI / ChatGPT. See below. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Select a saved Bot (App entry only) | Binds an AgentClaw IM bot to the ChatGPT app entry. |
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CCR_CODEX_CHATGPT_AUTH_FILE` (legacy `CODEXL_CODEX_CHATGPT_AUTH_FILE`) — path to a valid `auth.json`. Set it only when a profile should share a ChatGPT login token in memory (without copying it). By default each profile reads login state only from its own Codex home.
|
||||
- Claude Code-specific model-discovery variables are **not** passed to Codex.
|
||||
- Any other rows are exported into the middleware launcher.
|
||||
|
||||
> Each profile without shared credentials reports a local non-OpenAI compatibility identity so current ChatGPT builds do not enter a repeated authentication/attestation loop. CCR creates a short-lived `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is never retained as login state.
|
||||
|
||||
## Open and use
|
||||
|
||||
- **CLI:** click the terminal button and run the copied command:
|
||||
- **CLI:** click the terminal button in the desktop app and run the copied command:
|
||||
```text
|
||||
ccr-app "Codex - Work"
|
||||
```
|
||||
- **App:** click the play button to open ChatGPT with this profile's model, provider, and isolated user-data directory. Reopening the same profile activates the existing window.
|
||||
For CLI, run:
|
||||
```text
|
||||
ccr "Codex - Work"
|
||||
```
|
||||
- **App:** click the play button to open ChatGPT with this profile's model and provider. Reopening the same profile activates the existing window.
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id`. With **Only opened from CCR**, Codex gets isolated config files and a middleware launcher, and ChatGPT gets an isolated user-data directory, so several Codex profiles can run at once with different models or providers.
|
||||
Create separate Codex profiles when you want different models or providers.
|
||||
|
||||
## AgentClaw (Bot)
|
||||
|
||||
With a Bot bound and ChatGPT opened from CCR, the companion worker uses native Codex rollout Sessions for Project/Session browsing and continuation, queueing, cancellation, model settings, usage, attachments, and diagnostics. It exists only alongside the managed app. See [AgentClaw](/en/agentclaw/).
|
||||
With a Bot bound and ChatGPT opened from CCR, ChatGPT can relay conversations through the selected IM channel. See [AgentClaw](/en/agentclaw/).
|
||||
|
||||
## Verify
|
||||
|
||||
|
|
@ -101,6 +83,6 @@ With a Bot bound and ChatGPT opened from CCR, the companion worker uses native C
|
|||
## Common issues
|
||||
|
||||
- **Requests bypass CCR:** confirm the profile is **Enabled** and you opened Codex from CCR; a directly-opened Codex is unaffected unless the scope is **System default**.
|
||||
- **ChatGPT keeps asking to sign in:** this is expected for a profile without shared credentials — it uses a local compatibility identity. Share a token only if you intentionally set `CCR_CODEX_CHATGPT_AUTH_FILE`.
|
||||
- **ChatGPT keeps asking to sign in:** sign in to ChatGPT normally, then reopen it from CCR.
|
||||
- **Provider ID rejected:** use only letters, numbers, dots, underscores, or hyphens, and keep it stable across saves.
|
||||
- **Wrong model in the app:** confirm the **Codex model** field; if left empty, CCR falls back to the first available default model.
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
title: Grok CLI setup and configuration
|
||||
pageTitle: Grok CLI
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect Grok CLI to CCR. Grok CLI is CLI-only and always scoped to CCR-launched sessions. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect Grok CLI to CCR. Grok CLI is CLI-only and always scoped to CCR-launched sessions."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
Grok CLI is xAI's coding agent. In CCR it is **CLI only** and always uses **Only opened from CCR**: CCR does not rewrite your global Grok config. Instead it generates a launch wrapper and an isolated Grok home for each profile, so inference always uses the CCR key and model gateway.
|
||||
Grok CLI is xAI's coding agent. In CCR it is **CLI only** and always uses **Only opened from CCR**.
|
||||
|
||||
Use this page to route Grok CLI to any CCR provider or Fusion model, or to run several isolated Grok CLI sessions.
|
||||
Use this page to route Grok CLI to any CCR provider or Fusion model, or to run several separate Grok CLI sessions.
|
||||
|
||||
> New to CCR? Add a provider and model first. See [Add a provider](/en/guides/provider/) and the [Agent Config overview](/en/configuration/profiles/).
|
||||
|
||||
|
|
@ -19,25 +19,12 @@ Use this page to route Grok CLI to any CCR provider or Fusion model, or to run s
|
|||
2. Grok CLI is installed (available as `grok` on `PATH`).
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects Grok CLI
|
||||
|
||||
When you save a Grok CLI profile, CCR generates a launch **wrapper** in its `bin` directory that exports:
|
||||
|
||||
- `GROK_MODELS_BASE_URL` → the CCR gateway `/v1` endpoint; `GROK_MODELS_LIST_URL` → the `/v1/models` endpoint (model list).
|
||||
- `XAI_API_KEY` → this profile's CCR API key.
|
||||
- `GROK_DEFAULT_MODEL` → your selected model.
|
||||
- `GROK_HOME` → a profile-specific directory.
|
||||
|
||||
It also points `GROK_HOME` at a profile-specific home. That home starts as a private copy of your source Grok home's `config.toml`, while `auth.json` is excluded so a local xAI OAuth token cannot override the CCR key. Plugins, skills, sessions, and other home entries stay linked to the original Grok home.
|
||||
|
||||
If the CCR Desktop gateway is not running when you launch, `ccr-app` starts a shared temporary gateway service and stops it after the last concurrent Grok session exits.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **Grok CLI**.
|
||||
2. Enter a **Config name** (for example `Grok - Work`).
|
||||
3. Select a **Model**.
|
||||
4. Add environment variables only if you need a non-default Grok binary or source home.
|
||||
4. Leave advanced environment settings empty unless you have a specific local setup need.
|
||||
5. **Save**, then copy and run the command from the profile card.
|
||||
|
||||
## Configuration reference
|
||||
|
|
@ -46,44 +33,41 @@ Grok CLI is fixed to **Only opened from CCR** and **CLI only**, so those two fie
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **Grok CLI** | Tells CCR to generate the Grok wrapper and isolated home. |
|
||||
| Config name | Free text, e.g. `Grok - Work` | Identifies the profile and is used in `ccr-app "<name>"`. |
|
||||
| Agent | Choose **Grok CLI** | Creates a Grok CLI launch entry in CCR. |
|
||||
| Config name | Free text, e.g. `Grok - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>"`; CLI commands use `ccr "<name>"`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Model | A provider model or Fusion model | Writes `GROK_DEFAULT_MODEL`, the model Grok CLI uses at startup. |
|
||||
| Environment variables | Key/value rows | Exported into the wrapper. See below. |
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CCR_GROK_BIN` — absolute path to the real `grok` executable, if it is not on `PATH` in the CCR Desktop process.
|
||||
- `GROK_HOME` / `GROK_STORAGE_DIR` / `GROK_CONFIG_DIR` — set the **source** Grok home CCR copies `config.toml` from (defaults to `~/.grok`). Use this only when your Grok home lives somewhere non-standard.
|
||||
- CCR also adds the gateway host to `NO_PROXY`/`no_proxy` automatically so Grok talks to the local gateway directly; you do not configure this.
|
||||
|
||||
> Variables CCR manages itself (`GROK_MODELS_BASE_URL`, `GROK_MODELS_LIST_URL`, `GROK_DEFAULT_MODEL`, `GROK_HOME`, `XAI_API_KEY`, etc.) are reserved — setting them manually in the profile env has no effect because the wrapper overwrites them.
|
||||
| Model | A provider model or Fusion model | The model Grok CLI uses at startup. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
|
||||
## Open and use
|
||||
|
||||
Click the terminal button on the profile card and run the copied command:
|
||||
Desktop command copied from the profile card:
|
||||
|
||||
```text
|
||||
ccr-app "Grok - Work"
|
||||
```
|
||||
|
||||
For CLI, run:
|
||||
|
||||
```text
|
||||
ccr "Grok - Work"
|
||||
```
|
||||
|
||||
Inside Grok CLI, use `/model` to switch among the provider and Fusion models CCR returns; switched requests still go through CCR.
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id` and its own isolated `GROK_HOME`, so multiple Grok CLI profiles can run at once with different models or separate session histories, all without touching your original `~/.grok`.
|
||||
Create separate Grok CLI profiles when you want separate launch entries with different models.
|
||||
|
||||
## Verify
|
||||
|
||||
1. Run the `ccr-app` command copied from the profile card.
|
||||
1. Run the desktop `ccr-app` command copied from the profile card, or the CLI `ccr` command.
|
||||
2. Send one message in Grok CLI and confirm it replies.
|
||||
3. Open **Request logs** in CCR and confirm the request passed through the gateway.
|
||||
4. Run `/model` and confirm the CCR-exposed models appear.
|
||||
|
||||
## Common issues
|
||||
|
||||
- **Grok uses your xAI account instead of CCR:** the profile home excludes `auth.json` by design; confirm you launched via `ccr-app` and not a directly-opened `grok`.
|
||||
- **`grok` not found:** set `CCR_GROK_BIN` to the real executable path.
|
||||
- **`/model` shows no CCR models:** confirm a provider + model is configured in CCR, and that the gateway is reachable (the temp gateway starts only while a managed session is running).
|
||||
- **Wrong source config copied:** if your Grok home is not `~/.grok`, set `GROK_HOME` (or `GROK_STORAGE_DIR` / `GROK_CONFIG_DIR`) to the correct source home.
|
||||
- **Grok uses your xAI account instead of CCR:** confirm you launched Grok from the CCR profile card.
|
||||
- **`grok` not found:** make sure Grok CLI is installed and available from the same shell environment used to start CCR Desktop.
|
||||
- **`/model` shows no CCR models:** confirm a provider + model is configured in CCR.
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
title: Kilo Code setup and configuration
|
||||
pageTitle: Kilo Code
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect Kilo Code to CCR. Kilo Code is CLI-only in CCR. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect Kilo Code to CCR. Kilo Code is CLI-only in CCR."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
Kilo Code is a coding agent with an OpenAI-compatible provider model. In CCR it is **CLI only**: CCR writes a managed gateway provider into Kilo's JSONC config and opens the CLI through a wrapper that points Kilo at CCR.
|
||||
Kilo Code is a coding agent with an OpenAI-compatible provider model. In CCR it is **CLI only**.
|
||||
|
||||
Use this page to route Kilo Code to any CCR provider or Fusion model.
|
||||
|
||||
|
|
@ -19,22 +19,13 @@ Use this page to route Kilo Code to any CCR provider or Fusion model.
|
|||
2. Kilo Code is installed (available as `kilo` on `PATH`).
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects Kilo Code
|
||||
|
||||
When you save a Kilo Code profile, CCR:
|
||||
|
||||
- Writes a managed gateway provider into the Kilo **config file** (`kilo.jsonc`) that points Kilo at the CCR gateway with this profile's token and selected model.
|
||||
- Generates a launch **wrapper** that sets `KILO_CONFIG` (and an inline `KILO_CONFIG_CONTENT`) to the managed config, then runs Kilo.
|
||||
|
||||
With **Only opened from CCR**, the config lives in an isolated CCR-managed directory keyed by the profile `id`. With **System default**, CCR writes the real `~/.config/kilo/kilo.jsonc`.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **Kilo Code**.
|
||||
2. Enter a **Config name** (for example `Kilo - Work`).
|
||||
3. Choose **Effect scope**.
|
||||
4. Confirm **Provider ID**, **Provider name**, and **Kilo model**.
|
||||
5. Adjust the **Config file** and environment variables as needed.
|
||||
5. Adjust advanced settings only if your local setup needs them.
|
||||
6. **Save**, then copy and run the command from the profile card.
|
||||
|
||||
## Configuration reference
|
||||
|
|
@ -43,44 +34,42 @@ Kilo Code is fixed to **CLI only**, so the entry mode is not editable. The field
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **Kilo Code** | Tells CCR to write the Kilo gateway config and wrapper. |
|
||||
| Config name | Free text, e.g. `Kilo - Work` | Identifies the profile and is used in `ccr-app "<name>"`. |
|
||||
| Agent | Choose **Kilo Code** | Creates a Kilo Code launch entry in CCR. |
|
||||
| Config name | Free text, e.g. `Kilo - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>"`; CLI commands use `ccr "<name>"`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Isolated CCR-managed config vs. the real `~/.config/kilo/kilo.jsonc`. Only one enabled system-default Kilo profile is allowed. |
|
||||
| Provider ID | Default `claude-code-router` | The provider reference CCR writes into the Kilo config. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Keeps changes limited to CCR launches, or makes this the system-default Kilo profile. Only one enabled system-default Kilo profile is allowed. |
|
||||
| Provider ID | Default `claude-code-router` | Provider reference for this Kilo profile. |
|
||||
| Provider name | Free text; default `Claude Code Router` | Display name shown in Kilo. |
|
||||
| Kilo model | A provider model or Fusion model | Default model Kilo uses through CCR. |
|
||||
| Config file | Path, default `~/.config/kilo/kilo.jsonc` | Used in **System default** scope; **Only opened from CCR** writes into CCR-managed directories. |
|
||||
| Environment variables | Key/value rows | Exported into the wrapper. See below. |
|
||||
|
||||
> **Show all sessions** is not exposed for Kilo Code; it is forced off.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CCR_KILO_BIN` / `KILO_BIN` — absolute path to the real `kilo` executable, if it is not on `PATH` in the CCR Desktop process.
|
||||
- Any other rows are exported into the launch wrapper.
|
||||
- Variables CCR manages itself (`KILO_CONFIG`, `KILO_CONFIG_CONTENT`, `CCR_PROFILE_SURFACE`) are reserved — setting them manually has no effect.
|
||||
| Config file | Path | Used for the system-default Kilo profile. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
|
||||
## Open and use
|
||||
|
||||
Click the terminal button on the profile card and run the copied command:
|
||||
Desktop command copied from the profile card:
|
||||
|
||||
```text
|
||||
ccr-app "Kilo - Work"
|
||||
```
|
||||
|
||||
For CLI, run:
|
||||
|
||||
```text
|
||||
ccr "Kilo - Work"
|
||||
```
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id`. With **Only opened from CCR**, Kilo gets an isolated config file and wrapper, so several Kilo profiles can run at once with different models or providers.
|
||||
Create separate Kilo profiles when you want different models or providers.
|
||||
|
||||
## Verify
|
||||
|
||||
1. Run the `ccr-app` command copied from the profile card.
|
||||
1. Run the desktop `ccr-app` command copied from the profile card, or the CLI `ccr` command.
|
||||
2. Send one message in Kilo and confirm it replies.
|
||||
3. Open **Request logs** in CCR and confirm the request passed through the gateway.
|
||||
|
||||
## Common issues
|
||||
|
||||
- **Requests bypass CCR:** confirm the profile is **Enabled** and you launched via `ccr-app`; a directly-opened Kilo is unaffected unless the scope is **System default**.
|
||||
- **`kilo` not found:** set `CCR_KILO_BIN` to the real executable path.
|
||||
- **Requests bypass CCR:** confirm the profile is **Enabled** and you launched through the CCR profile command; a directly-opened Kilo is unaffected unless the scope is **System default**.
|
||||
- **`kilo` not found:** make sure Kilo Code is installed and available from the same shell environment used to start CCR Desktop.
|
||||
- **Wrong model:** confirm the **Kilo model** field resolves to a model CCR can serve.
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
title: Kimi CLI setup and configuration
|
||||
pageTitle: Kimi CLI
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect Kimi CLI to CCR. Kimi CLI is CLI-only and always scoped to CCR-launched sessions. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect Kimi CLI to CCR. Kimi CLI is CLI-only and always scoped to CCR-launched sessions."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
Kimi CLI is Moonshot's coding agent. In CCR it is **CLI only** and always uses **Only opened from CCR**: CCR does not rewrite your original `~/.kimi-code/config.toml`. Instead it generates a profile-specific Kimi home whose `config.toml` registers a private CCR provider and one model entry per selection, so `/model` switching always stays routed through CCR.
|
||||
Kimi CLI is Moonshot's coding agent. In CCR it is **CLI only** and always uses **Only opened from CCR**.
|
||||
|
||||
Use this page to route Kimi CLI to any CCR provider or Fusion model, expose several switchable models, or run isolated Kimi CLI sessions.
|
||||
Use this page to route Kimi CLI to any CCR provider or Fusion model, expose several switchable models, or run separate Kimi CLI sessions.
|
||||
|
||||
> New to CCR? Add a provider and model first. See [Add a provider](/en/guides/provider/) and the [Agent Config overview](/en/configuration/profiles/).
|
||||
|
||||
|
|
@ -19,22 +19,12 @@ Use this page to route Kimi CLI to any CCR provider or Fusion model, expose seve
|
|||
2. Kimi CLI is installed (available as `kimi` on `PATH`).
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects Kimi CLI
|
||||
|
||||
When you save a Kimi CLI profile, CCR:
|
||||
|
||||
- Creates a profile-specific `KIMI_CODE_HOME` directory under its config tree.
|
||||
- Generates a `config.toml` in that home defining a private OpenAI-compatible CCR provider (`base_url` = gateway `/v1`, API key = the profile token) and a `[models.<model>]` entry for the default model and every available model, including context window, capabilities, reasoning efforts, and display name.
|
||||
- Generates a launch **wrapper** that points `KIMI_CODE_HOME` at this home, clears any single-model override variables, then runs Kimi.
|
||||
|
||||
The original `~/.kimi-code/config.toml` is never rewritten. CCR reuses available sessions, skills, plugins, MCP configuration, and credentials from the source Kimi home by linking them into the profile home. If CCR Desktop is not running, the wrapper starts a shared temporary gateway and stops it after the last managed Kimi session exits.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **Kimi CLI**.
|
||||
2. Enter a **Config name** (for example `Kimi - Work`).
|
||||
3. Choose a **Kimi model** (the default) and one or more **Available models**.
|
||||
4. Add environment variables only if you need a non-default Kimi binary, source home, or custom headers.
|
||||
4. Leave advanced environment settings empty unless you have a specific local setup need.
|
||||
5. **Save**, then copy and run the command from the profile card.
|
||||
|
||||
## Configuration reference
|
||||
|
|
@ -43,39 +33,36 @@ Kimi CLI is fixed to **Only opened from CCR** and **CLI only**, so those two fie
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **Kimi CLI** | Tells CCR to generate the Kimi home, `config.toml`, and wrapper. |
|
||||
| Config name | Free text, e.g. `Kimi - Work` | Identifies the profile and is used in `ccr-app "<name>"`. |
|
||||
| Agent | Choose **Kimi CLI** | Creates a Kimi CLI launch entry in CCR. |
|
||||
| Config name | Free text, e.g. `Kimi - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>"`; CLI commands use `ccr "<name>"`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Kimi model | A provider model or Fusion model | The default model (`default_model`). At least one model is required. |
|
||||
| Available models | One or more provider/Fusion models | Each becomes a `[models.<model>]` entry exposed by Kimi's `/model`. The default model is always included. |
|
||||
| Environment variables | Key/value rows | Exported into the wrapper. See below. |
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CCR_KIMI_BIN` / `KIMI_BIN` — absolute path to the real `kimi` executable, if it is not on `PATH`.
|
||||
- `CCR_KIMI_SOURCE_HOME` / `KIMI_CODE_HOME` — set the **source** Kimi home CCR reuses sessions/skills/plugins/credentials from (defaults to `~/.kimi-code`).
|
||||
- `KIMI_CODE_CUSTOM_HEADERS` — newline-separated `Key: Value` lines sent as custom headers on every CCR request from this profile.
|
||||
- CCR also adds the gateway host to `NO_PROXY`/`no_proxy` automatically; you do not configure this.
|
||||
|
||||
> Variables CCR manages itself (`KIMI_CODE_HOME` and the single-model override variables) are reserved — setting them manually has no effect because the wrapper sets or clears them.
|
||||
| Kimi model | A provider model or Fusion model | The default model. At least one model is required. |
|
||||
| Available models | One or more provider/Fusion models | Models available from Kimi's `/model` menu. The default model is always included. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
|
||||
## Open and use
|
||||
|
||||
Click the terminal button on the profile card and run the copied command:
|
||||
Desktop command copied from the profile card:
|
||||
|
||||
```text
|
||||
ccr-app "Kimi - Work"
|
||||
```
|
||||
|
||||
For CLI, run:
|
||||
|
||||
```text
|
||||
ccr "Kimi - Work"
|
||||
```
|
||||
|
||||
Inside Kimi CLI, use `/model` to switch among the default and available models. Every selection stays routed through CCR's providers, routing, and Fusion.
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id` and its own `KIMI_CODE_HOME`, so multiple Kimi CLI profiles can run at once with different default models or model sets, all without changing your original Kimi configuration.
|
||||
Create separate Kimi CLI profiles when you want different default models or model sets.
|
||||
|
||||
## Verify
|
||||
|
||||
1. Run the `ccr-app` command copied from the profile card.
|
||||
1. Run the desktop `ccr-app` command copied from the profile card, or the CLI `ccr` command.
|
||||
2. Send one message in Kimi CLI and confirm it replies.
|
||||
3. Open **Request logs** in CCR and confirm the request passed through the gateway.
|
||||
4. Run `/model` and confirm the default plus available models appear.
|
||||
|
|
@ -84,5 +71,4 @@ Each profile has its own `id` and its own `KIMI_CODE_HOME`, so multiple Kimi CLI
|
|||
|
||||
- **`/model` is empty or missing models:** add at least one **Available model** (the default counts); confirm a provider + model is configured in CCR.
|
||||
- **Profile cannot be saved:** Kimi CLI requires both a default model and at least one available model.
|
||||
- **`kimi` not found:** set `CCR_KIMI_BIN` to the real executable path.
|
||||
- **Sessions/credentials not reused:** if your Kimi home is not `~/.kimi-code`, set `CCR_KIMI_SOURCE_HOME` (or `KIMI_CODE_HOME`) to the correct source home.
|
||||
- **`kimi` not found:** make sure Kimi CLI is installed and available from the same shell environment used to start CCR Desktop.
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
title: OpenCode setup and configuration
|
||||
pageTitle: OpenCode
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect OpenCode (CLI and App) to CCR. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect OpenCode (CLI and App) to CCR."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
OpenCode is a coding agent with an OpenAI-compatible provider model. CCR supports both surfaces:
|
||||
|
||||
- **OpenCode CLI** — the terminal agent. CCR writes a managed gateway provider into OpenCode's JSONC config and opens the CLI through a wrapper.
|
||||
- **OpenCode App** — the desktop app. CCR launches the installed OpenCode Desktop executable with the same effective config.
|
||||
- **OpenCode CLI** — the terminal agent.
|
||||
- **OpenCode App** — the desktop app.
|
||||
|
||||
Use this page to route OpenCode to any CCR provider or Fusion model, or to attach an IM bot to the App.
|
||||
|
||||
|
|
@ -22,22 +22,13 @@ Use this page to route OpenCode to any CCR provider or Fusion model, or to attac
|
|||
2. OpenCode CLI is installed (available as `opencode` on `PATH`); for App mode, the OpenCode Desktop app is installed.
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects OpenCode
|
||||
|
||||
When you save an OpenCode profile, CCR:
|
||||
|
||||
- Writes a managed gateway provider into the OpenCode **config file** (`opencode.jsonc`) that points OpenCode at the CCR gateway with this profile's token and selected model.
|
||||
- Generates a launch **wrapper** that sets `OPENCODE_CONFIG` (and an inline `OPENCODE_CONFIG_CONTENT`) to the managed config and `OPENCODE_CLIENT=cli`, then runs OpenCode.
|
||||
|
||||
With **Only opened from CCR**, the config lives in an isolated CCR-managed directory keyed by the profile `id`. With **System default**, CCR writes the real `~/.config/opencode/opencode.jsonc`. The App launches the installed OpenCode Desktop executable with the same effective config.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **OpenCode**.
|
||||
2. Enter a **Config name** (for example `OpenCode - Work`).
|
||||
3. Choose **Effect scope** and **Entry mode**.
|
||||
4. Confirm **Provider ID**, **Provider name**, and **OpenCode model**.
|
||||
5. Adjust the **Config file** and environment variables as needed.
|
||||
5. Adjust advanced settings only if your local setup needs them.
|
||||
6. If the entry mode includes App and you use AgentClaw, bind a **Bot**.
|
||||
7. **Save**, then open OpenCode from CCR (terminal button for CLI, play button for App).
|
||||
|
||||
|
|
@ -45,42 +36,37 @@ With **Only opened from CCR**, the config lives in an isolated CCR-managed direc
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **OpenCode** | Tells CCR to write the OpenCode gateway config and wrapper. |
|
||||
| Config name | Free text, e.g. `OpenCode - Work` | Identifies the profile and is used in `ccr-app "<name>"`. |
|
||||
| Agent | Choose **OpenCode** | Creates OpenCode launch entries in CCR. |
|
||||
| Config name | Free text, e.g. `OpenCode - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>"`; CLI commands use `ccr "<name>"`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Isolated CCR-managed config vs. the real `~/.config/opencode/opencode.jsonc`. Only one enabled system-default OpenCode profile is allowed. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Keeps changes limited to CCR launches, or makes this the system-default OpenCode profile. Only one enabled system-default OpenCode profile is allowed. |
|
||||
| Entry mode | `CLI & APP` / `CLI only` / `App only` | Which launch entries (terminal command and/or App) are exposed. |
|
||||
| Provider ID | Default `claude-code-router` | The provider reference CCR writes into the OpenCode config. |
|
||||
| Provider ID | Default `claude-code-router` | Provider reference for this OpenCode profile. |
|
||||
| Provider name | Free text; default `Claude Code Router` | Display name shown in OpenCode. |
|
||||
| OpenCode model | A provider model or Fusion model | Default model OpenCode uses through CCR. |
|
||||
| Config file | Path, default `~/.config/opencode/opencode.jsonc` | Used in **System default** scope; **Only opened from CCR** writes into CCR-managed directories. |
|
||||
| Environment variables | Key/value rows | Injected into OpenCode CLI, App, and its bot worker. See below. |
|
||||
| Config file | Path | Used for the system-default OpenCode profile. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Select a saved Bot (App entry only) | Binds an AgentClaw IM bot to the OpenCode App entry. |
|
||||
|
||||
> **Show all sessions** is not exposed for OpenCode; it is forced off.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CCR_OPENCODE_BIN` / `OPENCODE_BIN` — absolute path to the real `opencode` executable, if it is not on `PATH` in the CCR Desktop process.
|
||||
- `CCR_OPENCODE_BOT_CWD` — working directory the bot worker runs OpenCode in (it is passed via `opencode run --dir`). Set it to the same project directory currently open in the App when you use a non-default workspace; otherwise the bot defaults to a fresh workspace's filesystem root.
|
||||
- `CCR_OPENCODE_BOT_AUTO_APPROVE=true` — enables OpenCode's dangerous `--auto` mode for the bot worker. Use only in a trusted environment.
|
||||
- Variables CCR manages itself (`OPENCODE_CONFIG`, `OPENCODE_CONFIG_CONTENT`, `OPENCODE_CLIENT`, `CCR_PROFILE_SURFACE`) are reserved — setting them manually has no effect.
|
||||
|
||||
## Open and use
|
||||
|
||||
- **CLI:** click the terminal button and run the copied command:
|
||||
- **CLI:** click the terminal button in the desktop app and run the copied command:
|
||||
```text
|
||||
ccr-app "OpenCode - Work"
|
||||
```
|
||||
- **App:** click the play button to open OpenCode Desktop with this profile's config. OpenCode Desktop is single-instance, so CCR stops the managed instance before switching OpenCode profiles.
|
||||
For CLI, run:
|
||||
```text
|
||||
ccr "OpenCode - Work"
|
||||
```
|
||||
- **App:** click the play button to open OpenCode Desktop with this profile. OpenCode Desktop is single-instance, so CCR switches the active profile for you.
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id`. With **Only opened from CCR**, OpenCode gets an isolated config file and wrapper. Note the App itself is single-instance, so only one OpenCode App runs at a time; switch profiles from CCR rather than launching a second App.
|
||||
Create separate OpenCode profiles when you want different models or providers. The App itself is single-instance, so switch profiles from CCR rather than launching a second App.
|
||||
|
||||
## AgentClaw (Bot)
|
||||
|
||||
With a Bot bound and the App opened from CCR, CCR starts a companion worker next to the App that runs incoming bot messages through the OpenCode CLI and replies in the same conversation. Use `/project list|current|use` to pick a project, then `/session list|current|new|use|reset` to manage sessions. Only these slash-command domains are intercepted. The worker stops when the managed App exits or the profile is switched. See [AgentClaw](/en/agentclaw/).
|
||||
With a Bot bound and the App opened from CCR, OpenCode can relay conversations through the selected IM channel. See [AgentClaw](/en/agentclaw/).
|
||||
|
||||
## Verify
|
||||
|
||||
|
|
@ -91,6 +77,5 @@ With a Bot bound and the App opened from CCR, CCR starts a companion worker next
|
|||
## Common issues
|
||||
|
||||
- **Requests bypass CCR:** confirm the profile is **Enabled** and you opened OpenCode from CCR; a directly-opened OpenCode is unaffected unless the scope is **System default**.
|
||||
- **`opencode` not found:** set `CCR_OPENCODE_BIN` to the real executable path.
|
||||
- **Bot runs in the wrong directory:** set `CCR_OPENCODE_BOT_CWD` to the project directory open in the App.
|
||||
- **App did not switch profiles:** OpenCode Desktop is single-instance — switch profiles from CCR, which stops the previous managed instance first.
|
||||
- **`opencode` not found:** make sure OpenCode CLI is installed and available from the same shell environment used to start CCR Desktop.
|
||||
- **App did not switch profiles:** OpenCode Desktop is single-instance — switch profiles from CCR.
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
title: Pi setup and configuration
|
||||
pageTitle: Pi
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect Pi to CCR. Pi is CLI-only and always scoped to CCR-launched sessions. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect Pi to CCR. Pi is CLI-only and always scoped to CCR-launched sessions."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
Pi is a coding agent that selects its provider and model from the command line. In CCR it is **CLI only** and always uses **Only opened from CCR**: CCR generates a launch wrapper that runs Pi against the CCR gateway with your chosen provider and model.
|
||||
Pi is a coding agent that selects its provider and model from the command line. In CCR it is **CLI only** and always uses **Only opened from CCR**.
|
||||
|
||||
Use this page to route Pi to any CCR provider or Fusion model, or to run isolated Pi sessions.
|
||||
Use this page to route Pi to any CCR provider or Fusion model, or to run separate Pi sessions.
|
||||
|
||||
> New to CCR? Add a provider and model first. See [Add a provider](/en/guides/provider/) and the [Agent Config overview](/en/configuration/profiles/).
|
||||
|
||||
|
|
@ -19,21 +19,12 @@ Use this page to route Pi to any CCR provider or Fusion model, or to run isolate
|
|||
2. Pi is installed (available as `pi` on `PATH`).
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects Pi
|
||||
|
||||
When you save a Pi profile, CCR:
|
||||
|
||||
- Writes a managed gateway config under a profile-specific Pi agent directory (`PI_CODING_AGENT_DIR`, by default under `~/.pi/agent`).
|
||||
- Generates a launch **wrapper** that sets `PI_CODING_AGENT_DIR` and a separate session directory, disables the Pi version check, then runs `pi --provider <provider> --model <model>` against the CCR gateway.
|
||||
|
||||
If the CCR Desktop gateway is not running when you launch, `ccr-app` starts a shared temporary gateway service and stops it after the last concurrent Pi session exits.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **Pi**.
|
||||
2. Enter a **Config name** (for example `Pi - Work`).
|
||||
3. Select a **Model**.
|
||||
4. Add environment variables only if you need a non-default Pi binary or want to re-enable the version check.
|
||||
4. Leave advanced environment settings empty unless you have a specific local setup need.
|
||||
5. **Save**, then copy and run the command from the profile card.
|
||||
|
||||
## Configuration reference
|
||||
|
|
@ -42,41 +33,38 @@ Pi is fixed to **Only opened from CCR** and **CLI only**, so those two fields ar
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **Pi** | Tells CCR to generate the Pi agent directory, gateway config, and wrapper. |
|
||||
| Config name | Free text, e.g. `Pi - Work` | Identifies the profile and is used in `ccr-app "<name>"`. |
|
||||
| Agent | Choose **Pi** | Creates a Pi launch entry in CCR. |
|
||||
| Config name | Free text, e.g. `Pi - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>"`; CLI commands use `ccr "<name>"`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Model | A provider model or Fusion model | Passed to Pi as `--model`; the model Pi uses for every turn. |
|
||||
| Environment variables | Key/value rows | Exported into the wrapper. See below. |
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CCR_PI_BIN` / `PI_BIN` — absolute path to the real `pi` executable, if it is not on `PATH` in the CCR Desktop process.
|
||||
- `PI_SKIP_VERSION_CHECK` — defaults to `1` (version check skipped) so the wrapper starts cleanly. Set it to another value only if you intentionally want the check.
|
||||
- CCR also adds the gateway host to `NO_PROXY`/`no_proxy` automatically so Pi talks to the local gateway directly; you do not configure this.
|
||||
|
||||
> Variables CCR manages itself (`PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `CCR_PROFILE_SURFACE`) are reserved — setting them manually has no effect.
|
||||
| Model | A provider model or Fusion model | The model Pi uses for every turn. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
|
||||
## Open and use
|
||||
|
||||
Click the terminal button on the profile card and run the copied command:
|
||||
Desktop command copied from the profile card:
|
||||
|
||||
```text
|
||||
ccr-app "Pi - Work"
|
||||
```
|
||||
|
||||
For CLI, run:
|
||||
|
||||
```text
|
||||
ccr "Pi - Work"
|
||||
```
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id` and its own `PI_CODING_AGENT_DIR` and session directory, so multiple Pi profiles can run at once with different models or separate session histories.
|
||||
Create separate Pi profiles when you want separate launch entries with different models.
|
||||
|
||||
## Verify
|
||||
|
||||
1. Run the `ccr-app` command copied from the profile card.
|
||||
1. Run the desktop `ccr-app` command copied from the profile card, or the CLI `ccr` command.
|
||||
2. Send one message in Pi and confirm it replies.
|
||||
3. Open **Request logs** in CCR and confirm the request passed through the gateway.
|
||||
|
||||
## Common issues
|
||||
|
||||
- **Pi uses a different model than expected:** confirm the **Model** field; the wrapper always passes it as `--model`.
|
||||
- **`pi` not found:** set `CCR_PI_BIN` to the real executable path.
|
||||
- **Version-check prompt on startup:** this is expected to be skipped; if it appears, leave `PI_SKIP_VERSION_CHECK` at its default.
|
||||
- **Pi uses a different model than expected:** confirm the **Model** field.
|
||||
- **`pi` not found:** make sure Pi is installed and available from the same shell environment used to start CCR Desktop.
|
||||
- **`/model` or model list is empty:** Pi does not use a `/model` list — set the exact **Model** you want on the profile.
|
||||
|
|
|
|||
89
docs/src/content/docs/en/configuration/agents/workbuddy.md
Normal file
89
docs/src/content/docs/en/configuration/agents/workbuddy.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
title: WorkBuddy setup and configuration
|
||||
pageTitle: WorkBuddy
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect WorkBuddy to CCR. WorkBuddy is App-only in CCR."
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. CCR Desktop is running with at least one provider + model configured.
|
||||
2. WorkBuddy is installed on this machine. If CCR cannot detect it, set **APP_PATH** to the WorkBuddy executable.
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **WorkBuddy**.
|
||||
2. Enter a **Config name** (for example `WorkBuddy - Work`).
|
||||
3. Confirm **Provider ID**, **Provider name**, and **WorkBuddy model**.
|
||||
4. Optionally restrict **Allowed model list**. Leaving it empty makes every CCR model available in WorkBuddy.
|
||||
5. Set **APP_PATH** only if auto-detection does not find WorkBuddy.
|
||||
6. If you use AgentClaw, bind a **Bot**.
|
||||
7. **Save**, then open WorkBuddy from CCR with the play button.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
WorkBuddy is fixed to **App only**, so the entry mode is not editable. The fields you configure are:
|
||||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **WorkBuddy** | Creates a WorkBuddy App launch entry in CCR. |
|
||||
| Config name | Free text, e.g. `WorkBuddy - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>" app`; CLI commands use `ccr "<name>" app`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Keeps the profile limited to CCR launches, or makes it the system-default WorkBuddy profile. Only one enabled system-default WorkBuddy profile is allowed. |
|
||||
| Provider ID | Default `claude-code-router` | Provider reference for this WorkBuddy profile. |
|
||||
| Provider name | Free text; default `Claude Code Router` | Display name used by the generated profile. |
|
||||
| WorkBuddy model | A provider model or Fusion model | Default model when WorkBuddy App opens. |
|
||||
| Allowed model list | Optional multi-select | Controls which CCR models appear in WorkBuddy. Empty means all models. |
|
||||
| APP_PATH | Optional executable path | Overrides app auto-detection. Use it when WorkBuddy is installed in a custom location. |
|
||||
| Config file | Path | Used for the system-default WorkBuddy profile. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Select a saved Bot | Binds an AgentClaw IM bot to the WorkBuddy App entry. |
|
||||
|
||||
## Model list behavior
|
||||
|
||||
WorkBuddy's model settings follow the profile's **Allowed model list**:
|
||||
|
||||
- If **Allowed model list** has selections, only those models are written.
|
||||
- If **Allowed model list** is empty, every CCR model is available.
|
||||
- The selected **WorkBuddy model** is used as the default model.
|
||||
|
||||
After changing providers or model lists, reopen WorkBuddy from CCR. If the WorkBuddy settings window is already open, close and reopen that window to refresh the list.
|
||||
|
||||
## Open and use
|
||||
|
||||
Click the play button on the profile card to open WorkBuddy with this profile's model, provider, and model list. Reopening the same profile activates the existing window.
|
||||
|
||||
Desktop App command copied from the profile card:
|
||||
|
||||
```text
|
||||
ccr-app "WorkBuddy - Work" app
|
||||
```
|
||||
|
||||
For CLI, run:
|
||||
|
||||
```text
|
||||
ccr "WorkBuddy - Work" app
|
||||
```
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Create separate WorkBuddy profiles when you want different model lists or providers.
|
||||
|
||||
## AgentClaw (Bot)
|
||||
|
||||
With a Bot bound and WorkBuddy opened from CCR, WorkBuddy can relay activity through the selected IM channel. Closing WorkBuddy takes the relay offline. See [AgentClaw](/en/agentclaw/) for the full IM setup.
|
||||
|
||||
## Verify
|
||||
|
||||
1. Open WorkBuddy from CCR.
|
||||
2. Open WorkBuddy **Models** settings and confirm the expected models are listed.
|
||||
3. Send one message and confirm it replies.
|
||||
4. Open **Request logs** in CCR and confirm the request passed through the gateway.
|
||||
|
||||
## Common issues
|
||||
|
||||
- **Only one model appears:** apply the WorkBuddy profile again and reopen WorkBuddy from CCR. Empty **Allowed model list** means all CCR models should be available.
|
||||
- **Requests bypass CCR:** confirm the profile is **Enabled** and you opened WorkBuddy from CCR; reopen it from CCR if it was already running.
|
||||
- **Wrong model in the App:** confirm the **WorkBuddy model** field and the model list in WorkBuddy settings.
|
||||
- **WorkBuddy was not found:** set **APP_PATH** to the WorkBuddy executable.
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
title: ZCode setup and configuration
|
||||
pageTitle: ZCode
|
||||
eyebrow: Detailed configuration
|
||||
lead: "Connect ZCode to CCR. ZCode is App-only in CCR. This page covers every Agent Config field: how to set it and what effect it has."
|
||||
lead: "Connect ZCode to CCR. ZCode is App-only in CCR."
|
||||
---
|
||||
|
||||
## Who this is for
|
||||
|
||||
ZCode is a coding agent that runs as a desktop app. In CCR it is **App only**: CCR writes the ZCode CLI config, v2 config, and model cache, then starts the App with this profile's model, provider, and an isolated user-data directory.
|
||||
ZCode is a coding agent that runs as a desktop app. In CCR it is **App only**.
|
||||
|
||||
Use this page to route ZCode to any CCR provider or Fusion model, or to attach an IM bot.
|
||||
|
||||
|
|
@ -19,20 +19,12 @@ Use this page to route ZCode to any CCR provider or Fusion model, or to attach a
|
|||
2. The ZCode desktop app is installed and logged in on this machine.
|
||||
3. You are on **Agent Config** and click **Add profile**.
|
||||
|
||||
## How CCR connects ZCode
|
||||
|
||||
When you save a ZCode profile, CCR:
|
||||
|
||||
- Writes the ZCode **CLI config** (`~/.zcode/cli/config.json` by default), plus the ZCode v2 config and model cache, based on ZCode home or your custom config file.
|
||||
- Generates a **middleware launcher** that sets `ZCODE_HOME`/`ZCODE_STORAGE_DIR`, the provider, model, and model catalog, then hands off to the App.
|
||||
- Starts the App with the current profile's model, provider, and a separate user-data directory.
|
||||
|
||||
## Create the profile
|
||||
|
||||
1. On **Agent Config**, click **Add profile** and choose **ZCode**.
|
||||
2. Enter a **Config name** (for example `ZCode - Work`).
|
||||
3. Confirm **Provider ID**, **Provider name**, and **ZCode model**.
|
||||
4. Adjust the **Config file** and environment variables as needed.
|
||||
4. Adjust advanced settings only if your local setup needs them.
|
||||
5. If you use AgentClaw, bind a **Bot**.
|
||||
6. **Save**, then open ZCode from CCR with the play button.
|
||||
|
||||
|
|
@ -42,41 +34,40 @@ ZCode is fixed to **App only**, so the entry mode is not editable. The fields yo
|
|||
|
||||
| Field | How to set it | Effect |
|
||||
| --- | --- | --- |
|
||||
| Agent | Choose **ZCode** | Tells CCR to write the ZCode config and start the App. |
|
||||
| Config name | Free text, e.g. `ZCode - Work` | Identifies the profile and is used in `ccr-app "<name>" app`. |
|
||||
| Agent | Choose **ZCode** | Creates a ZCode App launch entry in CCR. |
|
||||
| Config name | Free text, e.g. `ZCode - Work` | Identifies the profile. Desktop commands use `ccr-app "<name>" app`; CLI commands use `ccr "<name>" app`. |
|
||||
| Enabled | Toggle on/off | Disabled profiles are not applied and not offered as launch entries. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Isolated CCR-managed config vs. the real ZCode home config. Only one enabled system-default ZCode profile is allowed. |
|
||||
| Provider ID | Default `claude-code-router` | The provider reference CCR writes into the ZCode config. |
|
||||
| Effect scope | `Only opened from CCR` / `System default` | Keeps the profile limited to CCR launches, or makes it the system-default ZCode profile. Only one enabled system-default ZCode profile is allowed. |
|
||||
| Provider ID | Default `claude-code-router` | Provider reference for this ZCode profile. |
|
||||
| Provider name | Free text; default `Claude Code Router` | Display name shown in ZCode. |
|
||||
| ZCode model | A provider model or Fusion model | Default model when ZCode App opens. |
|
||||
| Config file | Path, default `~/.zcode/cli/config.json` | CCR also writes the ZCode v2 config and model cache alongside it. |
|
||||
| Environment variables | Key/value rows | Injected into the ZCode App and the middleware launcher. |
|
||||
| Config file | Path | Used for the system-default ZCode profile. |
|
||||
| Environment variables | Key/value rows | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Select a saved Bot | Binds an AgentClaw IM bot to the ZCode App entry. |
|
||||
|
||||
> **Show all sessions** and **CCR managed compact** are not exposed for ZCode.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- Any rows are injected into the ZCode App and the middleware launcher.
|
||||
- Variables CCR manages itself (`ZCODE_HOME`, `ZCODE_STORAGE_DIR`, the `CCR_ZCODE_*` and `CODEXL_ZCODE_*` variables, `CCR_PROFILE_SURFACE`) are reserved — setting them manually has no effect.
|
||||
|
||||
## Open and use
|
||||
|
||||
Click the play button on the profile card to open ZCode with this profile's model, provider, and isolated user-data directory. Reopening the same profile activates the existing window.
|
||||
Click the play button on the profile card to open ZCode with this profile's model and provider. Reopening the same profile activates the existing window.
|
||||
|
||||
You can also copy the App command from the card:
|
||||
Desktop App command copied from the profile card:
|
||||
|
||||
```text
|
||||
ccr-app "ZCode - Work" app
|
||||
```
|
||||
|
||||
For CLI, run:
|
||||
|
||||
```text
|
||||
ccr "ZCode - Work" app
|
||||
```
|
||||
|
||||
## Multi-instance
|
||||
|
||||
Each profile has its own `id` and a separate user-data directory, so multiple ZCode profiles can run at once with different models or providers.
|
||||
Create separate ZCode profiles when you want different models or providers.
|
||||
|
||||
## AgentClaw (Bot)
|
||||
|
||||
With a Bot bound and ZCode opened from CCR, the Codex-compatible companion worker exposes native Session discovery, Project/Session browsing and continuation, queueing, cancellation, model settings, usage, attachments, and diagnostics. Closing ZCode App immediately takes the relay offline. See [AgentClaw](/en/agentclaw/).
|
||||
With a Bot bound and ZCode opened from CCR, ZCode can relay conversations through the selected IM channel. Closing ZCode App immediately takes the relay offline. See [AgentClaw](/en/agentclaw/).
|
||||
|
||||
## Verify
|
||||
|
||||
|
|
|
|||
|
|
@ -33,5 +33,5 @@ Request body / response body logging is governed by three low-level config optio
|
|||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `requestLogBodyCapture` | `all` | Whether to record request and response bodies: `all` records all, `errors` records bodies only for failed requests, `none` records no bodies. |
|
||||
| `requestLogMaxBodyBytes` | `52428800` (50 MiB) | Maximum bytes for a single request or response body; anything larger is truncated. The hard cap is also 50 MiB. |
|
||||
| `requestLogMaxBodyBytes` | `52428800` (50 MiB) | In-memory body capture and preview budget for a single request or response body. File-backed raw trace bodies can exceed this limit; the full body is stored in sidecar storage and loaded on demand from the Logs page. |
|
||||
| `requestLogSuccessSampleRate` | `1` | Sampling rate for successful requests, between `0` and `1`. `1` records all, `0.1` records roughly one in ten. |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: Agent Config
|
||||
pageTitle: Agent Config
|
||||
eyebrow: Detailed configuration
|
||||
lead: Create reusable launch configs for Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, and ZCode, and open independent agent instances from different configs. Use this page to run multiple agents side by side or pin different models to different purposes.
|
||||
lead: Create reusable launch configs for Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, ZCode, and WorkBuddy, and open independent agent instances from different configs. Use this page to run multiple agents side by side or pin different models to different purposes.
|
||||
---
|
||||
|
||||
> For a detailed, field-by-field setup guide to a single agent, open its page under **Agents** in the sidebar — for example, [Claude Code](/en/configuration/agents/claude-code/), [Codex](/en/configuration/agents/codex/), or [Grok CLI](/en/configuration/agents/grok/).
|
||||
|
|
@ -16,34 +16,25 @@ lead: Create reusable launch configs for Claude Code, Codex, OpenCode, Grok CLI,
|
|||
5. If the entry mode includes App, optionally bind the Bot used by AgentClaw and choose whether to forward agent messages or enable handoff.
|
||||
6. Save the config, then open it from the Agent Config card: the terminal button copies the CLI command, and the play button starts the App instance.
|
||||
|
||||
During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, Grok CLI, Kimi CLI, or ZCode setup you open directly from the system.
|
||||
During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, Grok CLI, Kimi CLI, ZCode, or WorkBuddy setup you open directly from the system.
|
||||
|
||||
## Multi-instance mechanism
|
||||
## Multi-instance
|
||||
|
||||
Every Agent Config has its own `id` and name. When CCR opens an agent, it finds the enabled config by name or `id`, then builds the launch plan for that config.
|
||||
|
||||
| Mechanism | Actual behavior |
|
||||
| --- | --- |
|
||||
| Separate config files | With **Only opened from CCR**, Claude Code and Codex write CCR-managed config files in directories separated by config `id` |
|
||||
| Separate launchers | Claude Code, Grok CLI, and Kimi CLI use separate launch wrappers; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name |
|
||||
| Separate app data directories | When opening App mode, Claude App, ChatGPT (the renamed Codex desktop app), and ZCode App use user-data directories separated by config `id` |
|
||||
| Runtime state | CCR tracks running app instances by entry mode and config `id`; reopening the same config activates the existing window, while a different config can open a separate instance |
|
||||
|
||||
This lets you create multiple configs for the same agent, such as "Claude Code - Work Project", "Claude Code - Test Model", or "Codex - Fusion Vision". They can use different models, scopes, and Bots, then open as separate agent instances.
|
||||
Each Agent Config is a separate launch profile. Create multiple profiles when you want different models, scopes, Bots, or App windows for the same agent. Reopening an already-running profile activates its existing window when that agent supports only one active App window.
|
||||
|
||||
## Common options
|
||||
|
||||
| Option | Applies to | Description |
|
||||
| --- | --- | --- |
|
||||
| Agent | All | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, or ZCode. Grok CLI and Kimi CLI support CLI only; ZCode supports App only. |
|
||||
| Config name | All | Identifies the config in CCR and can be used as the `ccr-app <config-name>` launch target. Names can contain spaces; copied commands are quoted automatically. |
|
||||
| Agent | All | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, ZCode, or WorkBuddy. Grok CLI and Kimi CLI support CLI only; ZCode and WorkBuddy support App only. |
|
||||
| Config name | All | Identifies the config in CCR. Desktop commands use `ccr-app <config-name>`; CLI commands use `ccr <config-name>`. Names can contain spaces; copied commands are quoted automatically. |
|
||||
| Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. |
|
||||
| Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. |
|
||||
| Entry mode | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI and Kimi CLI are fixed to `CLI only`. |
|
||||
| Effect scope | All | **Only opened from CCR** limits changes to CCR launches; **System default** also affects the agent when opened directly. Only one enabled system-default config is allowed per agent. |
|
||||
| Entry mode | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI and Kimi CLI are fixed to `CLI only`; ZCode and WorkBuddy are fixed to `App only`. |
|
||||
| Model | All | Default model for the opened agent, either a provider model or Fusion model. For Claude Code, leaving it empty keeps the Claude Code default. |
|
||||
| Available models | Kimi CLI | Models exposed by Kimi's `/model` command. The default model is always included. |
|
||||
| Bot | App entry | The IM entry used by AgentClaw. Bot forwarding only works for App mode opened from CCR; CLI does not forward Bot messages yet. See [AgentClaw](/en/agentclaw/) for the full setup. |
|
||||
| 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. |
|
||||
| Environment variables | All | Optional advanced overrides; leave empty for normal use. |
|
||||
|
||||
## Per-agent options
|
||||
|
||||
|
|
@ -51,82 +42,91 @@ This lets you create multiple configs for the same agent, such as "Claude Code -
|
|||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
| Model override | Default model for this profile. Leave it empty to keep Claude Code's own default model. |
|
||||
| Small fast model | Optional model for Claude Code lightweight tasks. Leave it empty to keep the Claude Code default. |
|
||||
| Settings file | Used for the system-default Claude Code profile. |
|
||||
| Environment variables | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Applies only to the Claude App entry. Select a saved Bot, then choose AgentClaw 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.
|
||||
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:
|
||||
Claude App and Claude Code CLI expose models differently:
|
||||
|
||||
| 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. |
|
||||
| Claude App | CCR model list | Open Claude App from CCR to use the model list for this profile. |
|
||||
|
||||
### OpenCode
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| Provider ID | Writes the OpenCode provider reference, defaulting to `claude-code-router`. |
|
||||
| Provider ID | Provider reference for this OpenCode profile, defaulting to `claude-code-router`. |
|
||||
| Provider name | Display name shown in OpenCode, defaulting to `Claude Code Router`. |
|
||||
| OpenCode model | Default model for OpenCode CLI and App. It can be a provider model or Fusion model. |
|
||||
| Config file | System-default mode uses OpenCode's default config; Only opened from CCR writes a profile-specific config under CCR's config directory. |
|
||||
| Environment variables | Injected into OpenCode CLI, OpenCode App, and its Bot worker. |
|
||||
| Bot | Applies to the OpenCode App entry opened from CCR. Incoming Bot messages run through OpenCode CLI and replies are sent back to the same Bot conversation. |
|
||||
| Config file | Used for the system-default OpenCode profile. |
|
||||
| Environment variables | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Applies to the OpenCode App entry opened from CCR. |
|
||||
|
||||
CCR keeps one OpenCode Bot worker next to the OpenCode App process. The worker stores a project and optional session for each Bot conversation. Send `/project list|current|use` to select an Agent project, then use `/session list|current|new|use|reset` to manage sessions inside that project. Selecting another project clears the previous session, and sessions from another project cannot be selected. Only these slash-command domains are intercepted; removed `/task` and legacy flat commands are not supported.
|
||||
|
||||
The OpenCode CLI must be available as `opencode` in the CCR Desktop process environment. If it is installed elsewhere, set `CCR_OPENCODE_BIN` in the Agent Config environment variables. Bot sessions default to the filesystem root used by a fresh OpenCode Desktop workspace; set `CCR_OPENCODE_BOT_CWD` to the same project directory currently opened in OpenCode App when using another workspace. CCR passes that directory explicitly through `opencode run --dir`, so the resulting session appears under the matching App project. Permissions are not auto-approved by default; `CCR_OPENCODE_BOT_AUTO_APPROVE=true` enables OpenCode's dangerous `--auto` mode and should be used only in a trusted environment.
|
||||
With a Bot bound, OpenCode can relay conversations through the selected IM channel.
|
||||
|
||||
### 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 ID | Provider reference for this Codex profile. Keep it stable and use only letters, numbers, dots, underscores, or hyphens. |
|
||||
| Provider name | Display name shown in Codex, defaulting to `Claude Code Router`. |
|
||||
| Codex model | Default Codex model. It can be a provider model or Fusion model; if left empty, CCR uses the first available default model. |
|
||||
| Show all sessions | Lets Codex show all sessions. ZCode does not expose this option. |
|
||||
| Config file | Defaults to `~/.codex/config.toml`. Only opened from CCR writes into CCR-managed isolated config directories. |
|
||||
| Environment variables | Injected into Codex CLI or ChatGPT. Claude Code-specific model discovery variables are not passed to Codex. |
|
||||
| Show all sessions | Lets Codex show all sessions. |
|
||||
| Config file | Used for the system-default Codex profile. |
|
||||
| Environment variables | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Applies only to the ChatGPT app entry. |
|
||||
|
||||
After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr-app "Codex - Work"`. Use the play button to open ChatGPT. Following the CodexL launch model, CCR starts the Electron executable inside the ChatGPT app bundle directly, gives it an isolated user-data directory, and points `CODEX_CLI_PATH` at the CCR middleware. The middleware forwards app-server traffic to ChatGPT's bundled Codex CLI and only adapts the account display. Each profile reads ChatGPT login state only from its own Codex home. A profile without credentials reports a local non-OpenAI compatibility identity so current ChatGPT builds do not enter a repeated authentication and attestation loop. Shared login is opt-in: set `CCR_CODEX_CHATGPT_AUTH_FILE` (or the legacy `CODEXL_CODEX_CHATGPT_AUTH_FILE`) to a valid `auth.json` when a profile should bridge that token in memory without copying it. To make the native app-server select its official API marketplace, CCR creates the exact `ccr-local-profile` bootstrap only during process startup and removes it after the first native response; it is also cleaned after startup or abnormal exit and is never retained as login state. Every other authentication file is preserved. Older `Codex.app` installations remain supported.
|
||||
|
||||
Model and public plugin listings are not synthesized by the middleware. The native Codex app-server reads the generated `model_catalog_json` and handles `model/list` plus public `plugin/list` requests unchanged. This lets Codex refresh the official public [`openai/plugins`](https://github.com/openai/plugins) Git marketplace over the network. In a virtual workspace, only account-private marketplace requests are answered with an explicit empty result because the native service requires real ChatGPT authentication for those sections; they are never replaced with local plugins. Any downloaded Git checkout is owned only by Codex as its normal last-known-good data, not used by CCR as a replacement catalog.
|
||||
After saving, use the terminal button on the desktop config card to copy the Codex CLI command, for example `ccr-app "Codex - Work"`. For CLI, run: `ccr "Codex - Work"`. Use the play button to open ChatGPT.
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI profiles are fixed to **Only opened from CCR** and **CLI only**. After saving, copy and run the card command, for example `ccr-app "Grok - Work"`.
|
||||
|
||||
The generated wrapper sets Grok's model base URL and model-list URL to CCR's `/v1` gateway, supplies the profile-specific CCR API key, and sets the selected CCR model as the default. If the CCR Desktop gateway is not running, `ccr-app` starts a shared temporary service for Grok sessions and cleans it up after the last session exits. Grok CLI does not expose a separate user-config-file option, so CCR points `GROK_HOME` at a profile-specific directory. Its `config.toml` starts as a private copy of the user's config and can change independently, while `auth.json` is excluded to prevent a local xAI OAuth token from overriding the CCR key. Plugins, skills, and sessions remain shared with the original Grok home. Inside Grok CLI, use `/model` to switch among the provider and Fusion models returned by CCR; switched requests continue through CCR.
|
||||
Grok CLI profiles are fixed to **Only opened from CCR** and **CLI only**. After saving, run the profile command: desktop `ccr-app "Grok - Work"`, or CLI `ccr "Grok - Work"`. Inside Grok CLI, use `/model` to switch among the provider and Fusion models returned by CCR.
|
||||
|
||||
### Kimi CLI
|
||||
|
||||
Kimi CLI profiles are fixed to **Only opened from CCR** and **CLI only**. Select one default model and one or more available models. The generated wrapper points `KIMI_CODE_HOME` at a profile-specific directory whose `config.toml` defines a private OpenAI-compatible CCR provider and a model entry for every selection. Kimi's `/model` command can therefore switch models without bypassing CCR. CCR preserves non-provider settings from the source config and reuses available sessions, skills, plugins, MCP configuration, and credentials without rewriting the original `~/.kimi-code/config.toml`. If CCR Desktop is not running, the launcher starts a shared temporary gateway and stops it after the last managed Kimi session exits.
|
||||
Kimi CLI profiles are fixed to **Only opened from CCR** and **CLI only**. Select one default model and one or more available models. Kimi's `/model` command can switch among the models you selected.
|
||||
|
||||
### ZCode
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| Provider ID | Writes the ZCode provider reference, defaulting to `claude-code-router`. |
|
||||
| Provider ID | Provider reference for this ZCode profile, 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. |
|
||||
| Config file | Used for the system-default ZCode profile. |
|
||||
| Environment variables | Optional advanced overrides; leave empty for normal use. |
|
||||
| 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.
|
||||
|
||||
### WorkBuddy
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| Provider ID | Provider reference for this WorkBuddy profile, defaulting to `claude-code-router`. |
|
||||
| Provider name | Display name used by the generated profile, defaulting to `Claude Code Router`. |
|
||||
| WorkBuddy model | Default model when WorkBuddy App opens. It can be a provider model or Fusion model. |
|
||||
| Allowed model list | Models visible in WorkBuddy; empty means every CCR model. |
|
||||
| APP_PATH | Optional WorkBuddy executable path when auto-detection does not find the App. |
|
||||
| Config file | Used for the system-default WorkBuddy profile. |
|
||||
| Environment variables | Optional advanced overrides; leave empty for normal use. |
|
||||
| Bot | Applies only to the WorkBuddy App entry. |
|
||||
|
||||
WorkBuddy supports App only, so its entry mode is fixed to `App only`.
|
||||
|
||||
## CLI and App modes
|
||||
|
||||
| Mode | How to open | Best for | Key differences |
|
||||
| --- | --- | --- | --- |
|
||||
| CLI | Click the terminal button to copy the command, then run `ccr-app <config-name>` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. |
|
||||
| CLI | Desktop: click the terminal button and run `ccr-app <config-name>`; CLI: run `ccr <config-name>` | Working inside a project directory, shell workflows, scripting | Opens the selected profile in the terminal; Bot forwarding support is pending. |
|
||||
| App | Click the play button in the CCR desktop app | Desktop windows, Bot forwarding, handoff | Reopening the same config activates the existing window. Multi-instance behavior depends on the Agent; OpenCode Desktop is single-instance, so CCR stops the managed instance before switching OpenCode profiles. |
|
||||
| CLI & APP | One config exposes both CLI and App entry points | Reusing the same model config in both terminal and desktop App workflows | Both entries share the config name, model, effect scope, and environment variables, but launch differently. |
|
||||
|
||||
|
|
@ -134,39 +134,41 @@ ZCode supports App only, so its entry mode is fixed to `App only`. The `Show all
|
|||
|
||||
### Claude Code
|
||||
|
||||
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.
|
||||
Claude Code supports CLI and App. Create separate profiles when you want different models, scopes, or Bots.
|
||||
|
||||
When opening Claude App from the desktop app, CCR also prepares a separate user-data directory for that config. Different Agent Config entries use different directories, so multiple Claude App instances can run at the same time.
|
||||
|
||||
With a Bot bound, Claude App's companion worker exposes Projects/Sessions, streaming replies, attachments, Session usage, and native permission/Ask User requests to IM. The worker stops with the App.
|
||||
With a Bot bound, Claude App can relay conversations through the selected IM channel.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex config writes `config.toml` and a model catalog file. With **Only opened from CCR**, CCR stores those files in a directory separated by config `id`.
|
||||
Codex supports CLI and App. Use the terminal button for Codex CLI and the play button for ChatGPT.
|
||||
|
||||
Codex supports CLI and App. CLI opens through the launcher for the selected config; App launches ChatGPT, uses a separate user-data directory, and passes the selected model and provider into the app.
|
||||
|
||||
With a Bot bound, the Codex App companion worker uses native Codex rollout Sessions for Project/Session browsing and continuation, queueing, cancellation, model settings, usage, attachments, and diagnostics. It exists only alongside the managed App.
|
||||
With a Bot bound, ChatGPT can relay conversations through the selected IM channel.
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode config writes a JSON/JSONC config that routes the selected provider and model through CCR. CLI opens through a profile-specific wrapper; App launches the installed OpenCode Desktop executable with the same effective config.
|
||||
OpenCode supports CLI and App. OpenCode Desktop is single-instance, so switch profiles from CCR rather than launching a second App.
|
||||
|
||||
When a Bot is selected and the App is opened from CCR, CCR starts a companion worker using OpenCode-native Sessions and the same Project/Session, queue, media, settings, and diagnostics contract as the other Apps. The worker stops when the managed OpenCode App exits or the profile is switched.
|
||||
With a Bot bound, OpenCode can relay conversations through the selected IM channel.
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI supports CLI only. CCR opens it through a profile-specific wrapper that injects the CCR model gateway, model discovery endpoint, API key, and default model. A profile-specific Grok home excludes xAI OAuth credentials so inference reliably uses the CCR key without rewriting the user's original Grok home.
|
||||
Grok CLI supports CLI only. Use `/model` inside Grok CLI to switch among CCR models.
|
||||
|
||||
### Kimi CLI
|
||||
|
||||
Kimi CLI supports CLI only. CCR opens it through a profile-specific wrapper and generated Kimi home containing the selected default model plus every available model. All generated model entries use the CCR gateway and profile API key, so `/model` switches remain routed through CCR; the user's original Kimi configuration remains untouched.
|
||||
Kimi CLI supports CLI only. Select one default model and any additional models you want in `/model`.
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode supports App only. CCR writes ZCode CLI config, v2 config, and model cache based on ZCode home or a custom config file, then starts the App with the current Agent Config's model, provider, and separate user-data directory.
|
||||
ZCode supports App only. Open it with the play button.
|
||||
|
||||
With a Bot bound, ZCode uses the Codex-compatible companion worker and native Session discovery. Closing ZCode App immediately takes the relay offline.
|
||||
With a Bot bound, ZCode can relay conversations through the selected IM channel. Closing ZCode App immediately takes the relay offline.
|
||||
|
||||
### WorkBuddy
|
||||
|
||||
WorkBuddy supports App only. Open it with the play button.
|
||||
|
||||
With a Bot bound, WorkBuddy can relay conversations through the selected IM channel. Closing WorkBuddy takes the relay offline.
|
||||
|
||||
## Multi-instance suggestions
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ Scripts run in rule-list order. A non-match continues to the next rule; a match
|
|||
3. Select the script file, choose a timeout from 10 to 30000 milliseconds, and use **Validate** or **Test script** to check it.
|
||||
4. Save the rule. CCR reads the file before every execution, so later file edits do not require saving the rule again.
|
||||
|
||||
The Desktop file picker stores an absolute path. A Web UI cannot obtain the real local path selected by the browser, so enter an absolute, relative, or `~/...` path on the machine running CCR. Relative paths resolve from the CCR process working directory. A script file may be at most 64 KiB.
|
||||
The Desktop file picker stores an absolute path. A Web UI cannot obtain the real local path selected by the browser, so enter an absolute, relative, or `~/...` path on the machine running CCR. Relative paths resolve from the CCR process working directory. A script file may be at most 5 MiB.
|
||||
|
||||
The script file is an **async function body**. Use the injected `input`, `api`, and `return` directly; do not write it as a CommonJS or ES module:
|
||||
|
||||
|
|
@ -397,7 +397,7 @@ The saved rule has the following shape. It is normally generated by the UI and d
|
|||
|
||||
| Limit | Current value |
|
||||
| --- | --- |
|
||||
| Script file | 64 KiB maximum |
|
||||
| Script file | 5 MiB maximum |
|
||||
| Execution timeout | 10-30000 ms; 2000 ms by default |
|
||||
| Fetch request / response body | 256 KiB / 1 MiB |
|
||||
| Individual file read or write | 1 MiB |
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ Choose Grok CLI and select a default model, then run the copied `ccr-app <profil
|
|||
|
||||
### ZCode
|
||||
|
||||
Choose ZCode and set the **ZCode model**, **Provider ID**, and **Provider Name**. ZCode is a desktop-app agent (entry fixed to **App only**): CCR writes its CLI config and model cache, then launches the app with this profile's model, provider, and an isolated user-data directory; open it from the play button on the profile card.
|
||||
Choose ZCode and set the **ZCode model**, **Provider ID**, and **Provider Name**. ZCode is a desktop-app agent with entry fixed to **App only**; open it from the play button on the profile card.
|
||||
|
||||
## Logs and observability
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ lead: "The interactive panel at the top of this page connects to your running CC
|
|||
|
||||
- In **Agent Config**, click **Add Profile**, choose your agent, and enter a **Profile name**.
|
||||
- During trial, prefer **Only opened from CCR** (the default) so only agents launched from CCR are affected; switch to **System default** once it is stable.
|
||||
- Claude Code and Codex let you choose an **Entry mode** (CLI & APP / CLI only / App only); Grok CLI and Kimi CLI are CLI-only, and ZCode is App-only.
|
||||
- Claude Code and Codex let you choose an **Entry mode** (CLI & APP / CLI only / App only); Grok CLI and Kimi CLI are CLI-only, and ZCode and WorkBuddy are App-only.
|
||||
- After saving, launch the agent from the buttons on its profile card (the terminal button opens the CLI, the play button opens the app), then verify with one request in **Request logs**.
|
||||
- Command names differ by distribution: the desktop app copies `ccr-app ...`; CLI uses `ccr ...` with the same profile name and optional `cli` / `app` suffix.
|
||||
|
||||
## Claude Code
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ Supports both CLI and App.
|
|||
3. **Save**, then open the CLI with the terminal button or the Claude App with the play button.
|
||||
4. Send a message to confirm it replies, and check **Request logs** to verify the request went through the gateway; use `/model` in the CLI to list and switch CCR-exposed models.
|
||||
|
||||
For tier models, the settings file, and environment variables, see [Claude Code setup and configuration](../../configuration/agents/claude-code/).
|
||||
For tier models and advanced settings, see [Claude Code setup and configuration](../../configuration/agents/claude-code/).
|
||||
|
||||
## Codex
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ Supports both Codex CLI and the ChatGPT desktop app.
|
|||
3. **Save**, then open the CLI with the terminal button or ChatGPT with the play button.
|
||||
4. Send a message to confirm it replies, and check **Request logs** to verify the request went through the gateway.
|
||||
|
||||
For `config.toml` fields, the CLI path, and ChatGPT login sharing, see [Codex setup and configuration](../../configuration/agents/codex/).
|
||||
For Codex-specific fields, see [Codex setup and configuration](../../configuration/agents/codex/).
|
||||
|
||||
## Grok CLI
|
||||
|
||||
|
|
@ -40,21 +41,21 @@ CLI-only, always scoped to **Only opened from CCR** — your global Grok config
|
|||
|
||||
1. **Add Profile** → choose **Grok CLI**, enter a **Profile name**.
|
||||
2. Choose a **Model**.
|
||||
3. **Save**, then copy and run the card's `ccr-app "<profile-name>"` command.
|
||||
3. **Save**, then run the profile command: desktop card `ccr-app "<profile-name>"`, or CLI `ccr "<profile-name>"`.
|
||||
4. Send a message in Grok to confirm it replies, and check **Request logs** to verify the request went through the gateway; use `/model` to switch CCR-exposed models.
|
||||
|
||||
When the CCR Desktop gateway is not running, the command starts a shared temporary gateway that stops after the last session exits. For all fields, see [Grok CLI setup and configuration](../../configuration/agents/grok/).
|
||||
For all fields, see [Grok CLI setup and configuration](../../configuration/agents/grok/).
|
||||
|
||||
## Kimi CLI
|
||||
|
||||
CLI-only, always scoped to **Only opened from CCR** — your existing `~/.kimi-code/config.toml` is never overwritten.
|
||||
CLI-only, always scoped to **Only opened from CCR**.
|
||||
|
||||
1. **Add Profile** → choose **Kimi CLI**, enter a **Profile name**.
|
||||
2. Choose a **Kimi model** (the default) and one or more **Available models**.
|
||||
3. **Save**, then copy and run the card's `ccr-app "<profile-name>"` command.
|
||||
3. **Save**, then run the profile command: desktop card `ccr-app "<profile-name>"`, or CLI `ccr "<profile-name>"`.
|
||||
4. Send a message in Kimi to confirm it replies, and check **Request logs** to verify the request went through the gateway; use `/model` to switch between the default and available models.
|
||||
|
||||
CCR launches Kimi with a profile-specific `KIMI_CODE_HOME` and reuses sessions, skills, plugins, and credentials from the source home. For all fields, see [Kimi CLI setup and configuration](../../configuration/agents/kimi/).
|
||||
For all fields, see [Kimi CLI setup and configuration](../../configuration/agents/kimi/).
|
||||
|
||||
## ZCode
|
||||
|
||||
|
|
@ -67,6 +68,18 @@ App-only (entry fixed to **App only**).
|
|||
|
||||
For a field-by-field reference and advanced topics like multiple instances and bot binding, see [ZCode setup and configuration](../../configuration/agents/zcode/).
|
||||
|
||||
## WorkBuddy
|
||||
|
||||
App-only (entry fixed to **App only**).
|
||||
|
||||
1. **Add Profile** → choose **WorkBuddy**, enter a **Profile name**.
|
||||
2. Confirm the **WorkBuddy model**, **Provider ID**, and **Provider Name**.
|
||||
3. Optionally restrict **Allowed model list**. Leave it empty to make every CCR model available in WorkBuddy.
|
||||
4. **Save**, then open WorkBuddy with the play button.
|
||||
5. Send a message to confirm it replies, and check **Request logs** to verify the request went through the gateway.
|
||||
|
||||
For APP_PATH, multiple profiles, and bot binding, see [WorkBuddy setup and configuration](../../configuration/agents/workbuddy/).
|
||||
|
||||
## OpenCode
|
||||
|
||||
Supports both OpenCode CLI and the desktop app.
|
||||
|
|
@ -76,4 +89,4 @@ Supports both OpenCode CLI and the desktop app.
|
|||
3. **Save**, then open the CLI with the terminal button, or open the OpenCode desktop app via CCR Desktop with the play button.
|
||||
4. Send a message to confirm it replies, and check **Request logs** to verify the request went through the gateway.
|
||||
|
||||
For `opencode.jsonc` fields, the CLI path, and config writing, see [OpenCode setup and configuration](../../configuration/agents/opencode/).
|
||||
For OpenCode-specific fields, see [OpenCode setup and configuration](../../configuration/agents/opencode/).
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ lead: AgentClaw 把你在本机通过 CCR 运行的 Agent 接入 IM:Agent 继
|
|||
|
||||
AgentClaw 是 CCR 的 Agent 接力能力。由 CCR 管理的本机 Agent 保留原有的工作区、登录态、配置文件、模型路由和工具权限;AgentClaw 通过 IM Bot 为它暴露一个远程入口。你可以在 Slack、Discord、Telegram、LINE、微信、企业微信、飞书或钉钉里查看 Agent 输出、继续对话、处理权限请求,或在电脑锁屏后接管当前任务。
|
||||
|
||||
接力(handoff)指电脑锁屏并超过空闲阈值后,Agent 的后续交互自动转到 IM,你可以在手机或另一台设备上继续。接力依赖跟随 App 启停的 companion worker:从 CCR 打开 Agent App 时 worker 随之启动,App 退出时 worker 和 Bot 连接一起停止。CLI-only Agent 可以继续通过 CCR 路由模型请求,但不会把消息转发到 IM。
|
||||
接力(handoff)指电脑锁屏并超过空闲阈值后,Agent 的后续交互自动转到 IM,你可以在手机或另一台设备上继续。使用 IM 接力时请保持 Agent App 打开;App 退出后 Bot 连接会停止。CLI-only Agent 可以继续通过 CCR 路由模型请求,但不会把消息转发到 IM。
|
||||
|
||||
## 适合场景
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ AgentClaw 是 CCR 的 Agent 接力能力。由 CCR 管理的本机 Agent 保留
|
|||
| Session | Project 下的 Agent 原生会话 |
|
||||
| Companion worker | 跟随受管 Agent App 启停的接力进程,负责 IM 消息、Project/Session、队列、附件和诊断 |
|
||||
|
||||
AgentClaw 的生命周期跟从 CCR 打开的 Agent App 一致。打开 Claude、Codex、ZCode 或 OpenCode App 时,CCR 启动对应的 companion worker;App 退出时 worker 和 Bot 连接同步停止。CLI-only Agent 当前可以通过 CCR 路由模型请求,但不转发 Bot 消息。
|
||||
AgentClaw 跟随从 CCR 打开的 Agent App。App 退出时 Bot 连接会停止。CLI-only Agent 当前可以通过 CCR 路由模型请求,但不转发 Bot 消息。
|
||||
|
||||
## 支持范围
|
||||
|
||||
|
|
@ -39,9 +39,10 @@ AgentClaw 的生命周期跟从 CCR 打开的 Agent App 一致。打开 Claude
|
|||
| Codex / ChatGPT App | 完整支持 Bot 转发、接力、Project/Session、队列、模型设置、用量和附件能力 |
|
||||
| OpenCode App | 完整支持 Bot 转发、接力和 Project/Session;消息由 OpenCode CLI 在同一配置下执行 |
|
||||
| ZCode App | 完整支持 Bot 转发、接力和原生 Session 扫描 |
|
||||
| WorkBuddy App | 完整支持 Bot 转发、接力和 WorkBuddy 伴生中继 |
|
||||
| Claude Code、Codex CLI | 可以通过 CCR 配置模型路由;当前不转发 Bot 消息 |
|
||||
| Grok CLI、Kimi CLI | 可以通过 CCR 配置模型路由;当前是 CLI-only,不进入 AgentClaw 接力 |
|
||||
| 其他本机 Agent | 需要先能被 CCR 以 App 入口管理,并实现对应 companion worker,才能作为 AgentClaw 执行体 |
|
||||
| 其他本机 Agent | 需要先能被 CCR 以 App 入口管理,才能作为 AgentClaw 执行体 |
|
||||
|
||||
## 三种模式
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ lead: 从创建 IM Bot 到绑定 Agent 配置、选择转发或接力模式的
|
|||
|
||||
1. CCR 桌面 App 已运行,网关服务可用。
|
||||
2. 已在 **供应商配置** 中添加可用模型,或准备好要使用的 Fusion 模型。
|
||||
3. 已创建一个 **Agent 配置**,且 **入口模式** 包含 App。完整 AgentClaw 接力目前支持 Claude App、Codex/ChatGPT App、OpenCode App 和 ZCode App;CLI-only Agent 不转发 Bot 消息。
|
||||
3. 已创建一个 **Agent 配置**,且 **入口模式** 包含 App。完整 AgentClaw 接力目前支持 Claude App、Codex/ChatGPT App、OpenCode App、ZCode App 和 WorkBuddy App;CLI-only Agent 不转发 Bot 消息。
|
||||
4. 目标 IM 平台已准备好 Bot 凭据、应用权限或二维码登录条件。
|
||||
|
||||
## 配置流程
|
||||
|
|
@ -22,7 +22,7 @@ lead: 从创建 IM Bot 到绑定 Agent 配置、选择转发或接力模式的
|
|||
6. 打开 **Bot** 开关,选择刚保存的 Bot。
|
||||
7. 按需配置转发、接力、语言、超时、附件、流式回复和 **允许 Agent 使用 Shell 工具**。
|
||||
8. 保存 Agent 配置。
|
||||
9. 从 CCR 重新打开 Claude App、Codex/ChatGPT App、OpenCode App 或 ZCode App。AgentClaw 只在受管 App 存活期间在线。
|
||||
9. 从 CCR 重新打开 Claude App、Codex/ChatGPT App、OpenCode App、ZCode App 或 WorkBuddy App。AgentClaw 只在受管 App 存活期间在线。
|
||||
|
||||
## 模式选择
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
title: Claude Code 接入与配置
|
||||
pageTitle: Claude Code
|
||||
eyebrow: 详细配置
|
||||
lead: "把 Claude Code(CLI 与 App)接入 CCR,让每一次请求都经过你配置的供应商、路由与 Fusion 模型。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 Claude Code(CLI 与 App)接入 CCR,让每一次请求都经过你配置的供应商、路由与 Fusion 模型。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
Claude Code 是 Anthropic 的编码 Agent。CCR 同时支持它的两种形态:
|
||||
|
||||
- **Claude Code CLI**:终端 Agent。CCR 改写它的设置文件,使其指向 CCR 网关,并通过启动包装器打开。
|
||||
- **Claude App**:桌面应用。CCR 以零配置方式打开它,自动写入网关、API Key、模型发现列表,以及隔离的用户数据目录。
|
||||
- **Claude Code CLI**:终端 Agent。
|
||||
- **Claude App**:桌面应用。
|
||||
|
||||
当你想让 Claude Code 走非 Anthropic 供应商、固定某个模型、运行多个相互隔离的 Claude Code 实例,或挂载 IM 机器人时,使用本页。
|
||||
当你想让 Claude Code 走非 Anthropic 供应商、固定某个模型、运行多个独立的 Claude Code 实例,或挂载 IM 机器人时,使用本页。
|
||||
|
||||
> 第一次使用 CCR?请先接入供应商和模型,再回到本页。参见[接入供应商](/guides/provider/)与 [Agent 配置总览](/configuration/profiles/)。
|
||||
|
||||
|
|
@ -22,27 +22,13 @@ Claude Code 是 Anthropic 的编码 Agent。CCR 同时支持它的两种形态
|
|||
2. 本机已安装并登录 Claude Code,或准备从 CCR 打开。
|
||||
3. 进入 **Agent 配置** 页面,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 Claude Code
|
||||
|
||||
保存(应用)一个 Claude Code 配置时,CCR 会在 Claude Code 的 **设置文件** 中写入受管内容:
|
||||
|
||||
- 把 `apiKeyHelper` 指向 CCR 生成的辅助脚本,由它返回该配置的 CCR API Key。
|
||||
- 把网关地址写入 `ANTHROPIC_BASE_URL`、`ANTHROPIC_API_BASE_URL`、`CLAUDE_AGENT_API_BASE_URL`。
|
||||
- 移除可能冲突的 `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY`,让密钥来自 CCR 而非本地存储。
|
||||
- 写入你选择的模型与各档位模型别名(见下文)。
|
||||
- 生成启动 **包装器**,设置入口模式、远程同步(用于 App)、机器人环境与工具/MCP 配置,再启动 Claude Code。
|
||||
|
||||
选择 **仅从 CCR 打开时生效** 时,这些文件都落在按配置 `id` 隔离的 CCR 受管目录中,你从系统直接打开的 Claude Code 不受影响。选择 **系统默认** 时,CCR 会写入真实的 `~/.claude/settings.json`。
|
||||
|
||||
Claude App 是 **零配置** 的:从 CCR 打开时会自动写入 App 网关配置、API Key、模型发现列表与独立的用户数据目录。如果 Claude App 已在运行,按提示从 CCR 重新打开即可。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **Claude Code**。
|
||||
2. 填写 **配置名称**(例如 `Claude Code - Work`)。
|
||||
3. 选择 **生效范围** 与 **入口模式**。
|
||||
4. 选择 **模型**(留空则保留 Claude Code 默认模型)。
|
||||
5. 按需填写各档位模型、设置文件与环境变量。
|
||||
5. 按需填写各档位模型和高级设置。
|
||||
6. 若入口模式包含 App 且需要使用 AgentClaw,绑定一个 **机器人**。
|
||||
7. **保存**,然后从 CCR 打开 Claude Code(终端按钮打开 CLI,播放按钮打开 App)。
|
||||
|
||||
|
|
@ -50,57 +36,52 @@ Claude App 是 **零配置** 的:从 CCR 打开时会自动写入 App 网关
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **Claude Code** | 让 CCR 采用 Claude Code 的设置文件机制。 |
|
||||
| 配置名称 | 自由文本,例如 `Claude Code - Work` | 在 CCR 中标识该配置,并用于 `ccr-app "<名称>"` 命令;允许空格,复制命令时自动加引号。 |
|
||||
| Agent | 选择 **Claude Code** | 在 CCR 中创建 Claude Code 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `Claude Code - Work` | 在 CCR 中标识该配置。桌面端命令使用 `ccr-app "<名称>"`,CLI 命令使用 `ccr "<名称>"`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | “仅从 CCR 打开”使用 CCR 受管的隔离设置文件;“系统默认”写入 `~/.claude/settings.json`。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 仅影响从 CCR 打开的 Claude Code,或作为系统默认 Claude Code 配置。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 入口模式 | `CLI 与 APP` / `仅 CLI` / `仅 App` | “CLI 与 APP”同时提供终端命令和 App 按钮;“仅 CLI”只生成命令;“仅 App”只提供 App 入口。 |
|
||||
| 模型 | 供应商模型或 Fusion 模型,例如 `Moonshot/kimi-k3` | 写入 `ANTHROPIC_MODEL`。留空则保留 Claude Code 自身默认模型。 |
|
||||
| Fable 模型 | 可选 | 写入 `ANTHROPIC_DEFAULT_FABLE_MODEL`,即 Claude Code 在 Fable 档位使用的模型。 |
|
||||
| Opus 模型 | 可选 | 写入 `ANTHROPIC_DEFAULT_OPUS_MODEL`。 |
|
||||
| Sonnet 模型 | 可选 | 写入 `ANTHROPIC_DEFAULT_SONNET_MODEL`。 |
|
||||
| Haiku 模型 | 可选 | 写入 `ANTHROPIC_DEFAULT_HAIKU_MODEL`(同时也是小型快速模型)。 |
|
||||
| 设置文件 | 路径,默认 `~/.claude/settings.json` | 在“系统默认”范围下使用;“仅从 CCR 打开”会改用 CCR 受管的隔离文件而忽略此项。 |
|
||||
| 模型 | 供应商模型或 Fusion 模型,例如 `Moonshot/kimi-k3` | 当前配置的默认模型。留空则保留 Claude Code 自身默认模型。 |
|
||||
| Fable 模型 | 可选 | 覆盖 Fable 档位使用的模型。 |
|
||||
| Opus 模型 | 可选 | 覆盖 Opus 档位使用的模型。 |
|
||||
| Sonnet 模型 | 可选 | 覆盖 Sonnet 档位使用的模型。 |
|
||||
| Haiku 模型 | 可选 | 覆盖小型快速档位使用的模型。 |
|
||||
| 设置文件 | 路径 | 用于系统默认 Claude Code 配置。 |
|
||||
| CCR 托管压缩 | 开关 | 让 CCR 为该配置托管上下文压缩(compact)。 |
|
||||
| 环境变量 | 键值对 | 合并进 Claude Code 设置的 `env`,见下文。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
| 机器人 | 选择已保存的机器人(仅 App 入口) | 把一个 AgentClaw IM 机器人绑定到 Claude App 入口;CLI 暂不转发机器人消息。 |
|
||||
|
||||
### 模型档位
|
||||
|
||||
Claude Code 会为内部不同档位选择模型。**模型** 字段设置默认值(`ANTHROPIC_MODEL`),**Fable / Opus / Sonnet / Haiku 模型** 字段则可单独覆盖某个档位——例如让 Opus 走更强的供应商模型,而 Haiku 走更便宜的模型。某个档位留空则由 Claude Code 自行决定。
|
||||
Claude Code 会为不同档位选择模型。**模型** 字段设置默认值,**Fable / Opus / Sonnet / Haiku 模型** 字段则可单独覆盖某个档位——例如让 Opus 走更强的供应商模型,而 Haiku 走更便宜的模型。某个档位留空则由 Claude Code 自行决定。
|
||||
|
||||
### CLI 与 App 的模型列表
|
||||
|
||||
| 入口 | 模型列表来源 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Claude Code CLI | CCR 网关模型发现 | 在 CLI 中使用 `/model` 查看并切换 CCR 暴露的模型,包括 Fusion 模型。 |
|
||||
| Claude App | CCR 生成的 App 推理模型 | Claude App 需要 Claude 兼容的模型名,CCR 会把每个 `供应商/模型` 与 Fusion 模型映射成 App 可识别的条目,并在显示名中保留真实含义。 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`:默认开启,使 `/model` 能列出网关模型。除非有特殊需要,请保留。
|
||||
- `CCR_CLAUDE_CODE_BIN`:当 CCR Desktop 进程的 `PATH` 中找不到 `claude` 时,用它指定真实可执行文件的绝对路径。
|
||||
- 其他键值对会导出到启动包装器并合并进设置 `env`。
|
||||
- 在中国时区下,CCR 还会自动设置 `TZ=UTC` 以避免时区相关的工具故障,无需手动配置。
|
||||
|
||||
> 网关地址、API Key 辅助脚本和启动包装器都由 CCR 自动写入。请勿在此手动设置 `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`——这些由 CCR 托管。
|
||||
| Claude App | CCR 模型列表 | 从 CCR 打开 Claude App 后使用当前配置的模型列表。 |
|
||||
|
||||
## 打开与使用
|
||||
|
||||
- **CLI**:点击配置卡片上的终端按钮,运行复制出的命令:
|
||||
- **CLI**:点击桌面端配置卡片上的终端按钮,运行复制出的命令:
|
||||
```text
|
||||
ccr-app "Claude Code - Work"
|
||||
```
|
||||
CLI请执行:
|
||||
```text
|
||||
ccr "Claude Code - Work"
|
||||
```
|
||||
进入 Claude Code 后用 `/model` 查看并切换 CCR 暴露的模型。
|
||||
- **App**:点击播放按钮。CCR 会用该配置的网关配置和独立用户数据目录打开 Claude App。再次打开同一配置会激活已有窗口。
|
||||
- **App**:点击播放按钮,用当前配置打开 Claude App。再次打开同一配置会激活已有窗口。
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id`。选择 **仅从 CCR 打开时生效** 时,Claude Code CLI 会获得隔离的设置文件与包装器,Claude App 会获得独立的用户数据目录,因此多个 Claude Code 配置可同时运行,各自使用不同的模型、范围或机器人。
|
||||
需要不同模型、范围或机器人时,创建多个 Claude Code 配置即可。
|
||||
|
||||
## AgentClaw(机器人)
|
||||
|
||||
绑定机器人并从 CCR 打开 Claude App 后,伴随工作进程会把项目/会话、流式回复、附件、会话用量,以及原生的权限/Ask User 请求暴露到 IM。App 关闭时该工作进程随之停止。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
绑定机器人并从 CCR 打开 Claude App 后,Claude App 可以把会话接入所选 IM 通道。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
|
||||
## 验证
|
||||
|
||||
|
|
@ -112,6 +93,6 @@ Claude Code 会为内部不同档位选择模型。**模型** 字段设置默认
|
|||
## 常见问题
|
||||
|
||||
- **请求没有走到 CCR**:确认配置已 **启用**,且你是从 CCR 打开 Claude Code(而非从系统直接打开)。除非范围是 **系统默认**,否则直接打开的 Claude Code 不受影响。
|
||||
- **`/model` 看不到 CCR 模型**:保留环境变量中的 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,并确认已配置供应商与模型。
|
||||
- **`/model` 看不到 CCR 模型**:确认已配置供应商与模型。
|
||||
- **Claude App 没有应用配置**:Claude App 已在运行——从 CCR 重新打开,或按提示重启。
|
||||
- **模型档位未生效**:档位只改变 Claude Code 某个内部档位使用哪个供应商模型;取值必须是 CCR 能提供的有效 `供应商/模型` 或 Fusion 模型。
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
title: Claude Design 接入与配置
|
||||
pageTitle: Claude Design
|
||||
eyebrow: 详细配置
|
||||
lead: "把 Claude Design 接入 CCR。Claude Design 仅支持 App,且完全由 CCR Desktop 托管。本页说明适用的少量字段,以及它的路由方式。"
|
||||
lead: "把 Claude Design 接入 CCR。Claude Design 仅支持 App。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
Claude Design 是 Anthropic 的设计 Agent,以桌面应用形态运行。在 CCR 中它 **仅支持 App**,且始终限定为从 CCR 打开:从 CCR Desktop 打开,由 CCR 为你托管其运行时。与各编码 Agent 不同,Claude Design 没有需要填写的模型/供应商/配置文件字段——这些都由 CCR 自动处理。
|
||||
Claude Design 是 Anthropic 的设计 Agent,以桌面应用形态运行。在 CCR 中它 **仅支持 App**,且始终从 CCR Desktop 打开。
|
||||
|
||||
当你想注册一个 Claude Design 配置,或可选地添加路由规则时,使用本页。
|
||||
|
||||
|
|
@ -19,10 +19,6 @@ Claude Design 是 Anthropic 的设计 Agent,以桌面应用形态运行。在
|
|||
2. Claude Design 可通过 CCR Desktop 使用。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 Claude Design
|
||||
|
||||
Claude Design **由 CCR Desktop 托管**——应用配置时,CCR 不会像对待 Claude Code 或 Codex 那样写入独立的 Agent 配置文件。该配置是一个启动入口,CCR Desktop 用它来以当前的网关、供应商、路由和模型选择打开并运行 Claude Design。它只能从 CCR Desktop 打开(不能用 `ccr-app` 终端命令)。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **Claude Design**。
|
||||
|
|
@ -36,20 +32,18 @@ Claude Design 固定为 **仅 App** 和 **仅从 CCR 打开时生效**。多数
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **Claude Design** | 注册一个仅 App、由 CCR 托管的配置。 |
|
||||
| Agent | 选择 **Claude Design** | 在 CCR 中创建 Claude Design App 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `Claude Design` | 在 CCR 中标识该配置。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 路由 | 可选的路由规则 | 影响该配置请求路由方式的规则。参见[智能路由](/routing/)。 |
|
||||
|
||||
> Claude Design 不暴露模型、供应商、配置文件、环境变量与入口模式——这些都由 CCR Desktop 托管。
|
||||
|
||||
## 路由
|
||||
|
||||
你可以为 Claude Design 配置附加路由规则,控制由哪个供应商或模型处理其请求(例如固定到某个供应商或增加故障转移)。增强路由开关对 Claude Design 不适用(始终开启),只有显式规则会生效。规则的工作方式见[智能路由](/routing/)。
|
||||
|
||||
## 打开与使用
|
||||
|
||||
从 CCR Desktop 打开 Claude Design。它无法用 `ccr-app` 终端命令启动。
|
||||
从 CCR Desktop 打开 Claude Design。它无法用终端配置命令启动。
|
||||
|
||||
## 验证
|
||||
|
||||
|
|
@ -59,6 +53,6 @@ Claude Design 固定为 **仅 App** 和 **仅从 CCR 打开时生效**。多数
|
|||
|
||||
## 常见问题
|
||||
|
||||
- **无法从终端打开**:Claude Design 仅支持 App,需从 CCR Desktop 打开,而非 `ccr-app`。
|
||||
- **无法从终端打开**:Claude Design 仅支持 App,需从 CCR Desktop 打开。
|
||||
- **请求绕过了 CCR**:确认配置已 **启用**,且你是从 CCR Desktop 打开 Claude Design。
|
||||
- **路由规则无效**:只有显式规则生效;增强路由开关对 Claude Design 始终开启。
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
title: Codex 接入与配置
|
||||
pageTitle: Codex
|
||||
eyebrow: 详细配置
|
||||
lead: "把 Codex(CLI 与 ChatGPT 桌面应用)接入 CCR。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 Codex(CLI 与 ChatGPT 桌面应用)接入 CCR。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
Codex 是 OpenAI 的编码 Agent。CCR 同时支持两种形态:
|
||||
|
||||
- **Codex CLI**:终端 Agent。CCR 在 Codex 的 `config.toml` 中写入受管供应商,并通过中间件启动器打开 CLI。
|
||||
- **ChatGPT 应用**:桌面应用(更名后的 Codex 桌面应用)。CCR 用隔离的用户数据目录启动它,并通过 CCR 中间件转发其 app-server 流量。
|
||||
- **Codex CLI**:终端 Agent。
|
||||
- **ChatGPT 应用**:桌面应用(更名后的 Codex 桌面应用)。
|
||||
|
||||
当你想让 Codex 走非 OpenAI 供应商、固定模型,或运行相互隔离的 Codex/ChatGPT 实例时,使用本页。
|
||||
当你想让 Codex 走非 OpenAI 供应商、固定模型,或运行独立的 Codex/ChatGPT 实例时,使用本页。
|
||||
|
||||
> 第一次使用 CCR?请先接入供应商和模型。参见[接入供应商](/guides/provider/)与 [Agent 配置总览](/configuration/profiles/)。
|
||||
|
||||
|
|
@ -22,27 +22,13 @@ Codex 是 OpenAI 的编码 Agent。CCR 同时支持两种形态:
|
|||
2. 已安装 Codex CLI(`PATH` 中可用 `codex`),或已安装 ChatGPT 桌面应用。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 Codex
|
||||
|
||||
保存一个 Codex 配置时,CCR 会在 Codex 的 **配置文件**(`config.toml`)中写入受管内容:
|
||||
|
||||
- 顶层写入 `model_provider`、`model`,以及指向模型目录的 `model_catalog_json`。
|
||||
- 写入 `[model_providers.<供应商 ID>]` 表,包含 CCR 网关 `/v1` 地址、Bearer Token 与 `wire_api = "responses"`。
|
||||
- 写入独立的 `<供应商 ID>.config.toml` 配置文件(Codex 的分离配置文件格式)。
|
||||
- 写入模型目录文件(`ccr-model-catalog.json`),供原生 app-server 读取 `model/list`。
|
||||
- 生成 CLI **中间件启动器**,设置 `CODEX_HOME`、供应商/模型与入口模式,再启动 Codex。
|
||||
|
||||
选择 **仅从 CCR 打开时生效** 时,这些文件落在按配置 `id` 隔离的 CCR 受管目录中。选择 **系统默认** 时,CCR 写入 `~/.codex/config.toml`。
|
||||
|
||||
对于 **ChatGPT 应用**,CCR 会直接启动应用包内的 Electron 可执行文件,为其分配隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间件。中间件把 app-server 流量转发到 ChatGPT 内置的 Codex CLI,仅适配账号展示,不合成模型或插件列表。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **Codex**。
|
||||
2. 填写 **配置名称**(例如 `Codex - Work`)。
|
||||
3. 选择 **生效范围** 与 **入口模式**。
|
||||
4. 确认 **供应商 ID**、**供应商名称** 与 **Codex 模型**。
|
||||
5. 按需调整 **显示全部会话**、**配置文件** 与环境变量。
|
||||
5. 只有本机环境有特殊要求时,再调整高级设置。
|
||||
6. 若入口模式包含 App 且需要使用 AgentClaw,绑定一个 **机器人**。
|
||||
7. **保存**,然后从 CCR 打开 Codex(终端按钮打开 CLI,播放按钮打开 ChatGPT)。
|
||||
|
||||
|
|
@ -50,46 +36,42 @@ Codex 是 OpenAI 的编码 Agent。CCR 同时支持两种形态:
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **Codex** | 让 CCR 采用 Codex 的 `config.toml` 机制。 |
|
||||
| 配置名称 | 自由文本,例如 `Codex - Work` | 标识该配置,并用于 `ccr-app "<名称>"`。 |
|
||||
| Agent | 选择 **Codex** | 在 CCR 中创建 Codex 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `Codex - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>"`,CLI 命令使用 `ccr "<名称>"`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 隔离的 CCR 受管文件 vs. 真实的 `~/.codex/config.toml`。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 仅影响从 CCR 打开的 Codex,或作为系统默认 Codex 配置。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 入口模式 | `CLI 与 APP` / `仅 CLI` / `仅 App` | 提供哪些启动入口(终端命令和/或 ChatGPT 应用)。 |
|
||||
| 供应商 ID | 字母、数字、`.`、`_`、`-`;默认 `claude-code-router` | 写入 Codex 的 `model_provider` 与供应商表键名,请保持稳定。 |
|
||||
| 供应商 ID | 字母、数字、`.`、`_`、`-`;默认 `claude-code-router` | 当前 Codex 配置使用的供应商引用,请保持稳定。 |
|
||||
| 供应商名称 | 自由文本;默认 `Claude Code Router` | 在 Codex 中显示的名称。 |
|
||||
| Codex 模型 | 供应商模型或 Fusion 模型 | Codex 默认模型。留空时 CCR 使用第一个可用默认模型。 |
|
||||
| 显示全部会话 | 开关 | 写入 `show_all_sessions`,让 Codex 列出全部会话。 |
|
||||
| 配置文件 | 路径,默认 `~/.codex/config.toml` | 在“系统默认”范围下使用;“仅从 CCR 打开”写入 CCR 受管目录。 |
|
||||
| Codex CLI 路径 | 可选,`codex` 可执行文件绝对路径 | 供中间件启动器使用;仅当 Codex 不在 `PATH` 时填写。 |
|
||||
| 显示全部会话 | 开关 | 让 Codex 列出全部会话。 |
|
||||
| 配置文件 | 路径 | 用于系统默认 Codex 配置。 |
|
||||
| Codex CLI 路径 | 可选,`codex` 可执行文件绝对路径 | 仅当 Codex 不在 `PATH` 时填写。 |
|
||||
| Codex home | 可选目录 | 设置 `CODEX_HOME`;仅当需要特定 home 目录时填写。 |
|
||||
| 远程前端模式 | `app` / `cli` / `claude-code` | 中间件呈现 Codex 前端的方式,无特殊需要请保持默认。 |
|
||||
| 远程前端模式 | `app` / `cli` / `claude-code` | 无特殊需要请保持默认。 |
|
||||
| CCR 托管压缩 | 开关 | 让 CCR 为该配置托管上下文压缩。 |
|
||||
| 环境变量 | 键值对 | 注入 Codex CLI / ChatGPT,见下文。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
| 机器人 | 选择已保存的机器人(仅 App 入口) | 把一个 AgentClaw IM 机器人绑定到 ChatGPT 应用入口。 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CCR_CODEX_CHATGPT_AUTH_FILE`(旧名 `CODEXL_CODEX_CHATGPT_AUTH_FILE`):指向有效 `auth.json` 的路径。仅当希望某个配置在内存中共享 ChatGPT 登录 Token(不复制文件)时设置。默认每个配置只读取自身 Codex home 的登录态。
|
||||
- Claude Code 专属的模型发现变量 **不会** 传给 Codex。
|
||||
- 其他键值对会导出到中间件启动器。
|
||||
|
||||
> 未共享凭据的配置会报告一个本地的非 OpenAI 兼容身份,使当前 ChatGPT 版本不会陷入反复认证/校验循环。CCR 仅在进程启动期间创建临时的 `ccr-local-profile` 引导项,并在首次原生响应后移除,绝不作为登录态保留。
|
||||
|
||||
## 打开与使用
|
||||
|
||||
- **CLI**:点击终端按钮,运行复制出的命令:
|
||||
- **CLI**:在桌面端点击终端按钮,运行复制出的命令:
|
||||
```text
|
||||
ccr-app "Codex - Work"
|
||||
```
|
||||
- **App**:点击播放按钮,用该配置的模型、供应商和独立用户数据目录打开 ChatGPT。再次打开同一配置会激活已有窗口。
|
||||
CLI请执行:
|
||||
```text
|
||||
ccr "Codex - Work"
|
||||
```
|
||||
- **App**:点击播放按钮,用该配置的模型和供应商打开 ChatGPT。再次打开同一配置会激活已有窗口。
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id`。选择 **仅从 CCR 打开时生效** 时,Codex 会获得隔离的配置文件与中间件启动器,ChatGPT 会获得独立的用户数据目录,因此多个 Codex 配置可同时运行,各自使用不同的模型或供应商。
|
||||
需要不同模型或供应商时,创建多个 Codex 配置即可。
|
||||
|
||||
## AgentClaw(机器人)
|
||||
|
||||
绑定机器人并从 CCR 打开 ChatGPT 后,伴随工作进程会使用 Codex 原生 rollout 会话进行项目/会话浏览与续接、排队、取消、模型设置、用量、附件与诊断。它仅随受管应用存在。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
绑定机器人并从 CCR 打开 ChatGPT 后,ChatGPT 可以把会话接入所选 IM 通道。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
|
||||
## 验证
|
||||
|
||||
|
|
@ -101,6 +83,6 @@ Codex 是 OpenAI 的编码 Agent。CCR 同时支持两种形态:
|
|||
## 常见问题
|
||||
|
||||
- **请求绕过了 CCR**:确认配置已 **启用**,且你是从 CCR 打开 Codex;除非范围是 **系统默认**,否则直接打开的 Codex 不受影响。
|
||||
- **ChatGPT 一直要求登录**:未共享凭据的配置会使用本地兼容身份,属正常现象。仅在你有意设置 `CCR_CODEX_CHATGPT_AUTH_FILE` 时才共享 Token。
|
||||
- **ChatGPT 一直要求登录**:正常登录 ChatGPT 后,再从 CCR 重新打开。
|
||||
- **供应商 ID 被拒**:只使用字母、数字、点、下划线或连字符,并在多次保存间保持稳定。
|
||||
- **App 中模型不对**:检查 **Codex 模型** 字段;留空时 CCR 会回退到第一个可用默认模型。
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
title: Grok CLI 接入与配置
|
||||
pageTitle: Grok CLI
|
||||
eyebrow: 详细配置
|
||||
lead: "把 Grok CLI 接入 CCR。Grok CLI 仅支持 CLI,且始终限定为从 CCR 打开的会话。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 Grok CLI 接入 CCR。Grok CLI 仅支持 CLI,且始终限定为从 CCR 打开的会话。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
Grok CLI 是 xAI 的编码 Agent。在 CCR 中它 **仅支持 CLI**,且始终使用 **仅从 CCR 打开时生效**:CCR 不会改写你的全局 Grok 配置,而是为每个配置生成启动包装器和隔离的 Grok home,确保推理始终使用 CCR 的密钥与模型网关。
|
||||
Grok CLI 是 xAI 的编码 Agent。在 CCR 中它 **仅支持 CLI**,且始终使用 **仅从 CCR 打开时生效**。
|
||||
|
||||
当你想让 Grok CLI 走任意 CCR 供应商或 Fusion 模型,或运行多个相互隔离的 Grok CLI 会话时,使用本页。
|
||||
当你想让 Grok CLI 走任意 CCR 供应商或 Fusion 模型,或运行多个独立的 Grok CLI 会话时,使用本页。
|
||||
|
||||
> 第一次使用 CCR?请先接入供应商和模型。参见[接入供应商](/guides/provider/)与 [Agent 配置总览](/configuration/profiles/)。
|
||||
|
||||
|
|
@ -19,25 +19,12 @@ Grok CLI 是 xAI 的编码 Agent。在 CCR 中它 **仅支持 CLI**,且始终
|
|||
2. 已安装 Grok CLI(`PATH` 中可用 `grok`)。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 Grok CLI
|
||||
|
||||
保存一个 Grok CLI 配置时,CCR 会在其 `bin` 目录生成启动 **包装器**,导出:
|
||||
|
||||
- `GROK_MODELS_BASE_URL` → CCR 网关的 `/v1` 端点;`GROK_MODELS_LIST_URL` → `/v1/models` 端点(模型列表)。
|
||||
- `XAI_API_KEY` → 该配置的 CCR API Key。
|
||||
- `GROK_DEFAULT_MODEL` → 你选择的模型。
|
||||
- `GROK_HOME` → 配置专属目录。
|
||||
|
||||
它还会把 `GROK_HOME` 指向配置专属的 home。该 home 的 `config.toml` 起始时是源 Grok home 的私有副本,而 `auth.json` 被排除,避免本地 xAI OAuth Token 覆盖 CCR 密钥。插件、技能、会话等 home 条目仍链接到原始 Grok home。
|
||||
|
||||
启动时若 CCR Desktop 网关未运行,`ccr-app` 会启动一个可共享的临时网关服务,并在最后一个并发 Grok 会话退出后停止它。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **Grok CLI**。
|
||||
2. 填写 **配置名称**(例如 `Grok - Work`)。
|
||||
3. 选择 **模型**。
|
||||
4. 仅当需要非默认的 Grok 可执行文件或源 home 时,再添加环境变量。
|
||||
4. 高级环境设置通常留空,只有本机环境有特殊要求时才填写。
|
||||
5. **保存**,然后复制并运行配置卡片上的命令。
|
||||
|
||||
## 配置项详解
|
||||
|
|
@ -46,44 +33,41 @@ Grok CLI 固定为 **仅从 CCR 打开时生效** 和 **仅 CLI**,这两项不
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **Grok CLI** | 让 CCR 生成 Grok 包装器与隔离 home。 |
|
||||
| 配置名称 | 自由文本,例如 `Grok - Work` | 标识该配置,并用于 `ccr-app "<名称>"`。 |
|
||||
| Agent | 选择 **Grok CLI** | 在 CCR 中创建 Grok CLI 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `Grok - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>"`,CLI 命令使用 `ccr "<名称>"`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 模型 | 供应商模型或 Fusion 模型 | 写入 `GROK_DEFAULT_MODEL`,即 Grok CLI 启动时使用的模型。 |
|
||||
| 环境变量 | 键值对 | 导出到包装器,见下文。 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CCR_GROK_BIN`:当 CCR Desktop 进程的 `PATH` 中找不到 `grok` 时,指定真实可执行文件的绝对路径。
|
||||
- `GROK_HOME` / `GROK_STORAGE_DIR` / `GROK_CONFIG_DIR`:设置 CCR 复制 `config.toml` 的 **源** Grok home(默认 `~/.grok`)。仅当你的 Grok home 在非默认位置时使用。
|
||||
- CCR 还会自动把网关主机加入 `NO_PROXY`/`no_proxy`,让 Grok 直连本地网关,无需手动配置。
|
||||
|
||||
> CCR 自行管理的变量(`GROK_MODELS_BASE_URL`、`GROK_MODELS_LIST_URL`、`GROK_DEFAULT_MODEL`、`GROK_HOME`、`XAI_API_KEY` 等)均为保留项——在配置环境变量中手动设置无效,因为包装器会覆盖它们。
|
||||
| 模型 | 供应商模型或 Fusion 模型 | Grok CLI 启动时使用的模型。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
|
||||
## 打开与使用
|
||||
|
||||
点击配置卡片上的终端按钮,运行复制出的命令:
|
||||
桌面端配置卡片复制出的命令:
|
||||
|
||||
```text
|
||||
ccr-app "Grok - Work"
|
||||
```
|
||||
|
||||
CLI请执行:
|
||||
|
||||
```text
|
||||
ccr "Grok - Work"
|
||||
```
|
||||
|
||||
进入 Grok CLI 后,使用 `/model` 在 CCR 返回的供应商与 Fusion 模型之间切换;切换后的请求仍经过 CCR。
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id` 和独立的隔离 `GROK_HOME`,因此多个 Grok CLI 配置可同时运行,使用不同的模型或彼此独立的会话历史,且不会影响原有的 `~/.grok`。
|
||||
需要不同模型时,创建多个 Grok CLI 配置即可。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 运行从配置卡片复制的 `ccr-app` 命令。
|
||||
1. 运行桌面端配置卡片复制的 `ccr-app` 命令,或 CLI 的 `ccr` 命令。
|
||||
2. 在 Grok CLI 中发送一条消息,确认能正常回复。
|
||||
3. 打开 CCR 的 **请求日志**,确认请求经过了网关。
|
||||
4. 运行 `/model`,确认 CCR 暴露的模型出现。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **Grok 用了 xAI 账号而非 CCR**:配置 home 按设计排除了 `auth.json`;请确认你是通过 `ccr-app` 启动,而非直接打开 `grok`。
|
||||
- **找不到 `grok`**:用 `CCR_GROK_BIN` 指定真实可执行文件路径。
|
||||
- **`/model` 看不到 CCR 模型**:确认 CCR 中已配置供应商与模型,且网关可达(临时网关仅在受管会话运行期间启动)。
|
||||
- **复制了错误的源配置**:若你的 Grok home 不是 `~/.grok`,请用 `GROK_HOME`(或 `GROK_STORAGE_DIR` / `GROK_CONFIG_DIR`)指定正确的源 home。
|
||||
- **Grok 用了 xAI 账号而非 CCR**:确认你是从 CCR 配置卡片打开 Grok。
|
||||
- **找不到 `grok`**:确认 Grok CLI 已安装,并且启动 CCR Desktop 的同一 shell 环境可以找到它。
|
||||
- **`/model` 看不到 CCR 模型**:确认 CCR 中已配置供应商与模型。
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
title: Kilo Code 接入与配置
|
||||
pageTitle: Kilo Code
|
||||
eyebrow: 详细配置
|
||||
lead: "把 Kilo Code 接入 CCR。Kilo Code 在 CCR 中仅支持 CLI。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 Kilo Code 接入 CCR。Kilo Code 在 CCR 中仅支持 CLI。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
Kilo Code 是一款采用 OpenAI 兼容供应商模型的编码 Agent。在 CCR 中它 **仅支持 CLI**:CCR 在 Kilo 的 JSONC 配置中写入受管网关供应商,并通过包装器把 Kilo 指向 CCR。
|
||||
Kilo Code 是一款采用 OpenAI 兼容供应商模型的编码 Agent。在 CCR 中它 **仅支持 CLI**。
|
||||
|
||||
当你想让 Kilo Code 走任意 CCR 供应商或 Fusion 模型时,使用本页。
|
||||
|
||||
|
|
@ -19,22 +19,13 @@ Kilo Code 是一款采用 OpenAI 兼容供应商模型的编码 Agent。在 CCR
|
|||
2. 已安装 Kilo Code(`PATH` 中可用 `kilo`)。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 Kilo Code
|
||||
|
||||
保存一个 Kilo Code 配置时,CCR 会:
|
||||
|
||||
- 在 Kilo 的 **配置文件**(`kilo.jsonc`)中写入受管网关供应商,把 Kilo 指向 CCR 网关,并使用该配置的 Token 与所选模型。
|
||||
- 生成启动 **包装器**,把 `KILO_CONFIG`(以及内联的 `KILO_CONFIG_CONTENT`)指向受管配置,再启动 Kilo。
|
||||
|
||||
选择 **仅从 CCR 打开时生效** 时,配置落在按配置 `id` 隔离的 CCR 受管目录中。选择 **系统默认** 时,CCR 写入真实的 `~/.config/kilo/kilo.jsonc`。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **Kilo Code**。
|
||||
2. 填写 **配置名称**(例如 `Kilo - Work`)。
|
||||
3. 选择 **生效范围**。
|
||||
4. 确认 **供应商 ID**、**供应商名称** 与 **Kilo 模型**。
|
||||
5. 按需调整 **配置文件** 与环境变量。
|
||||
5. 只有本机环境有特殊要求时,再调整高级设置。
|
||||
6. **保存**,然后复制并运行配置卡片上的命令。
|
||||
|
||||
## 配置项详解
|
||||
|
|
@ -43,44 +34,42 @@ Kilo Code 固定为 **仅 CLI**,入口模式不可编辑。可配置的字段
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **Kilo Code** | 让 CCR 写入 Kilo 网关配置与包装器。 |
|
||||
| 配置名称 | 自由文本,例如 `Kilo - Work` | 标识该配置,并用于 `ccr-app "<名称>"`。 |
|
||||
| Agent | 选择 **Kilo Code** | 在 CCR 中创建 Kilo Code 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `Kilo - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>"`,CLI 命令使用 `ccr "<名称>"`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 隔离的 CCR 受管配置 vs. 真实的 `~/.config/kilo/kilo.jsonc`。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 供应商 ID | 默认 `claude-code-router` | CCR 写入 Kilo 配置的供应商引用。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 仅影响从 CCR 打开的 Kilo,或作为系统默认 Kilo 配置。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 供应商 ID | 默认 `claude-code-router` | 当前 Kilo 配置使用的供应商引用。 |
|
||||
| 供应商名称 | 自由文本;默认 `Claude Code Router` | 在 Kilo 中显示的名称。 |
|
||||
| Kilo 模型 | 供应商模型或 Fusion 模型 | Kilo 通过 CCR 使用的默认模型。 |
|
||||
| 配置文件 | 路径,默认 `~/.config/kilo/kilo.jsonc` | 在“系统默认”范围下使用;“仅从 CCR 打开”写入 CCR 受管目录。 |
|
||||
| 环境变量 | 键值对 | 导出到包装器,见下文。 |
|
||||
|
||||
> Kilo Code 不暴露 **显示全部会话**,该项被强制关闭。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CCR_KILO_BIN` / `KILO_BIN`:当 CCR Desktop 进程的 `PATH` 中找不到 `kilo` 时,指定真实可执行文件的绝对路径。
|
||||
- 其他键值对会导出到启动包装器。
|
||||
- CCR 自行管理的变量(`KILO_CONFIG`、`KILO_CONFIG_CONTENT`、`CCR_PROFILE_SURFACE`)均为保留项——手动设置无效。
|
||||
| 配置文件 | 路径 | 用于系统默认 Kilo 配置。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
|
||||
## 打开与使用
|
||||
|
||||
点击配置卡片上的终端按钮,运行复制出的命令:
|
||||
桌面端配置卡片复制出的命令:
|
||||
|
||||
```text
|
||||
ccr-app "Kilo - Work"
|
||||
```
|
||||
|
||||
CLI请执行:
|
||||
|
||||
```text
|
||||
ccr "Kilo - Work"
|
||||
```
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id`。选择 **仅从 CCR 打开时生效** 时,Kilo 会获得隔离的配置文件与包装器,因此多个 Kilo 配置可同时运行,使用不同的模型或供应商。
|
||||
需要不同模型或供应商时,创建多个 Kilo 配置即可。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 运行从配置卡片复制的 `ccr-app` 命令。
|
||||
1. 运行桌面端配置卡片复制的 `ccr-app` 命令,或 CLI 的 `ccr` 命令。
|
||||
2. 在 Kilo 中发送一条消息,确认能正常回复。
|
||||
3. 打开 CCR 的 **请求日志**,确认请求经过了网关。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **请求绕过了 CCR**:确认配置已 **启用**,且你是通过 `ccr-app` 启动;除非范围是 **系统默认**,否则直接打开的 Kilo 不受影响。
|
||||
- **找不到 `kilo`**:用 `CCR_KILO_BIN` 指定真实可执行文件路径。
|
||||
- **请求绕过了 CCR**:确认配置已 **启用**,且你是通过 CCR 配置命令启动;除非范围是 **系统默认**,否则直接打开的 Kilo 不受影响。
|
||||
- **找不到 `kilo`**:确认 Kilo Code 已安装,并且启动 CCR Desktop 的同一 shell 环境可以找到它。
|
||||
- **模型不对**:确认 **Kilo 模型** 字段解析为 CCR 能提供的模型。
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
title: Kimi CLI 接入与配置
|
||||
pageTitle: Kimi CLI
|
||||
eyebrow: 详细配置
|
||||
lead: "把 Kimi CLI 接入 CCR。Kimi CLI 仅支持 CLI,且始终限定为从 CCR 打开的会话。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 Kimi CLI 接入 CCR。Kimi CLI 仅支持 CLI,且始终限定为从 CCR 打开的会话。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
Kimi CLI 是 Moonshot 的编码 Agent。在 CCR 中它 **仅支持 CLI**,且始终使用 **仅从 CCR 打开时生效**:CCR 不会改写原有的 `~/.kimi-code/config.toml`,而是生成配置专属的 Kimi home,其中的 `config.toml` 注册一个私有的 CCR 供应商,并为每个选中模型生成一条模型条目,使 `/model` 切换始终经过 CCR。
|
||||
Kimi CLI 是 Moonshot 的编码 Agent。在 CCR 中它 **仅支持 CLI**,且始终使用 **仅从 CCR 打开时生效**。
|
||||
|
||||
当你想让 Kimi CLI 走任意 CCR 供应商或 Fusion 模型、暴露多个可切换模型,或运行相互隔离的 Kimi CLI 会话时,使用本页。
|
||||
当你想让 Kimi CLI 走任意 CCR 供应商或 Fusion 模型、暴露多个可切换模型,或运行独立的 Kimi CLI 会话时,使用本页。
|
||||
|
||||
> 第一次使用 CCR?请先接入供应商和模型。参见[接入供应商](/guides/provider/)与 [Agent 配置总览](/configuration/profiles/)。
|
||||
|
||||
|
|
@ -19,22 +19,12 @@ Kimi CLI 是 Moonshot 的编码 Agent。在 CCR 中它 **仅支持 CLI**,且
|
|||
2. 已安装 Kimi CLI(`PATH` 中可用 `kimi`)。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 Kimi CLI
|
||||
|
||||
保存一个 Kimi CLI 配置时,CCR 会:
|
||||
|
||||
- 在其配置目录树下创建配置专属的 `KIMI_CODE_HOME` 目录。
|
||||
- 在该 home 中生成 `config.toml`,定义一个 OpenAI 兼容的私有 CCR 供应商(`base_url` 为网关 `/v1`,API Key 为配置 Token),并为默认模型和每个可用模型生成 `[models.<模型>]` 条目,包含上下文窗口、能力、推理档位与显示名。
|
||||
- 生成启动 **包装器**,把 `KIMI_CODE_HOME` 指向该 home,清除单模型覆盖变量,再启动 Kimi。
|
||||
|
||||
原有的 `~/.kimi-code/config.toml` 绝不会被改写。CCR 通过链接方式复用源 Kimi home 中的会话、技能、插件、MCP 配置与凭据。CCR Desktop 未运行时,包装器会启动一个可共享的临时网关,并在最后一个受管 Kimi 会话退出后停止它。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **Kimi CLI**。
|
||||
2. 填写 **配置名称**(例如 `Kimi - Work`)。
|
||||
3. 选择 **Kimi 模型**(默认模型)以及一个或多个 **可用模型**。
|
||||
4. 仅当需要非默认的 Kimi 可执行文件、源 home 或自定义请求头时,再添加环境变量。
|
||||
4. 高级环境设置通常留空,只有本机环境有特殊要求时才填写。
|
||||
5. **保存**,然后复制并运行配置卡片上的命令。
|
||||
|
||||
## 配置项详解
|
||||
|
|
@ -43,39 +33,36 @@ Kimi CLI 固定为 **仅从 CCR 打开时生效** 和 **仅 CLI**,这两项不
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **Kimi CLI** | 让 CCR 生成 Kimi home、`config.toml` 与包装器。 |
|
||||
| 配置名称 | 自由文本,例如 `Kimi - Work` | 标识该配置,并用于 `ccr-app "<名称>"`。 |
|
||||
| Agent | 选择 **Kimi CLI** | 在 CCR 中创建 Kimi CLI 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `Kimi - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>"`,CLI 命令使用 `ccr "<名称>"`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| Kimi 模型 | 供应商模型或 Fusion 模型 | 默认模型(`default_model`),至少需要一个。 |
|
||||
| 可用模型 | 一个或多个供应商/Fusion 模型 | 每个都会成为 Kimi `/model` 暴露的 `[models.<模型>]` 条目;默认模型始终包含在内。 |
|
||||
| 环境变量 | 键值对 | 导出到包装器,见下文。 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CCR_KIMI_BIN` / `KIMI_BIN`:当 `PATH` 中找不到 `kimi` 时,指定真实可执行文件的绝对路径。
|
||||
- `CCR_KIMI_SOURCE_HOME` / `KIMI_CODE_HOME`:设置 CCR 复用会话/技能/插件/凭据的 **源** Kimi home(默认 `~/.kimi-code`)。
|
||||
- `KIMI_CODE_CUSTOM_HEADERS`:换行分隔的 `Key: Value`,作为该配置每次 CCR 请求的自定义请求头。
|
||||
- CCR 还会自动把网关主机加入 `NO_PROXY`/`no_proxy`,无需手动配置。
|
||||
|
||||
> CCR 自行管理的变量(`KIMI_CODE_HOME` 及单模型覆盖变量)均为保留项——手动设置无效,因为包装器会设置或清除它们。
|
||||
| Kimi 模型 | 供应商模型或 Fusion 模型 | 默认模型,至少需要一个。 |
|
||||
| 可用模型 | 一个或多个供应商/Fusion 模型 | Kimi `/model` 菜单里可切换的模型。默认模型始终包含在内。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
|
||||
## 打开与使用
|
||||
|
||||
点击配置卡片上的终端按钮,运行复制出的命令:
|
||||
桌面端配置卡片复制出的命令:
|
||||
|
||||
```text
|
||||
ccr-app "Kimi - Work"
|
||||
```
|
||||
|
||||
CLI请执行:
|
||||
|
||||
```text
|
||||
ccr "Kimi - Work"
|
||||
```
|
||||
|
||||
进入 Kimi CLI 后,使用 `/model` 在默认模型与可用模型之间切换。每一次选择都仍经过 CCR 的供应商、路由与 Fusion。
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id` 和独立的 `KIMI_CODE_HOME`,因此多个 Kimi CLI 配置可同时运行,使用不同的默认模型或模型集合,且不会改动原有的 Kimi 配置。
|
||||
需要不同默认模型或模型集合时,创建多个 Kimi CLI 配置即可。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 运行从配置卡片复制的 `ccr-app` 命令。
|
||||
1. 运行桌面端配置卡片复制的 `ccr-app` 命令,或 CLI 的 `ccr` 命令。
|
||||
2. 在 Kimi CLI 中发送一条消息,确认能正常回复。
|
||||
3. 打开 CCR 的 **请求日志**,确认请求经过了网关。
|
||||
4. 运行 `/model`,确认默认模型与可用模型均出现。
|
||||
|
|
@ -84,5 +71,4 @@ ccr-app "Kimi - Work"
|
|||
|
||||
- **`/model` 为空或缺模型**:至少添加一个 **可用模型**(默认模型计入);确认 CCR 中已配置供应商与模型。
|
||||
- **配置无法保存**:Kimi CLI 要求同时有默认模型和至少一个可用模型。
|
||||
- **找不到 `kimi`**:用 `CCR_KIMI_BIN` 指定真实可执行文件路径。
|
||||
- **会话/凭据未被复用**:若你的 Kimi home 不是 `~/.kimi-code`,请用 `CCR_KIMI_SOURCE_HOME`(或 `KIMI_CODE_HOME`)指定正确的源 home。
|
||||
- **找不到 `kimi`**:确认 Kimi CLI 已安装,并且启动 CCR Desktop 的同一 shell 环境可以找到它。
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
title: OpenCode 接入与配置
|
||||
pageTitle: OpenCode
|
||||
eyebrow: 详细配置
|
||||
lead: "把 OpenCode(CLI 与 App)接入 CCR。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 OpenCode(CLI 与 App)接入 CCR。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
OpenCode 是一款采用 OpenAI 兼容供应商模型的编码 Agent。CCR 同时支持两种形态:
|
||||
|
||||
- **OpenCode CLI**:终端 Agent。CCR 在 OpenCode 的 JSONC 配置中写入受管网关供应商,并通过包装器打开 CLI。
|
||||
- **OpenCode App**:桌面应用。CCR 用相同的有效配置启动已安装的 OpenCode Desktop 可执行文件。
|
||||
- **OpenCode CLI**:终端 Agent。
|
||||
- **OpenCode App**:桌面应用。
|
||||
|
||||
当你想让 OpenCode 走任意 CCR 供应商或 Fusion 模型,或给 App 挂载 IM 机器人时,使用本页。
|
||||
|
||||
|
|
@ -22,22 +22,13 @@ OpenCode 是一款采用 OpenAI 兼容供应商模型的编码 Agent。CCR 同
|
|||
2. 已安装 OpenCode CLI(`PATH` 中可用 `opencode`);如需 App 模式,已安装 OpenCode Desktop 应用。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 OpenCode
|
||||
|
||||
保存一个 OpenCode 配置时,CCR 会:
|
||||
|
||||
- 在 OpenCode 的 **配置文件**(`opencode.jsonc`)中写入受管网关供应商,把 OpenCode 指向 CCR 网关,并使用该配置的 Token 与所选模型。
|
||||
- 生成启动 **包装器**,把 `OPENCODE_CONFIG`(以及内联的 `OPENCODE_CONFIG_CONTENT`)指向受管配置,设置 `OPENCODE_CLIENT=cli`,再启动 OpenCode。
|
||||
|
||||
选择 **仅从 CCR 打开时生效** 时,配置落在按配置 `id` 隔离的 CCR 受管目录中。选择 **系统默认** 时,CCR 写入真实的 `~/.config/opencode/opencode.jsonc`。App 则用相同的有效配置启动已安装的 OpenCode Desktop 可执行文件。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **OpenCode**。
|
||||
2. 填写 **配置名称**(例如 `OpenCode - Work`)。
|
||||
3. 选择 **生效范围** 与 **入口模式**。
|
||||
4. 确认 **供应商 ID**、**供应商名称** 与 **OpenCode 模型**。
|
||||
5. 按需调整 **配置文件** 与环境变量。
|
||||
5. 只有本机环境有特殊要求时,再调整高级设置。
|
||||
6. 若入口模式包含 App 且需要使用 AgentClaw,绑定一个 **机器人**。
|
||||
7. **保存**,然后从 CCR 打开 OpenCode(终端按钮打开 CLI,播放按钮打开 App)。
|
||||
|
||||
|
|
@ -45,42 +36,37 @@ OpenCode 是一款采用 OpenAI 兼容供应商模型的编码 Agent。CCR 同
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **OpenCode** | 让 CCR 写入 OpenCode 网关配置与包装器。 |
|
||||
| 配置名称 | 自由文本,例如 `OpenCode - Work` | 标识该配置,并用于 `ccr-app "<名称>"`。 |
|
||||
| Agent | 选择 **OpenCode** | 在 CCR 中创建 OpenCode 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `OpenCode - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>"`,CLI 命令使用 `ccr "<名称>"`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 隔离的 CCR 受管配置 vs. 真实的 `~/.config/opencode/opencode.jsonc`。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 仅影响从 CCR 打开的 OpenCode,或作为系统默认 OpenCode 配置。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 入口模式 | `CLI 与 APP` / `仅 CLI` / `仅 App` | 提供哪些启动入口(终端命令和/或 App)。 |
|
||||
| 供应商 ID | 默认 `claude-code-router` | CCR 写入 OpenCode 配置的供应商引用。 |
|
||||
| 供应商 ID | 默认 `claude-code-router` | 当前 OpenCode 配置使用的供应商引用。 |
|
||||
| 供应商名称 | 自由文本;默认 `Claude Code Router` | 在 OpenCode 中显示的名称。 |
|
||||
| OpenCode 模型 | 供应商模型或 Fusion 模型 | OpenCode 通过 CCR 使用的默认模型。 |
|
||||
| 配置文件 | 路径,默认 `~/.config/opencode/opencode.jsonc` | 在“系统默认”范围下使用;“仅从 CCR 打开”写入 CCR 受管目录。 |
|
||||
| 环境变量 | 键值对 | 注入 OpenCode CLI、App 及其机器人工作进程,见下文。 |
|
||||
| 配置文件 | 路径 | 用于系统默认 OpenCode 配置。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
| 机器人 | 选择已保存的机器人(仅 App 入口) | 把一个 AgentClaw IM 机器人绑定到 OpenCode App 入口。 |
|
||||
|
||||
> OpenCode 不暴露 **显示全部会话**,该项被强制关闭。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CCR_OPENCODE_BIN` / `OPENCODE_BIN`:当 CCR Desktop 进程的 `PATH` 中找不到 `opencode` 时,指定真实可执行文件的绝对路径。
|
||||
- `CCR_OPENCODE_BOT_CWD`:机器人工作进程运行 OpenCode 的工作目录(通过 `opencode run --dir` 传入)。使用非默认工作区时,把它设为 App 当前打开的同一项目目录;否则机器人默认使用新工作区的文件系统根目录。
|
||||
- `CCR_OPENCODE_BOT_AUTO_APPROVE=true`:为机器人工作进程启用 OpenCode 危险的 `--auto` 模式。仅在可信环境中使用。
|
||||
- CCR 自行管理的变量(`OPENCODE_CONFIG`、`OPENCODE_CONFIG_CONTENT`、`OPENCODE_CLIENT`、`CCR_PROFILE_SURFACE`)均为保留项——手动设置无效。
|
||||
|
||||
## 打开与使用
|
||||
|
||||
- **CLI**:点击终端按钮,运行复制出的命令:
|
||||
- **CLI**:在桌面端点击终端按钮,运行复制出的命令:
|
||||
```text
|
||||
ccr-app "OpenCode - Work"
|
||||
```
|
||||
- **App**:点击播放按钮,用该配置打开 OpenCode Desktop。OpenCode Desktop 为单实例,因此切换 OpenCode 配置时 CCR 会先停止受管实例。
|
||||
CLI请执行:
|
||||
```text
|
||||
ccr "OpenCode - Work"
|
||||
```
|
||||
- **App**:点击播放按钮,用该配置打开 OpenCode Desktop。OpenCode Desktop 为单实例,因此请从 CCR 切换配置。
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id`。选择 **仅从 CCR 打开时生效** 时,OpenCode 会获得隔离的配置文件与包装器。注意 App 本身是单实例,同一时间只能运行一个 OpenCode App;请从 CCR 切换配置,而不是再启动一个 App。
|
||||
需要不同模型或供应商时,创建多个 OpenCode 配置即可。App 本身是单实例,请从 CCR 切换配置,而不是再启动一个 App。
|
||||
|
||||
## AgentClaw(机器人)
|
||||
|
||||
绑定机器人并从 CCR 打开 App 后,CCR 会在 App 旁启动伴随工作进程,通过 OpenCode CLI 运行进入的机器人消息,并在同一会话中回复。用 `/project list|current|use` 选择项目,再用 `/session list|current|new|use|reset` 管理会话。仅这两个命令域会被拦截。受管 App 退出或切换配置时,该工作进程停止。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
绑定机器人并从 CCR 打开 App 后,OpenCode 可以把会话接入所选 IM 通道。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
|
||||
## 验证
|
||||
|
||||
|
|
@ -91,6 +77,5 @@ OpenCode 是一款采用 OpenAI 兼容供应商模型的编码 Agent。CCR 同
|
|||
## 常见问题
|
||||
|
||||
- **请求绕过了 CCR**:确认配置已 **启用**,且你是从 CCR 打开 OpenCode;除非范围是 **系统默认**,否则直接打开的 OpenCode 不受影响。
|
||||
- **找不到 `opencode`**:用 `CCR_OPENCODE_BIN` 指定真实可执行文件路径。
|
||||
- **机器人在错误目录运行**:把 `CCR_OPENCODE_BOT_CWD` 设为 App 中打开的项目目录。
|
||||
- **App 没有切换配置**:OpenCode Desktop 为单实例——请从 CCR 切换配置,CCR 会先停止上一个受管实例。
|
||||
- **找不到 `opencode`**:确认 OpenCode CLI 已安装,并且启动 CCR Desktop 的同一 shell 环境可以找到它。
|
||||
- **App 没有切换配置**:OpenCode Desktop 为单实例,请从 CCR 切换配置。
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
title: Pi 接入与配置
|
||||
pageTitle: Pi
|
||||
eyebrow: 详细配置
|
||||
lead: "把 Pi 接入 CCR。Pi 仅支持 CLI,且始终限定为从 CCR 打开的会话。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 Pi 接入 CCR。Pi 仅支持 CLI,且始终限定为从 CCR 打开的会话。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
Pi 是一款通过命令行指定供应商与模型的编码 Agent。在 CCR 中它 **仅支持 CLI**,且始终使用 **仅从 CCR 打开时生效**:CCR 生成启动包装器,让 Pi 走 CCR 网关,并使用你选择的供应商与模型。
|
||||
Pi 是一款通过命令行指定供应商与模型的编码 Agent。在 CCR 中它 **仅支持 CLI**,且始终使用 **仅从 CCR 打开时生效**。
|
||||
|
||||
当你想让 Pi 走任意 CCR 供应商或 Fusion 模型,或运行相互隔离的 Pi 会话时,使用本页。
|
||||
当你想让 Pi 走任意 CCR 供应商或 Fusion 模型,或运行独立的 Pi 会话时,使用本页。
|
||||
|
||||
> 第一次使用 CCR?请先接入供应商和模型。参见[接入供应商](/guides/provider/)与 [Agent 配置总览](/configuration/profiles/)。
|
||||
|
||||
|
|
@ -19,21 +19,12 @@ Pi 是一款通过命令行指定供应商与模型的编码 Agent。在 CCR 中
|
|||
2. 已安装 Pi(`PATH` 中可用 `pi`)。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 Pi
|
||||
|
||||
保存一个 Pi 配置时,CCR 会:
|
||||
|
||||
- 在配置专属的 Pi agent 目录(`PI_CODING_AGENT_DIR`,默认位于 `~/.pi/agent` 下)写入受管网关配置。
|
||||
- 生成启动 **包装器**,设置 `PI_CODING_AGENT_DIR` 与独立的会话目录,关闭 Pi 版本检查,再以 `pi --provider <供应商> --model <模型>` 走 CCR 网关。
|
||||
|
||||
启动时若 CCR Desktop 网关未运行,`ccr-app` 会启动一个可共享的临时网关服务,并在最后一个并发 Pi 会话退出后停止它。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **Pi**。
|
||||
2. 填写 **配置名称**(例如 `Pi - Work`)。
|
||||
3. 选择 **模型**。
|
||||
4. 仅当需要非默认的 Pi 可执行文件或想恢复版本检查时,再添加环境变量。
|
||||
4. 高级环境设置通常留空,只有本机环境有特殊要求时才填写。
|
||||
5. **保存**,然后复制并运行配置卡片上的命令。
|
||||
|
||||
## 配置项详解
|
||||
|
|
@ -42,41 +33,38 @@ Pi 固定为 **仅从 CCR 打开时生效** 和 **仅 CLI**,这两项不可编
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **Pi** | 让 CCR 生成 Pi agent 目录、网关配置与包装器。 |
|
||||
| 配置名称 | 自由文本,例如 `Pi - Work` | 标识该配置,并用于 `ccr-app "<名称>"`。 |
|
||||
| Agent | 选择 **Pi** | 在 CCR 中创建 Pi 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `Pi - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>"`,CLI 命令使用 `ccr "<名称>"`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 模型 | 供应商模型或 Fusion 模型 | 作为 `--model` 传给 Pi,即 Pi 每一轮使用的模型。 |
|
||||
| 环境变量 | 键值对 | 导出到包装器,见下文。 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CCR_PI_BIN` / `PI_BIN`:当 CCR Desktop 进程的 `PATH` 中找不到 `pi` 时,指定真实可执行文件的绝对路径。
|
||||
- `PI_SKIP_VERSION_CHECK`:默认为 `1`(跳过版本检查),使包装器干净启动。仅当有意启用检查时才改为其他值。
|
||||
- CCR 还会自动把网关主机加入 `NO_PROXY`/`no_proxy`,让 Pi 直连本地网关,无需手动配置。
|
||||
|
||||
> CCR 自行管理的变量(`PI_CODING_AGENT_DIR`、`PI_CODING_AGENT_SESSION_DIR`、`CCR_PROFILE_SURFACE`)均为保留项——手动设置无效。
|
||||
| 模型 | 供应商模型或 Fusion 模型 | Pi 每一轮使用的模型。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
|
||||
## 打开与使用
|
||||
|
||||
点击配置卡片上的终端按钮,运行复制出的命令:
|
||||
桌面端配置卡片复制出的命令:
|
||||
|
||||
```text
|
||||
ccr-app "Pi - Work"
|
||||
```
|
||||
|
||||
CLI请执行:
|
||||
|
||||
```text
|
||||
ccr "Pi - Work"
|
||||
```
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id`,以及独立的 `PI_CODING_AGENT_DIR` 和会话目录,因此多个 Pi 配置可同时运行,使用不同的模型或彼此独立的会话历史。
|
||||
需要不同模型时,创建多个 Pi 配置即可。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 运行从配置卡片复制的 `ccr-app` 命令。
|
||||
1. 运行桌面端配置卡片复制的 `ccr-app` 命令,或 CLI 的 `ccr` 命令。
|
||||
2. 在 Pi 中发送一条消息,确认能正常回复。
|
||||
3. 打开 CCR 的 **请求日志**,确认请求经过了网关。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **Pi 用了非预期的模型**:检查 **模型** 字段;包装器始终把它作为 `--model` 传入。
|
||||
- **找不到 `pi`**:用 `CCR_PI_BIN` 指定真实可执行文件路径。
|
||||
- **启动时出现版本检查提示**:正常情况下会被跳过;若出现,请将 `PI_SKIP_VERSION_CHECK` 保留为默认值。
|
||||
- **Pi 用了非预期的模型**:检查 **模型** 字段。
|
||||
- **找不到 `pi`**:确认 Pi 已安装,并且启动 CCR Desktop 的同一 shell 环境可以找到它。
|
||||
- **`/model` 或模型列表为空**:Pi 不使用 `/model` 列表——请在配置上直接设置所需的 **模型**。
|
||||
|
|
|
|||
89
docs/src/content/docs/zh/configuration/agents/workbuddy.md
Normal file
89
docs/src/content/docs/zh/configuration/agents/workbuddy.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
title: WorkBuddy 接入与配置
|
||||
pageTitle: WorkBuddy
|
||||
eyebrow: 详细配置
|
||||
lead: "把 WorkBuddy 接入 CCR。WorkBuddy 在 CCR 中仅支持 App。"
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. CCR Desktop 正在运行,且已配置至少一个供应商与模型。
|
||||
2. 本机已安装 WorkBuddy。如果 CCR 无法自动找到它,请在 **APP_PATH** 中填写 WorkBuddy 可执行文件路径。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **WorkBuddy**。
|
||||
2. 填写 **配置名称**(例如 `WorkBuddy - Work`)。
|
||||
3. 确认 **供应商 ID**、**供应商名称** 与 **WorkBuddy 模型**。
|
||||
4. 按需限制 **允许模型列表**。留空表示 WorkBuddy 可使用全部 CCR 模型。
|
||||
5. 只有自动探测不到 WorkBuddy 时才填写 **APP_PATH**。
|
||||
6. 如需使用 AgentClaw,绑定一个 **机器人**。
|
||||
7. **保存**,然后用播放按钮从 CCR 打开 WorkBuddy。
|
||||
|
||||
## 配置项详解
|
||||
|
||||
WorkBuddy 固定为 **仅 App**,入口模式不可编辑。可配置的字段为:
|
||||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **WorkBuddy** | 在 CCR 中创建 WorkBuddy App 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `WorkBuddy - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>" app`,CLI 命令使用 `ccr "<名称>" app`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 仅影响从 CCR 打开的 WorkBuddy,或作为系统默认 WorkBuddy 配置。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 供应商 ID | 默认 `claude-code-router` | 当前 WorkBuddy 配置使用的供应商引用。 |
|
||||
| 供应商名称 | 自由文本;默认 `Claude Code Router` | 生成配置中使用的显示名称。 |
|
||||
| WorkBuddy 模型 | 供应商模型或 Fusion 模型 | WorkBuddy App 打开时的默认模型。 |
|
||||
| 允许模型列表 | 可选多选 | 控制 WorkBuddy 中可见的 CCR 模型。留空表示全部模型。 |
|
||||
| APP_PATH | 可选可执行文件路径 | 覆盖 App 自动探测。WorkBuddy 安装在自定义位置时使用。 |
|
||||
| 配置文件 | 路径 | 用于系统默认 WorkBuddy 配置。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
| 机器人 | 选择已保存的机器人 | 把一个 AgentClaw IM 机器人绑定到 WorkBuddy App 入口。 |
|
||||
|
||||
## 模型列表行为
|
||||
|
||||
WorkBuddy 的模型设置会跟随 **允许模型列表**:
|
||||
|
||||
- **允许模型列表** 有选择时,只写入这些模型。
|
||||
- **允许模型列表** 留空时,WorkBuddy 可使用全部 CCR 模型。
|
||||
- 当前选择的 **WorkBuddy 模型** 会作为默认模型。
|
||||
|
||||
修改供应商或模型列表后,请从 CCR 重新打开 WorkBuddy。如果 WorkBuddy 的设置窗口已经打开,请关闭并重新打开该窗口刷新列表。
|
||||
|
||||
## 打开与使用
|
||||
|
||||
点击配置卡片上的播放按钮,用该配置的模型、供应商和模型列表打开 WorkBuddy。再次打开同一配置会激活已有窗口。
|
||||
|
||||
桌面端配置卡片复制出的 App 命令:
|
||||
|
||||
```text
|
||||
ccr-app "WorkBuddy - Work" app
|
||||
```
|
||||
|
||||
CLI请执行:
|
||||
|
||||
```text
|
||||
ccr "WorkBuddy - Work" app
|
||||
```
|
||||
|
||||
## 多实例
|
||||
|
||||
需要不同模型列表或供应商时,创建多个 WorkBuddy 配置即可。
|
||||
|
||||
## AgentClaw(机器人)
|
||||
|
||||
绑定机器人并从 CCR 打开 WorkBuddy 后,WorkBuddy 可以把会话接入所选 IM 通道。关闭 WorkBuddy 会让中继下线。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 从 CCR 打开 WorkBuddy。
|
||||
2. 打开 WorkBuddy 的 **Models** 设置,确认模型列表符合预期。
|
||||
3. 发送一条消息,确认能正常回复。
|
||||
4. 打开 CCR 的 **请求日志**,确认请求经过了网关。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **只显示一个模型**:重新应用 WorkBuddy 配置,并从 CCR 重新打开 WorkBuddy。**允许模型列表** 留空表示 WorkBuddy 可使用全部 CCR 模型。
|
||||
- **请求绕过了 CCR**:确认配置已 **启用**,且你是从 CCR 打开 WorkBuddy;若已在运行,请从 CCR 重新打开。
|
||||
- **App 中模型不对**:检查 **WorkBuddy 模型** 字段,以及 WorkBuddy 设置里的模型列表。
|
||||
- **找不到 WorkBuddy**:在 **APP_PATH** 中填写 WorkBuddy 可执行文件路径。
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
title: ZCode 接入与配置
|
||||
pageTitle: ZCode
|
||||
eyebrow: 详细配置
|
||||
lead: "把 ZCode 接入 CCR。ZCode 在 CCR 中仅支持 App。本页逐一说明 Agent 配置中的每一个字段:怎么配置,配置后有什么效果。"
|
||||
lead: "把 ZCode 接入 CCR。ZCode 在 CCR 中仅支持 App。"
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
ZCode 是一款以桌面应用形态运行的编码 Agent。在 CCR 中它 **仅支持 App**:CCR 写入 ZCode 的 CLI 配置、v2 配置与模型缓存,然后用该配置的模型、供应商和独立的用户数据目录启动 App。
|
||||
ZCode 是一款以桌面应用形态运行的编码 Agent。在 CCR 中它 **仅支持 App**。
|
||||
|
||||
当你想让 ZCode 走任意 CCR 供应商或 Fusion 模型,或挂载 IM 机器人时,使用本页。
|
||||
|
||||
|
|
@ -19,20 +19,12 @@ ZCode 是一款以桌面应用形态运行的编码 Agent。在 CCR 中它 **仅
|
|||
2. 本机已安装并登录 ZCode 桌面应用。
|
||||
3. 进入 **Agent 配置**,点击 **添加配置**。
|
||||
|
||||
## CCR 如何接入 ZCode
|
||||
|
||||
保存一个 ZCode 配置时,CCR 会:
|
||||
|
||||
- 写入 ZCode 的 **CLI 配置**(默认 `~/.zcode/cli/config.json`),以及 ZCode v2 配置和模型缓存,基于 ZCode home 或你指定的自定义配置文件。
|
||||
- 生成 **中间件启动器**,设置 `ZCODE_HOME`/`ZCODE_STORAGE_DIR`、供应商、模型与模型目录,再交给 App。
|
||||
- 用当前配置的模型、供应商和独立的用户数据目录启动 App。
|
||||
|
||||
## 创建配置
|
||||
|
||||
1. 在 **Agent 配置** 点击 **添加配置**,选择 **ZCode**。
|
||||
2. 填写 **配置名称**(例如 `ZCode - Work`)。
|
||||
3. 确认 **供应商 ID**、**供应商名称** 与 **ZCode 模型**。
|
||||
4. 按需调整 **配置文件** 与环境变量。
|
||||
4. 只有本机环境有特殊要求时,再调整高级设置。
|
||||
5. 如需使用 AgentClaw,绑定一个 **机器人**。
|
||||
6. **保存**,然后用播放按钮从 CCR 打开 ZCode。
|
||||
|
||||
|
|
@ -42,41 +34,40 @@ ZCode 固定为 **仅 App**,入口模式不可编辑。可配置的字段为
|
|||
|
||||
| 字段 | 如何配置 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 选择 **ZCode** | 让 CCR 写入 ZCode 配置并启动 App。 |
|
||||
| 配置名称 | 自由文本,例如 `ZCode - Work` | 标识该配置,并用于 `ccr-app "<名称>" app`。 |
|
||||
| Agent | 选择 **ZCode** | 在 CCR 中创建 ZCode App 启动入口。 |
|
||||
| 配置名称 | 自由文本,例如 `ZCode - Work` | 标识该配置。桌面端命令使用 `ccr-app "<名称>" app`,CLI 命令使用 `ccr "<名称>" app`。 |
|
||||
| 启用 | 开关 | 关闭的配置不会被应用,也不会出现在启动入口。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 隔离的 CCR 受管配置 vs. 真实的 ZCode home 配置。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 供应商 ID | 默认 `claude-code-router` | CCR 写入 ZCode 配置的供应商引用。 |
|
||||
| 生效范围 | `仅从 CCR 打开时生效` / `系统默认` | 仅影响从 CCR 打开的 ZCode,或作为系统默认 ZCode 配置。同一 Agent 只允许一个启用的系统默认配置。 |
|
||||
| 供应商 ID | 默认 `claude-code-router` | 当前 ZCode 配置使用的供应商引用。 |
|
||||
| 供应商名称 | 自由文本;默认 `Claude Code Router` | 在 ZCode 中显示的名称。 |
|
||||
| ZCode 模型 | 供应商模型或 Fusion 模型 | ZCode App 打开时的默认模型。 |
|
||||
| 配置文件 | 路径,默认 `~/.zcode/cli/config.json` | CCR 还会同时写入 ZCode v2 配置与模型缓存。 |
|
||||
| 环境变量 | 键值对 | 注入 ZCode App 与中间件启动器。 |
|
||||
| 配置文件 | 路径 | 用于系统默认 ZCode 配置。 |
|
||||
| 环境变量 | 键值对 | 可选高级设置;普通使用保持为空。 |
|
||||
| 机器人 | 选择已保存的机器人 | 把一个 AgentClaw IM 机器人绑定到 ZCode App 入口。 |
|
||||
|
||||
> ZCode 不暴露 **显示全部会话** 与 **CCR 托管压缩**。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- 任意键值对都会注入 ZCode App 与中间件启动器。
|
||||
- CCR 自行管理的变量(`ZCODE_HOME`、`ZCODE_STORAGE_DIR`、各 `CCR_ZCODE_*` 与 `CODEXL_ZCODE_*` 变量、`CCR_PROFILE_SURFACE`)均为保留项——手动设置无效。
|
||||
|
||||
## 打开与使用
|
||||
|
||||
点击配置卡片上的播放按钮,用该配置的模型、供应商和独立用户数据目录打开 ZCode。再次打开同一配置会激活已有窗口。
|
||||
点击配置卡片上的播放按钮,用该配置的模型和供应商打开 ZCode。再次打开同一配置会激活已有窗口。
|
||||
|
||||
你也可以从卡片复制 App 命令:
|
||||
桌面端配置卡片复制出的 App 命令:
|
||||
|
||||
```text
|
||||
ccr-app "ZCode - Work" app
|
||||
```
|
||||
|
||||
CLI请执行:
|
||||
|
||||
```text
|
||||
ccr "ZCode - Work" app
|
||||
```
|
||||
|
||||
## 多实例
|
||||
|
||||
每个配置都有独立的 `id` 和独立的用户数据目录,因此多个 ZCode 配置可同时运行,使用不同的模型或供应商。
|
||||
需要不同模型或供应商时,创建多个 ZCode 配置即可。
|
||||
|
||||
## AgentClaw(机器人)
|
||||
|
||||
绑定机器人并从 CCR 打开 ZCode 后,Codex 兼容的伴随工作进程会暴露原生会话发现、项目/会话浏览与续接、排队、取消、模型设置、用量、附件与诊断。关闭 ZCode App 会立即让中继下线。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
绑定机器人并从 CCR 打开 ZCode 后,ZCode 可以把会话接入所选 IM 通道。关闭 ZCode App 会立即让中继下线。完整 IM 设置见 [AgentClaw](/agentclaw/)。
|
||||
|
||||
## 验证
|
||||
|
||||
|
|
|
|||
|
|
@ -33,5 +33,5 @@ lead: 本页是日志与观测相关开关和面板能力的配置参考:在
|
|||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `requestLogBodyCapture` | `all` | 控制是否记录请求体和响应体:`all` 记录全部,`errors` 只记录失败请求的 body,`none` 不记录任何 body。 |
|
||||
| `requestLogMaxBodyBytes` | `52428800`(50 MiB) | 单个请求体或响应体的最大字节数,超出部分会被截断;硬上限同样是 50 MiB。 |
|
||||
| `requestLogMaxBodyBytes` | `52428800`(50 MiB) | 单个请求体或响应体的内存捕获与预览预算。基于文件的 raw trace body 可以超过该限制;完整 body 会保存到 sidecar 存储,并在日志页按需加载。 |
|
||||
| `requestLogSuccessSampleRate` | `1` | 成功请求的采样率,取 `0` 到 `1` 之间,`1` 表示全部记录,`0.1` 表示记录约十分之一。 |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: Agent 配置
|
||||
pageTitle: Agent 配置
|
||||
eyebrow: 详细配置
|
||||
lead: 为 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI 和 ZCode 创建可复用的启动配置(Agent 配置),并用不同配置打开相互独立的 Agent 实例。需要多开 Agent 或为不同用途固定不同模型时使用本页。
|
||||
lead: 为 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI、ZCode 和 WorkBuddy 创建可复用的启动配置(Agent 配置),并用不同配置打开相互独立的 Agent 实例。需要多开 Agent 或为不同用途固定不同模型时使用本页。
|
||||
---
|
||||
|
||||
> 若需要某个 Agent 的逐字段详细接入指南,可在侧边栏 **Agent 配置** 下打开对应页面,例如 [Claude Code](/configuration/agents/claude-code/)、[Codex](/configuration/agents/codex/) 或 [Grok CLI](/configuration/agents/grok/)。
|
||||
|
|
@ -16,34 +16,25 @@ lead: 为 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI 和 ZCode 创建
|
|||
5. 如果入口模式包含 App,可以绑定 AgentClaw 使用的 Bot,并选择是否转发 Agent 消息或开启接力。
|
||||
6. 保存后,从 Agent 配置卡片打开:终端图标会复制 CLI 命令,播放图标会启动 App 实例。
|
||||
|
||||
试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex、Grok CLI、Kimi CLI 或 ZCode。
|
||||
试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 或 WorkBuddy。
|
||||
|
||||
## 多开机制
|
||||
## 多开
|
||||
|
||||
每个 Agent 配置都有自己的 `id` 和名称。CCR 打开 Agent 时会按名称或 `id` 找到启用的配置,再根据配置生成对应的启动计划。
|
||||
|
||||
| 机制 | 实际行为 |
|
||||
| --- | --- |
|
||||
| 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 |
|
||||
| 独立启动器 | Claude Code、Grok CLI 和 Kimi CLI 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 |
|
||||
| 独立 App 数据目录 | 从 App 打开时,Claude App、ChatGPT(Codex 桌面端的新名称)、ZCode App 都会使用按配置 `id` 区分的用户数据目录 |
|
||||
| 运行状态 | CCR 按打开入口和配置 `id` 记录运行中的 App 实例;同一个配置再次打开会激活已有窗口,不同配置可以打开不同实例 |
|
||||
|
||||
这意味着你可以为同一个 Agent 建多个配置,例如“Claude Code - 工作项目”“Claude Code - 测试模型”“Codex - Fusion 图像能力”。它们可以选择不同模型、不同作用范围和不同 Bot,打开后就是不同的 Agent 实例。
|
||||
每个 Agent 配置都是一个独立启动配置。需要为同一个 Agent 使用不同模型、作用范围、Bot 或桌面窗口时,创建多个配置即可。某些 Agent 只支持单个 App 窗口,重复打开同一配置时会激活已有窗口。
|
||||
|
||||
## 常用选项
|
||||
|
||||
| 选项 | 适用范围 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Agent | 全部 | 选择 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI 或 ZCode。Grok CLI 和 Kimi CLI 只支持 CLI,ZCode 只支持 App。 |
|
||||
| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr-app <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 |
|
||||
| Agent | 全部 | 选择 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI、ZCode 或 WorkBuddy。Grok CLI 和 Kimi CLI 只支持 CLI,ZCode 和 WorkBuddy 只支持 App。 |
|
||||
| 配置名称 | 全部 | 用于在 CCR 中识别配置。桌面端命令使用 `ccr-app <配置名称>`,CLI 命令使用 `ccr <配置名称>`。名称可以有空格,复制命令时 CCR 会自动加引号。 |
|
||||
| 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 |
|
||||
| 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 |
|
||||
| 入口模式 | Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI 和 Kimi CLI 固定为 `CLI only`。 |
|
||||
| 作用范围 | 全部 | **仅从 CCR 打开时生效** 只影响从 CCR 打开的 Agent;**系统默认** 也会影响你直接打开的 Agent。同一个 Agent 同时只能有一个启用的系统默认配置。 |
|
||||
| 入口模式 | Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI 和 Kimi CLI 固定为 `CLI only`;ZCode 和 WorkBuddy 固定为 `App only`。 |
|
||||
| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 留空表示保留 Claude Code 默认模型。 |
|
||||
| 可用模型 | Kimi CLI | Kimi `/model` 命令中可切换的模型;默认模型始终包含在内。 |
|
||||
| Bot | App 入口 | AgentClaw 的 IM 接入口。只有从 CCR 打开的 App 模式会转发 Bot 消息;CLI 当前不转发 Bot 消息。详细步骤见 [AgentClaw](/agentclaw/)。 |
|
||||
| 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 |
|
||||
| 环境变量 | 全部 | 可选高级设置;普通使用保持为空。 |
|
||||
|
||||
## 各 Agent 的配置项
|
||||
|
||||
|
|
@ -51,82 +42,91 @@ lead: 为 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI 和 ZCode 创建
|
|||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| 模型覆盖 | 写入 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 和启动包装器。 |
|
||||
| 模型覆盖 | 当前配置的默认模型。留空时保留 Claude Code 自己的默认模型。 |
|
||||
| 小模型 | Claude Code 轻量任务使用的可选模型。留空时保留 Claude Code 默认值。 |
|
||||
| 设置文件 | 用于系统默认 Claude Code 配置。 |
|
||||
| 环境变量 | 可选高级设置;普通使用保持为空。 |
|
||||
| Bot | 只在 Claude App 入口生效,可选择已保存 Bot,并配置 AgentClaw 的消息转发或接力。 |
|
||||
|
||||
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 已经打开,按提示重启或从 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 可识别的模型项,并用显示名称保留真实模型含义。 |
|
||||
| Claude App | CCR 模型列表 | 从 CCR 打开 Claude App 后使用当前配置的模型列表。 |
|
||||
|
||||
### OpenCode
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Provider ID | 写入 OpenCode 的供应商引用,默认是 `claude-code-router`。 |
|
||||
| Provider ID | 当前 OpenCode 配置使用的供应商引用,默认是 `claude-code-router`。 |
|
||||
| Provider name | OpenCode 中展示的供应商名称,默认是 `Claude Code Router`。 |
|
||||
| OpenCode model | OpenCode CLI 和 App 的默认模型,可以选择普通供应商模型或 Fusion 模型。 |
|
||||
| 配置文件 | 系统默认模式使用 OpenCode 默认配置;仅从 CCR 打开时生效会在 CCR 配置目录下写入配置专属文件。 |
|
||||
| 环境变量 | 注入 OpenCode CLI、OpenCode App 以及对应的 Bot worker。 |
|
||||
| Bot | 在从 CCR 打开的 OpenCode App 入口生效。收到 Bot 消息后会通过 OpenCode CLI 执行,并把回复发回同一个 Bot 会话。 |
|
||||
| 配置文件 | 用于系统默认 OpenCode 配置。 |
|
||||
| 环境变量 | 可选高级设置;普通使用保持为空。 |
|
||||
| Bot | 在从 CCR 打开的 OpenCode App 入口生效。 |
|
||||
|
||||
CCR 会为 OpenCode App 启动一个配套 Bot worker,并为每个 Bot conversation 分别保存 Project 与可选 Session。先用 `/project list|current|use` 选择 Agent Project,再用 `/session list|current|new|use|reset` 管理该 Project 下的 Agent Session。切换 Project 会清除原 Session,其他 Project 的 Session 无法被选中。Bot 只拦截这两个 slash 命令域;已移除的 `/task` 和旧平铺命令不再兼容。
|
||||
|
||||
CCR Desktop 进程环境中必须能够执行 `opencode`。如果 CLI 安装在其他位置,可以在 Agent 配置环境变量中设置 `CCR_OPENCODE_BIN`。Bot session 默认使用全新 OpenCode Desktop 工作区对应的文件系统根目录;如果 App 当前打开了其他项目,应通过 `CCR_OPENCODE_BOT_CWD` 设置同一个项目目录。CCR 会把该目录显式传给 `opencode run --dir`,使新 session 出现在 App 对应的项目下。默认不会自动批准权限;只有在可信环境中才应设置 `CCR_OPENCODE_BOT_AUTO_APPROVE=true`,因为它会启用 OpenCode 的高风险 `--auto` 模式。
|
||||
绑定 Bot 后,OpenCode 可以把会话接入所选 IM 通道。
|
||||
|
||||
### Codex
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Provider ID | 写入 Codex 的 `model_provider`,默认是 `claude-code-router`。建议保持稳定,只使用字母、数字、点、下划线或短横线。 |
|
||||
| Provider ID | 当前 Codex 配置使用的供应商引用。建议保持稳定,只使用字母、数字、点、下划线或短横线。 |
|
||||
| Provider name | Codex 中展示的供应商名称,默认是 `Claude Code Router`。 |
|
||||
| Codex model | 写入 Codex 默认模型。可以选择普通供应商模型或 Fusion 模型;留空时 CCR 使用可用模型中的默认值。 |
|
||||
| Show all sessions | 让 Codex 显示所有会话。ZCode 不提供该项。 |
|
||||
| 配置文件 | 默认是 `~/.codex/config.toml`。仅从 CCR 打开时生效会写入 CCR 管理的独立配置目录。 |
|
||||
| 环境变量 | 注入 Codex CLI 或 ChatGPT。Claude Code 专用的模型发现变量不会传给 Codex。 |
|
||||
| Show all sessions | 让 Codex 显示所有会话。 |
|
||||
| 配置文件 | 用于系统默认 Codex 配置。 |
|
||||
| 环境变量 | 可选高级设置;普通使用保持为空。 |
|
||||
| Bot | 只在 ChatGPT App 入口生效。 |
|
||||
|
||||
保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr-app "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:每个配置只从自己的 Codex Home 读取 ChatGPT 登录状态;没有凭据时返回本地非 OpenAI 兼容身份,避免新版 ChatGPT 进入循环鉴权和 attestation。共享登录改为显式启用:只有设置 `CCR_CODEX_CHATGPT_AUTH_FILE`(或兼容变量 `CODEXL_CODEX_CHATGPT_AUTH_FILE`)并指向有效 `auth.json` 时,配置才会在内存中桥接该 token,且不会复制认证文件。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。
|
||||
|
||||
模型和公共插件列表不再由中间层合成。原生 Codex app-server 读取生成的 `model_catalog_json`,并原样处理 `model/list` 与公共 `plugin/list` 请求,因此 Codex 可以自行联网刷新官方公开 [`openai/plugins`](https://github.com/openai/plugins) Git marketplace。虚拟 workspace 中,只有必须使用真实 ChatGPT 鉴权的账号私有 marketplace 请求会得到明确空结果,绝不会用本地插件替代。下载后的 Git checkout 只作为 Codex 自己的常规 last-known-good 数据,CCR 不会拿它替代远端目录。
|
||||
保存后,Codex CLI 在桌面端可用配置卡片里的终端图标复制命令,例如 `ccr-app "Codex - Work"`;CLI请执行:`ccr "Codex - Work"`。ChatGPT 使用播放图标打开。
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。保存后复制并运行配置卡片上的命令,例如 `ccr-app "Grok - Work"`。
|
||||
|
||||
生成的包装器会把 Grok 的模型网关和模型列表地址指向 CCR 的 `/v1`,注入该配置专属的 CCR API Key,并把选中的 CCR 模型设为默认模型。如果 CCR Desktop 网关尚未运行,`ccr-app` 会为 Grok 会话启动一个可共享的临时服务,并在最后一个会话退出后清理。Grok CLI 没有单独指定用户配置文件的选项,因此 CCR 会把 `GROK_HOME` 指向配置专属目录;其中的 `config.toml` 初始复制自用户配置,之后可以独立修改,不会回写原文件,同时隔离 `auth.json`,避免本机 xAI OAuth token 覆盖 CCR Key。插件、技能和会话目录仍与原 Grok home 共享。进入 Grok CLI 后可以使用 `/model` 切换 CCR 返回的普通供应商模型或 Fusion 模型,切换后的请求仍然经过 CCR。
|
||||
Grok CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。保存后运行配置命令:桌面端是 `ccr-app "Grok - Work"`,CLI 是 `ccr "Grok - Work"`。进入 Grok CLI 后可以使用 `/model` 切换 CCR 返回的普通供应商模型或 Fusion 模型。
|
||||
|
||||
### Kimi CLI
|
||||
|
||||
Kimi CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。请选择一个默认模型以及一个或多个可用模型。生成的包装器会把 `KIMI_CODE_HOME` 指向配置专属目录,其中的 `config.toml` 定义私有的 OpenAI 兼容 CCR 供应商,并为每个选中模型生成模型项,因此可在 Kimi 内使用 `/model` 切换且不会绕过 CCR。CCR 会保留来源配置中的非供应商设置,并复用可用的会话、技能、插件、MCP 配置和凭据,不会改写原始 `~/.kimi-code/config.toml`。CCR Desktop 未运行时,启动器会创建共享的临时网关,并在最后一个受管 Kimi 会话退出后停止。
|
||||
Kimi CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。请选择一个默认模型以及一个或多个可用模型。Kimi 的 `/model` 可以在你选择的模型之间切换。
|
||||
|
||||
### ZCode
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Provider ID | 写入 ZCode 供应商引用,默认是 `claude-code-router`。 |
|
||||
| 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 和中间层启动器。 |
|
||||
| 配置文件 | 用于系统默认 ZCode 配置。 |
|
||||
| 环境变量 | 可选高级设置;普通使用保持为空。 |
|
||||
| Bot | 只在 ZCode App 入口生效。 |
|
||||
|
||||
ZCode 只支持 App 打开,因此入口模式固定为 `App only`,也不会显示 `Show all sessions`。
|
||||
|
||||
### WorkBuddy
|
||||
|
||||
| 配置项 | 作用 |
|
||||
| --- | --- |
|
||||
| Provider ID | 当前 WorkBuddy 配置使用的供应商引用,默认是 `claude-code-router`。 |
|
||||
| Provider name | 生成配置中使用的显示名称,默认是 `Claude Code Router`。 |
|
||||
| WorkBuddy model | WorkBuddy App 打开后的默认模型。可以选择普通供应商模型或 Fusion 模型。 |
|
||||
| 允许模型列表 | WorkBuddy 中可见的模型;留空表示全部 CCR 模型。 |
|
||||
| APP_PATH | 自动探测不到 App 时填写 WorkBuddy 可执行文件路径。 |
|
||||
| 配置文件 | 用于系统默认 WorkBuddy 配置。 |
|
||||
| 环境变量 | 可选高级设置;普通使用保持为空。 |
|
||||
| Bot | 只在 WorkBuddy App 入口生效。 |
|
||||
|
||||
WorkBuddy 只支持 App 打开,因此入口模式固定为 `App only`。
|
||||
|
||||
## CLI 与 App 模式区别
|
||||
|
||||
| 模式 | 如何打开 | 适合场景 | 主要差异 |
|
||||
| --- | --- | --- | --- |
|
||||
| CLI | 点击终端图标复制命令,然后在终端运行 `ccr-app <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 |
|
||||
| CLI | 桌面端:点击终端图标并运行 `ccr-app <配置名称>`;CLI:运行 `ccr <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 在终端打开当前配置;当前不转发 Bot 消息。 |
|
||||
| App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、Bot 消息转发或接力 | 同一配置重复打开会激活已有窗口。是否支持多开取决于 Agent;OpenCode Desktop 是单实例应用,切换 OpenCode 配置时 CCR 会先停止其管理的旧实例。 |
|
||||
| CLI & APP | 同一个配置同时提供 CLI 和 App 入口 | 同一套模型配置既用于终端,也用于桌面 App | 两个入口共用配置名称、模型、作用范围和环境变量,但启动方式不同。 |
|
||||
|
||||
|
|
@ -134,39 +134,41 @@ ZCode 只支持 App 打开,因此入口模式固定为 `App only`,也不会
|
|||
|
||||
### Claude Code
|
||||
|
||||
Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生效”时,CCR 会在自己的配置目录下为这个 Agent 配置生成独立设置文件,并通过独立启动包装器打开 Claude Code。
|
||||
Claude Code 支持 CLI 和 App。需要不同模型、作用范围或 Bot 时,创建多个配置即可。
|
||||
|
||||
从桌面 App 打开 Claude App 时,CCR 还会为该配置准备独立用户数据目录。不同 Agent 配置使用不同目录,因此可以同时打开多个 Claude App 实例。
|
||||
|
||||
绑定 Bot 后,Claude App 的伴生 worker 会把 Project/Session、流式回复、附件、会话用量和原生权限/Ask User 请求接入 IM;App 退出时 worker 同步停止。
|
||||
绑定 Bot 后,Claude App 可以把会话接入所选 IM 通道。
|
||||
|
||||
### Codex
|
||||
|
||||
Codex 配置会写入 `config.toml`,并生成模型目录文件。选择“仅从 CCR 打开时生效”时,CCR 会把这些文件放在按配置 `id` 区分的目录中。
|
||||
Codex 支持 CLI 和 App。终端按钮打开 Codex CLI,播放按钮打开 ChatGPT。
|
||||
|
||||
Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会启动 ChatGPT、使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 App。
|
||||
|
||||
绑定 Bot 后,Codex App 的伴生 worker 使用 Codex 原生 rollout Session,实现 Project/Session 浏览、续接、队列、取消、模型设置、用量、附件和诊断。该 worker 只随受管 App 存活。
|
||||
绑定 Bot 后,ChatGPT 可以把会话接入所选 IM 通道。
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode 配置会写入 JSON/JSONC 文件,把当前选择的供应商和模型路由到 CCR。CLI 通过配置专属包装器启动;App 使用相同的有效配置启动已安装的 OpenCode Desktop。
|
||||
OpenCode 支持 CLI 和 App。OpenCode Desktop 是单实例应用,请从 CCR 切换配置,而不是再启动一个 App。
|
||||
|
||||
选择 Bot 并从 CCR 打开 App 后,CCR 会启动配套 worker,通过 OpenCode 原生 Session 处理收到的 Bot 消息,并提供与其他 App 一致的 Project/Session、队列、媒体、设置和诊断合同。受管 OpenCode App 退出或切换配置时,该 worker 也会同步停止。
|
||||
绑定 Bot 后,OpenCode 可以把会话接入所选 IM 通道。
|
||||
|
||||
### Grok CLI
|
||||
|
||||
Grok CLI 只支持 CLI。CCR 通过配置专属包装器启动它,注入 CCR 模型网关、模型发现地址、API Key 和默认模型,并通过不含 xAI OAuth 凭据的配置专属 Grok home 保证推理使用 CCR Key;用户原有的 Grok home 不会被改写。
|
||||
Grok CLI 只支持 CLI。进入 Grok CLI 后可用 `/model` 切换 CCR 模型。
|
||||
|
||||
### Kimi CLI
|
||||
|
||||
Kimi CLI 只支持 CLI。CCR 通过配置专属包装器和生成的 Kimi home 启动它,其中包含默认模型和所有选中的可用模型。每个模型项都使用 CCR 网关和配置专属 API Key,因此 `/model` 切换后仍经过 CCR;用户原有的 Kimi 配置不会被改写。
|
||||
Kimi CLI 只支持 CLI。请选择一个默认模型,并按需添加 `/model` 中可切换的模型。
|
||||
|
||||
### ZCode
|
||||
|
||||
ZCode 只支持 App 打开。CCR 会根据 ZCode home 或自定义配置文件写入 ZCode 的 CLI 配置、v2 配置和模型缓存,并在 App 启动时使用当前 Agent 配置的模型、供应商和独立用户数据目录。
|
||||
ZCode 只支持 App 打开。使用播放按钮打开。
|
||||
|
||||
绑定 Bot 后,ZCode 使用与 Codex 同类的 App 伴生 worker 和原生 Session 扫描;ZCode App 关闭时接力立即离线。
|
||||
绑定 Bot 后,ZCode 可以把会话接入所选 IM 通道;ZCode App 关闭时接力立即离线。
|
||||
|
||||
### WorkBuddy
|
||||
|
||||
WorkBuddy 只支持 App 打开。使用播放按钮打开。
|
||||
|
||||
绑定 Bot 后,WorkBuddy 可以把会话接入所选 IM 通道;WorkBuddy 关闭时接力离线。
|
||||
|
||||
## 多开建议
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ CCR 会自动为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件
|
|||
3. 选择脚本文件,设置 10–30000 毫秒的超时时间,然后使用 **验证** 或 **测试脚本** 检查结果。
|
||||
4. 保存规则。CCR 会在每次执行前重新读取文件,因此后续修改脚本内容不需要重新保存规则。
|
||||
|
||||
桌面版文件选择器保存绝对路径。Web UI 不能获得浏览器所选文件的真实路径,需要手动填写 CCR 服务所在机器上的绝对路径、相对路径或 `~/...` 路径;相对路径以 CCR 进程的工作目录为基准。单个脚本文件最大 64 KiB。
|
||||
桌面版文件选择器保存绝对路径。Web UI 不能获得浏览器所选文件的真实路径,需要手动填写 CCR 服务所在机器上的绝对路径、相对路径或 `~/...` 路径;相对路径以 CCR 进程的工作目录为基准。单个脚本文件最大 5 MiB。
|
||||
|
||||
脚本文件是一个 **异步函数体**。直接使用已注入的 `input`、`api` 和 `return`;不需要写 `module.exports`、`export default` 或 `import`:
|
||||
|
||||
|
|
@ -397,7 +397,7 @@ return {
|
|||
|
||||
| 限制 | 当前值 |
|
||||
| --- | --- |
|
||||
| 脚本文件 | 最大 64 KiB |
|
||||
| 脚本文件 | 最大 5 MiB |
|
||||
| 执行超时 | 10–30000 毫秒,默认 2000 毫秒 |
|
||||
| Fetch 请求体 / 响应体 | 256 KiB / 1 MiB |
|
||||
| 单次文件读取或写入 | 1 MiB |
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ Agent 配置让 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI、ZCode 等
|
|||
|
||||
### ZCode
|
||||
|
||||
选择 ZCode 并设置 **ZCode 模型**、**供应商 ID** 和 **供应商名称**。ZCode 是以桌面应用形态运行的 Agent(入口固定为 **仅 App**):CCR 写入它的 CLI 配置和模型缓存后,用该配置的模型、供应商和独立的用户数据目录启动 App,从配置卡片上的播放按钮打开。
|
||||
选择 ZCode 并设置 **ZCode 模型**、**供应商 ID** 和 **供应商名称**。ZCode 是以桌面应用形态运行的 Agent,入口固定为 **仅 App**,从配置卡片上的播放按钮打开。
|
||||
|
||||
## 日志与观测
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ lead: "页面顶部的交互式面板可直接连到你正在运行的 CCR,为
|
|||
|
||||
- 在 **Agent 配置** 点击 **添加配置**,选择你的 Agent,填写 **配置名称**。
|
||||
- 试用阶段优先选择 **仅从 CCR 打开时生效**(默认),只影响从 CCR 打开的 Agent;确认稳定后再考虑 **系统默认**。
|
||||
- Claude Code、Codex 可选择 **入口模式**(CLI 与 APP / 仅 CLI / 仅 App);Grok CLI、Kimi CLI 固定为仅 CLI,ZCode 固定为仅 App。
|
||||
- Claude Code、Codex 可选择 **入口模式**(CLI 与 APP / 仅 CLI / 仅 App);Grok CLI、Kimi CLI 固定为仅 CLI,ZCode 和 WorkBuddy 固定为仅 App。
|
||||
- 保存后尽量用配置卡片上的按钮启动 Agent(终端按钮打开 CLI,播放按钮打开 App),再发一条请求到 **请求日志** 验证。
|
||||
- 命令名按发行版区分:桌面端卡片复制的是 `ccr-app ...`;CLI 使用 `ccr ...`,配置名称和可选的 `cli` / `app` 后缀保持一致。
|
||||
|
||||
## Claude Code
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ lead: "页面顶部的交互式面板可直接连到你正在运行的 CCR,为
|
|||
3. **保存**,然后用终端按钮打开 CLI,或播放按钮打开 Claude App。
|
||||
4. 发一条消息确认能正常回复,到 **请求日志** 核对请求是否经过网关;CLI 中可用 `/model` 查看、切换 CCR 暴露的模型。
|
||||
|
||||
各档位模型、设置文件和环境变量见 [Claude Code 接入与配置](../../configuration/agents/claude-code/)。
|
||||
各档位模型和高级设置见 [Claude Code 接入与配置](../../configuration/agents/claude-code/)。
|
||||
|
||||
## Codex
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ lead: "页面顶部的交互式面板可直接连到你正在运行的 CCR,为
|
|||
3. **保存**,然后用终端按钮打开 CLI,或播放按钮打开 ChatGPT。
|
||||
4. 发一条消息确认能正常回复,到 **请求日志** 核对请求是否经过网关。
|
||||
|
||||
`config.toml` 字段、CLI 路径和 ChatGPT 登录共享见 [Codex 接入与配置](../../configuration/agents/codex/)。
|
||||
Codex 专属字段见 [Codex 接入与配置](../../configuration/agents/codex/)。
|
||||
|
||||
## Grok CLI
|
||||
|
||||
|
|
@ -40,21 +41,21 @@ lead: "页面顶部的交互式面板可直接连到你正在运行的 CCR,为
|
|||
|
||||
1. **添加配置** → 选择 **Grok CLI**,填写 **配置名称**。
|
||||
2. 选择 **模型**。
|
||||
3. **保存**,复制卡片上的 `ccr-app "<配置名称>"` 命令并运行。
|
||||
3. **保存**,然后运行配置命令:桌面端卡片是 `ccr-app "<配置名称>"`,CLI 是 `ccr "<配置名称>"`。
|
||||
4. 在 Grok 中发一条消息确认能正常回复,到 **请求日志** 核对请求是否经过网关;用 `/model` 切换 CCR 暴露的模型。
|
||||
|
||||
CCR Desktop 未运行时,该命令会启动一个可共享的临时网关,并在最后一个会话退出后停止。完整字段见 [Grok CLI 接入与配置](../../configuration/agents/grok/)。
|
||||
完整字段见 [Grok CLI 接入与配置](../../configuration/agents/grok/)。
|
||||
|
||||
## Kimi CLI
|
||||
|
||||
仅支持 CLI,且固定为 **仅从 CCR 打开时生效**,不会改写原有的 `~/.kimi-code/config.toml`。
|
||||
仅支持 CLI,且固定为 **仅从 CCR 打开时生效**。
|
||||
|
||||
1. **添加配置** → 选择 **Kimi CLI**,填写 **配置名称**。
|
||||
2. 选择 **Kimi 模型**(默认模型)以及一个或多个 **可用模型**。
|
||||
3. **保存**,复制卡片上的 `ccr-app "<配置名称>"` 命令并运行。
|
||||
3. **保存**,然后运行配置命令:桌面端卡片是 `ccr-app "<配置名称>"`,CLI 是 `ccr "<配置名称>"`。
|
||||
4. 在 Kimi 中发一条消息确认能正常回复,到 **请求日志** 核对请求是否经过网关;用 `/model` 在默认模型与可用模型之间切换。
|
||||
|
||||
CCR 会用配置专属的 `KIMI_CODE_HOME` 启动 Kimi,并复用源 home 中的会话、技能、插件和凭据。完整字段见 [Kimi CLI 接入与配置](../../configuration/agents/kimi/)。
|
||||
完整字段见 [Kimi CLI 接入与配置](../../configuration/agents/kimi/)。
|
||||
|
||||
## ZCode
|
||||
|
||||
|
|
@ -67,6 +68,18 @@ CCR 会用配置专属的 `KIMI_CODE_HOME` 启动 Kimi,并复用源 home 中
|
|||
|
||||
字段逐项说明和多实例、机器人绑定等进阶用法见 [ZCode 接入与配置](../../configuration/agents/zcode/)。
|
||||
|
||||
## WorkBuddy
|
||||
|
||||
仅支持 App(入口固定为 **仅 App**)。
|
||||
|
||||
1. **添加配置** → 选择 **WorkBuddy**,填写 **配置名称**。
|
||||
2. 确认 **WorkBuddy 模型**、**供应商 ID** 和 **供应商名称**。
|
||||
3. 按需限制 **允许模型列表**。留空表示 WorkBuddy 可使用全部 CCR 模型。
|
||||
4. **保存**,点击播放按钮打开 WorkBuddy。
|
||||
5. 发一条消息确认能正常回复,到 **请求日志** 核对请求是否经过网关。
|
||||
|
||||
APP_PATH、多配置和机器人绑定见 [WorkBuddy 接入与配置](../../configuration/agents/workbuddy/)。
|
||||
|
||||
## OpenCode
|
||||
|
||||
支持 OpenCode CLI 和桌面应用两种形态。
|
||||
|
|
@ -76,4 +89,4 @@ CCR 会用配置专属的 `KIMI_CODE_HOME` 启动 Kimi,并复用源 home 中
|
|||
3. **保存**,然后用终端按钮打开 CLI,或播放按钮通过 CCR Desktop 打开 OpenCode 桌面应用。
|
||||
4. 发一条消息确认能正常回复,到 **请求日志** 核对请求是否经过网关。
|
||||
|
||||
`opencode.jsonc` 字段、CLI 路径和配置写入见 [OpenCode 接入与配置](../../configuration/agents/opencode/)。
|
||||
OpenCode 专属字段见 [OpenCode 接入与配置](../../configuration/agents/opencode/)。
|
||||
|
|
|
|||
|
|
@ -189,6 +189,13 @@ export const docPages: DocPageDef[] = [
|
|||
path: { zh: "/configuration/agents/zcode/", en: "/en/configuration/agents/zcode/" },
|
||||
source: { zh: "configuration/agents/zcode.md", en: "configuration/agents/zcode.md" },
|
||||
},
|
||||
{
|
||||
key: "configuration/agents/workbuddy",
|
||||
section: "configuration",
|
||||
label: { zh: "WorkBuddy", en: "WorkBuddy" },
|
||||
path: { zh: "/configuration/agents/workbuddy/", en: "/en/configuration/agents/workbuddy/" },
|
||||
source: { zh: "configuration/agents/workbuddy.md", en: "configuration/agents/workbuddy.md" },
|
||||
},
|
||||
{
|
||||
key: "configuration/agents/claude-design",
|
||||
section: "configuration",
|
||||
|
|
@ -472,6 +479,7 @@ export const docSections: DocSectionDef[] = [
|
|||
pageItem("configuration/agents/opencode"),
|
||||
pageItem("configuration/agents/pi"),
|
||||
pageItem("configuration/agents/zcode"),
|
||||
pageItem("configuration/agents/workbuddy"),
|
||||
pageItem("configuration/agents/claude-design"),
|
||||
],
|
||||
},
|
||||
|
|
|
|||
729
package-lock.json
generated
729
package-lock.json
generated
File diff suppressed because it is too large
Load diff
23
package.json
23
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "claude-code-router-monorepo",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.21",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"description": "Local Claude Code Router gateway with CLI and web management UI.",
|
||||
|
|
@ -72,13 +72,14 @@
|
|||
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.16",
|
||||
"@the-next-ai/ai-gateway": "^1.0.17",
|
||||
"@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"
|
||||
"pm2": "^7.0.3",
|
||||
"undici": "^7.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -96,7 +97,7 @@
|
|||
"electron": "^42.3.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"esbuild": "^0.27.7",
|
||||
"js-yaml": "^4.2.0",
|
||||
"js-yaml": "^4.3.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"motion": "^12.40.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
@ -109,6 +110,18 @@
|
|||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "$@types/react"
|
||||
"@types/react": "$@types/react",
|
||||
"fast-uri@3.1.2": "3.1.5",
|
||||
"find-my-way@9.6.0": "9.7.0",
|
||||
"js-yaml": "$js-yaml",
|
||||
"minimatch@10.2.5": {
|
||||
"brace-expansion": "5.0.9"
|
||||
},
|
||||
"pm2": {
|
||||
"js-yaml": "4.3.1"
|
||||
},
|
||||
"socks": {
|
||||
"ip-address": "10.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.21",
|
||||
"license": "MIT",
|
||||
"description": "Local Claude Code Router gateway with CLI and web management UI.",
|
||||
"repository": {
|
||||
|
|
@ -41,10 +41,10 @@
|
|||
"test:integration": "node ../../build/test.mjs cli --scope integration && node ../../build/run-tests.mjs cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.16",
|
||||
"@the-next-ai/ai-gateway": "^1.0.17",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"undici": "^7.27.2"
|
||||
"undici": "^7.29.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@claude-code-router/core",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.21",
|
||||
"private": true,
|
||||
"description": "Claude Code Router core gateway, routing, provider, and storage services.",
|
||||
"main": "dist/main/server.js",
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
"test:integration": "node ../../build/test.mjs core --scope integration && node ../../build/run-tests.mjs core"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.16",
|
||||
"@the-next-ai/ai-gateway": "^1.0.17",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"pm2": "^6.0.13",
|
||||
"undici": "^7.27.2"
|
||||
"pm2": "^7.0.3",
|
||||
"undici": "^7.29.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ function encodeClaudeAppGatewayRouteModel(model: string): string {
|
|||
return Buffer.from(stripClaudeAppGatewayOneMillionContextSuffix(model), "utf8").toString("hex");
|
||||
}
|
||||
|
||||
function decodeClaudeAppGatewayRouteId(routeId: string): string | undefined {
|
||||
export function decodeClaudeAppGatewayRouteId(routeId: string): string | undefined {
|
||||
const normalized = stripClaudeAppGatewayOneMillionContextSuffix(routeId).toLowerCase();
|
||||
const match = /^anthropic\/claude-ccr(?:\d+)?-h([0-9a-f]+)$/.exec(normalized);
|
||||
const encoded = match?.[1];
|
||||
|
|
|
|||
82
packages/core/src/agents/claude-code/auth-mode.ts
Normal file
82
packages/core/src/agents/claude-code/auth-mode.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export const CLAUDE_CODE_AUTH_MODE_ENV = "CCR_CLAUDE_CODE_AUTH_MODE";
|
||||
|
||||
export type ClaudeCodeGatewayAuthMode = "api-key-helper" | "wif";
|
||||
export type ClaudeCodeGatewayAuthModePreference = ClaudeCodeGatewayAuthMode | "auto";
|
||||
|
||||
type ClaudeCodeAuthProfile = {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
const claudeCodeWifMinimumVersion = [2, 1, 200] as const;
|
||||
|
||||
export function resolveClaudeCodeGatewayAuthMode(profile: ClaudeCodeAuthProfile): ClaudeCodeGatewayAuthMode {
|
||||
const preference = claudeCodeGatewayAuthModePreference(profile);
|
||||
if (preference === "wif" || preference === "api-key-helper") {
|
||||
return preference;
|
||||
}
|
||||
|
||||
const version = detectClaudeCodeCliVersion(profile);
|
||||
return version && compareVersions(version, claudeCodeWifMinimumVersion) >= 0
|
||||
? "wif"
|
||||
: "api-key-helper";
|
||||
}
|
||||
|
||||
export function claudeCodeGatewayAuthModePreference(profile: ClaudeCodeAuthProfile): ClaudeCodeGatewayAuthModePreference {
|
||||
return normalizeClaudeCodeGatewayAuthMode(
|
||||
profile.env?.[CLAUDE_CODE_AUTH_MODE_ENV] ||
|
||||
process.env[CLAUDE_CODE_AUTH_MODE_ENV]
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeClaudeCodeGatewayAuthMode(value: string | undefined): ClaudeCodeGatewayAuthModePreference {
|
||||
const normalized = value?.trim().toLowerCase().replace(/_/g, "-") || "";
|
||||
if (normalized === "wif" || normalized === "workload-identity" || normalized === "workload-identity-federation") {
|
||||
return "wif";
|
||||
}
|
||||
if (normalized === "helper" || normalized === "api-key-helper" || normalized === "apikey-helper" || normalized === "legacy") {
|
||||
return "api-key-helper";
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function detectClaudeCodeCliVersion(profile: ClaudeCodeAuthProfile): [number, number, number] | undefined {
|
||||
const command = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
|
||||
try {
|
||||
const result = spawnSync(command, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 1500,
|
||||
windowsHide: true
|
||||
});
|
||||
if (result.error) {
|
||||
return undefined;
|
||||
}
|
||||
return parseVersion(`${result.stdout || ""}\n${result.stderr || ""}`);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseVersion(value: string): [number, number, number] | undefined {
|
||||
const match = value.match(/\b(\d+)\.(\d+)(?:\.(\d+))?\b/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
Number.parseInt(match[1] ?? "0", 10),
|
||||
Number.parseInt(match[2] ?? "0", 10),
|
||||
Number.parseInt(match[3] ?? "0", 10)
|
||||
];
|
||||
}
|
||||
|
||||
function compareVersions(left: readonly number[], right: readonly number[]): number {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const delta = (left[index] ?? 0) - (right[index] ?? 0);
|
||||
if (delta !== 0) {
|
||||
return delta;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -10,13 +10,14 @@ import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profil
|
|||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
|
||||
import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog";
|
||||
import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config";
|
||||
import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
export type CodexAppLookupResult = {
|
||||
checked: string[];
|
||||
executable?: string;
|
||||
};
|
||||
|
||||
type CodexCompatibleAppKind = "codex" | "zcode";
|
||||
type CodexCompatibleAppKind = "codex" | "workbuddy" | "zcode";
|
||||
|
||||
type CodexCompatibleAppSpec = {
|
||||
bundledCliNames: string[];
|
||||
|
|
@ -49,9 +50,31 @@ export type CodexCompatibleAppModelCatalogWriteResult = {
|
|||
changed: boolean;
|
||||
file: string;
|
||||
userDataDir: string;
|
||||
workbuddyModelsConfig?: WorkbuddyModelsConfigWriteResult;
|
||||
workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult;
|
||||
};
|
||||
|
||||
export type WorkbuddyModelsConfigWriteResult = {
|
||||
changed: boolean;
|
||||
file: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type WorkbuddyVirtualAuthResult = {
|
||||
authFile: string;
|
||||
changed: boolean;
|
||||
configDir: string;
|
||||
homeDir: string;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
||||
export const codexDesktopAppName = "ChatGPT";
|
||||
export const workbuddyDesktopAppName = "WorkBuddy AI";
|
||||
|
||||
const workbuddyVirtualAuthId = "workbuddy-desktop-ai";
|
||||
const workbuddyVirtualAuthUserId = "ccr-local-profile";
|
||||
const workbuddyVirtualAuthToken = "ccr-local-profile";
|
||||
const workbuddyVirtualAuthExpiresAt = 1_999_999_999_999;
|
||||
|
||||
const codexAppSpec: CodexCompatibleAppSpec = {
|
||||
bundledCliNames: ["codex", "Codex", "OpenAI Codex"],
|
||||
|
|
@ -157,6 +180,56 @@ const zcodeAppSpec: CodexCompatibleAppSpec = {
|
|||
]
|
||||
};
|
||||
|
||||
const workbuddyAppSpec: CodexCompatibleAppSpec = {
|
||||
bundledCliNames: [
|
||||
"app.asar.unpacked/cli/bin/codebuddy",
|
||||
"app.asar.unpacked/cli/bin/cbc",
|
||||
"app.asar.unpacked/cli/bin/cbc-prewarm",
|
||||
"codebuddy",
|
||||
"CodeBuddy",
|
||||
"workbuddy",
|
||||
"WorkBuddy"
|
||||
],
|
||||
defaultCliCommand: "codebuddy",
|
||||
displayName: workbuddyDesktopAppName,
|
||||
envPathKeys: ["CCR_WORKBUDDY_APP_PATH", "WORKBUDDY_APP_PATH", "CODEXL_WORKBUDDY_PATH"],
|
||||
kind: "workbuddy",
|
||||
linuxCandidates: [
|
||||
"/opt/WorkBuddy AI/workbuddy-ai",
|
||||
"/opt/WorkBuddy AI/WorkBuddyAI",
|
||||
"/opt/WorkBuddy/workbuddy",
|
||||
"/opt/WorkBuddy/WorkBuddy",
|
||||
"/usr/local/bin/workbuddy-ai",
|
||||
"/usr/bin/workbuddy-ai",
|
||||
"/usr/local/bin/workbuddy",
|
||||
"/usr/bin/workbuddy"
|
||||
],
|
||||
macAppNames: ["WorkBuddy AI.app", "WorkBuddy.app", "WorkBuddyAI.app", "Workbuddy.app"],
|
||||
modelCatalogFilename: "ccr-workbuddy-model-catalog.json",
|
||||
userDataDirName: "workbuddy-app-user-data",
|
||||
windowsAppDirs: ["WorkBuddy AI", "WorkBuddyAI", "WorkBuddy", "Workbuddy", "CodeBuddy"],
|
||||
windowsExeNames: [
|
||||
"WorkBuddy AI.exe",
|
||||
"WorkBuddyAI.exe",
|
||||
"WorkBuddy.exe",
|
||||
"workbuddy-ai.exe",
|
||||
"workbuddy.exe",
|
||||
"CodeBuddy.exe",
|
||||
"codebuddy.exe"
|
||||
],
|
||||
windowsPackageKeywords: ["workbuddy", "workbuddyai", "workbuddy-ai", "codebuddy"],
|
||||
windowsVendorDirs: ["WorkBuddy", "WorkBuddy AI", "CodeBuddy"],
|
||||
windowsWhereNames: [
|
||||
"WorkBuddy AI",
|
||||
"WorkBuddyAI",
|
||||
"WorkBuddy",
|
||||
"workbuddy-ai",
|
||||
"workbuddy",
|
||||
"CodeBuddy",
|
||||
"codebuddy"
|
||||
]
|
||||
};
|
||||
|
||||
export function launchCodexAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult {
|
||||
return launchCodexCompatibleAppProfile(configDir, profile, codexAppSpec, config);
|
||||
}
|
||||
|
|
@ -173,12 +246,26 @@ export function launchZcodeAppProfile(configDir: string, profile: ProfileConfig,
|
|||
return launchCodexCompatibleAppProfile(configDir, profile, zcodeAppSpec, config);
|
||||
}
|
||||
|
||||
export function findInstalledWorkbuddyAppExecutable(profileAppPath?: string): CodexAppLookupResult {
|
||||
return findInstalledCodexCompatibleAppExecutable(workbuddyAppSpec, profileAppPath);
|
||||
}
|
||||
|
||||
export function launchWorkbuddyAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult {
|
||||
return launchCodexCompatibleAppProfile(configDir, profile, workbuddyAppSpec, config);
|
||||
}
|
||||
|
||||
export function refreshCodexCompatibleAppProfileFiles(
|
||||
configDir: string,
|
||||
profile: ProfileConfig,
|
||||
config?: AppConfig
|
||||
): { modelCatalogChanged: boolean; modelCatalogFile: string; userDataDir: string } {
|
||||
const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec;
|
||||
): {
|
||||
modelCatalogChanged: boolean;
|
||||
modelCatalogFile: string;
|
||||
userDataDir: string;
|
||||
workbuddyModelsConfig?: WorkbuddyModelsConfigWriteResult;
|
||||
workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult;
|
||||
} {
|
||||
const spec = codexCompatibleAppSpecForProfile(profile);
|
||||
if (spec.kind === "zcode" && config?.APIKEY) {
|
||||
writeZcodeGatewayConfig(config, profile, config.APIKEY, { backup: false });
|
||||
}
|
||||
|
|
@ -186,7 +273,9 @@ export function refreshCodexCompatibleAppProfileFiles(
|
|||
return {
|
||||
modelCatalogChanged: modelCatalog.changed,
|
||||
modelCatalogFile: modelCatalog.file,
|
||||
userDataDir: modelCatalog.userDataDir
|
||||
userDataDir: modelCatalog.userDataDir,
|
||||
workbuddyModelsConfig: modelCatalog.workbuddyModelsConfig,
|
||||
workbuddyVirtualAuth: modelCatalog.workbuddyVirtualAuth
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +284,7 @@ export function writeCodexCompatibleAppModelCatalog(
|
|||
profile: ProfileConfig,
|
||||
config?: AppConfig
|
||||
): CodexCompatibleAppModelCatalogWriteResult {
|
||||
const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec;
|
||||
const spec = codexCompatibleAppSpecForProfile(profile);
|
||||
const configFile = resolveCodexConfigFile(configDir, profile);
|
||||
const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile);
|
||||
if (spec.kind === "codex") {
|
||||
|
|
@ -204,30 +293,158 @@ export function writeCodexCompatibleAppModelCatalog(
|
|||
const userDataDir = codexElectronUserDataDir(codexHome, profile, spec);
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
const file = codexAppModelCatalogFile(userDataDir, spec);
|
||||
const content = codexCompatibleAppModelCatalogJson(config, profile.model, spec.kind);
|
||||
const content = codexCompatibleAppModelCatalogJson(config, profile.model, spec.kind, profileAllowedModels(profile));
|
||||
const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
|
||||
if (previous !== content) {
|
||||
writeFileSync(file, content, "utf8");
|
||||
}
|
||||
return { changed: previous !== content, file, userDataDir };
|
||||
const workbuddyModelsConfig = spec.kind === "workbuddy"
|
||||
? writeWorkbuddyModelsConfig(codexHome, profile, config)
|
||||
: undefined;
|
||||
const workbuddyVirtualAuth = spec.kind === "workbuddy"
|
||||
? prepareWorkbuddyAppVirtualAuth(codexHome, userDataDir, profile)
|
||||
: undefined;
|
||||
return {
|
||||
changed: previous !== content || Boolean(workbuddyModelsConfig?.changed),
|
||||
file,
|
||||
userDataDir,
|
||||
workbuddyModelsConfig,
|
||||
workbuddyVirtualAuth
|
||||
};
|
||||
}
|
||||
|
||||
export function writeWorkbuddyModelsConfig(
|
||||
workbuddyConfigDir: string,
|
||||
profile: ProfileConfig,
|
||||
config?: AppConfig
|
||||
): WorkbuddyModelsConfigWriteResult {
|
||||
const file = path.join(workbuddyConfigDir, "models.json");
|
||||
const model = workbuddySelectedModel(profile, config);
|
||||
const content = `${JSON.stringify(workbuddyModelsConfig(model, profile, config), null, 2)}\n`;
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
|
||||
if (previous !== content) {
|
||||
writeFileSync(file, content, "utf8");
|
||||
}
|
||||
return { changed: previous !== content, file, model };
|
||||
}
|
||||
|
||||
function workbuddySelectedModel(
|
||||
profile: Pick<ProfileConfig, "model">,
|
||||
config?: CodexCompatibleAppModelCatalogConfig
|
||||
): string {
|
||||
const configured = profile.model?.trim();
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
return codexCompatibleAppModelCatalog(config, undefined, "workbuddy").models[0]?.slug || "gpt-5-codex";
|
||||
}
|
||||
|
||||
function workbuddyModelsConfig(
|
||||
defaultModel: string,
|
||||
profile: ProfileConfig,
|
||||
config?: AppConfig
|
||||
): Record<string, unknown> {
|
||||
const allowedModels = profileAllowedModels(profile);
|
||||
const catalogItems = codexCompatibleAppModelCatalog(config, defaultModel, "workbuddy", allowedModels).models;
|
||||
const models = catalogItems.map((catalogItem) =>
|
||||
workbuddyModelConfig(catalogItem.slug || catalogItem.id || catalogItem.model, defaultModel, profile, catalogItem, config)
|
||||
);
|
||||
if (models.length === 0) {
|
||||
models.push(workbuddyModelConfig(defaultModel, defaultModel, profile, undefined, config));
|
||||
}
|
||||
return {
|
||||
availableModels: models.map((item) => String(item.id || "")),
|
||||
models
|
||||
};
|
||||
}
|
||||
|
||||
function workbuddyModelConfig(
|
||||
model: string,
|
||||
defaultModel: string,
|
||||
profile: Pick<ProfileConfig, "name" | "providerName">,
|
||||
catalogItem: CodexModelCatalogItem | undefined,
|
||||
config?: AppConfig
|
||||
): Record<string, unknown> {
|
||||
const vendor = workbuddyModelVendor(model, profile.providerName);
|
||||
const displayName = model.includes("/") ? model : `${vendor} / ${model}`;
|
||||
const workbuddyModel: Record<string, unknown> = {
|
||||
apiKey: "${CCR_PROFILE_API_KEY}",
|
||||
disabled: false,
|
||||
id: model,
|
||||
isDefault: model === defaultModel,
|
||||
name: displayName,
|
||||
supportsImages: Boolean(catalogItem?.supports_image_detail_original),
|
||||
supportsReasoning: Boolean(catalogItem?.supports_reasoning_summaries),
|
||||
supportsToolCall: true,
|
||||
tags: ["chat", "custom"],
|
||||
url: workbuddyGatewayBaseUrl(config),
|
||||
vendor
|
||||
};
|
||||
const maxInputTokens = catalogItem?.context_window;
|
||||
if (typeof maxInputTokens === "number" && Number.isFinite(maxInputTokens) && maxInputTokens > 0) {
|
||||
workbuddyModel.maxInputTokens = Math.trunc(maxInputTokens);
|
||||
}
|
||||
const reasoningEfforts = catalogItem?.supported_reasoning_efforts;
|
||||
if (Array.isArray(reasoningEfforts) && reasoningEfforts.length > 0) {
|
||||
workbuddyModel.reasoning = {
|
||||
...(catalogItem?.default_reasoning_effort ? { defaultEffort: catalogItem.default_reasoning_effort } : {}),
|
||||
supportedEfforts: reasoningEfforts
|
||||
};
|
||||
}
|
||||
return workbuddyModel;
|
||||
}
|
||||
|
||||
function workbuddyModelVendor(model: string, providerName?: string): string {
|
||||
const slashIndex = model.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
const provider = model.slice(0, slashIndex).trim();
|
||||
if (provider) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return providerName?.trim() || "Claude Code Router";
|
||||
}
|
||||
|
||||
function workbuddyGatewayBaseUrl(config?: Pick<AppConfig, "gateway">): string {
|
||||
if (!config?.gateway) {
|
||||
return "${CCR_WORKBUDDY_GATEWAY_BASE_URL}";
|
||||
}
|
||||
const host = config.gateway.host === "0.0.0.0" || config.gateway.host === "::"
|
||||
? "127.0.0.1"
|
||||
: config.gateway.host || "127.0.0.1";
|
||||
const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
return `http://${formattedHost}:${config.gateway.port}/v1`;
|
||||
}
|
||||
|
||||
function codexCompatibleAppSpecForProfile(profile: Pick<ProfileConfig, "agent">): CodexCompatibleAppSpec {
|
||||
if (profile.agent === "zcode") {
|
||||
return zcodeAppSpec;
|
||||
}
|
||||
if (profile.agent === "workbuddy") {
|
||||
return workbuddyAppSpec;
|
||||
}
|
||||
return codexAppSpec;
|
||||
}
|
||||
|
||||
function codexCompatibleAppModelCatalogJson(
|
||||
config?: CodexCompatibleAppModelCatalogConfig,
|
||||
selectedModel?: string,
|
||||
kind: CodexCompatibleAppKind = "codex"
|
||||
kind: CodexCompatibleAppKind = "codex",
|
||||
allowedModels?: string[]
|
||||
): string {
|
||||
return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel, kind), null, 2)}\n`;
|
||||
return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel, kind, allowedModels), null, 2)}\n`;
|
||||
}
|
||||
|
||||
function codexCompatibleAppModelCatalog(
|
||||
config?: CodexCompatibleAppModelCatalogConfig,
|
||||
selectedModel?: string,
|
||||
kind: CodexCompatibleAppKind = "codex"
|
||||
kind: CodexCompatibleAppKind = "codex",
|
||||
allowedModels?: string[]
|
||||
): CodexModelCatalog {
|
||||
const catalog = kind === "zcode"
|
||||
? buildZcodeModelCatalog(config, selectedModel)
|
||||
: buildCodexModelCatalog(config, selectedModel);
|
||||
? buildZcodeModelCatalog(config, selectedModel, { allowedModels })
|
||||
: buildCodexModelCatalog(config, selectedModel, { allowedModels });
|
||||
return {
|
||||
models: catalog.models.map((model) => codexCompatibleAppModelCatalogItem(model, config))
|
||||
};
|
||||
|
|
@ -366,7 +583,7 @@ function launchCodexCompatibleAppProfile(
|
|||
|
||||
const configFile = resolveCodexConfigFile(configDir, profile);
|
||||
const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile);
|
||||
const { modelCatalogFile, userDataDir } = refreshCodexCompatibleAppProfileFiles(configDir, profile, config);
|
||||
const { modelCatalogFile, userDataDir, workbuddyVirtualAuth } = refreshCodexCompatibleAppProfileFiles(configDir, profile, config);
|
||||
if (spec.kind === "codex") prepareCodexAppCdpUserDataDir(userDataDir);
|
||||
|
||||
const appEnv: Record<string, string> = {
|
||||
|
|
@ -375,7 +592,8 @@ function launchCodexCompatibleAppProfile(
|
|||
...codexProfileEnv(profile, lookup.executable, spec),
|
||||
CODEXL_PROFILE_SURFACE: "app",
|
||||
CCR_PROFILE_SURFACE: "app",
|
||||
...codexAppAgentEnv(spec, plan.command, codexHome, userDataDir, modelCatalogFile),
|
||||
...codexAppAgentEnv(spec, plan.command, codexHome, userDataDir, modelCatalogFile, workbuddyVirtualAuth),
|
||||
...codexCompatibleAppGatewayEnv(spec, config),
|
||||
ELECTRON_ENABLE_LOGGING: "1"
|
||||
};
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
|
|
@ -406,6 +624,19 @@ function launchCodexCompatibleAppProfile(
|
|||
};
|
||||
}
|
||||
|
||||
function codexCompatibleAppGatewayEnv(spec: CodexCompatibleAppSpec, config?: AppConfig): Record<string, string> {
|
||||
if (spec.kind !== "workbuddy" || !config) {
|
||||
return {};
|
||||
}
|
||||
const baseUrl = workbuddyGatewayBaseUrl(config);
|
||||
const apiKey = config.APIKEY?.trim() || "";
|
||||
return {
|
||||
...(apiKey ? { CCR_PROFILE_API_KEY: apiKey, CODEXL_PROFILE_API_KEY: apiKey } : {}),
|
||||
CCR_WORKBUDDY_GATEWAY_BASE_URL: baseUrl,
|
||||
CODEXL_WORKBUDDY_GATEWAY_BASE_URL: baseUrl
|
||||
};
|
||||
}
|
||||
|
||||
function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: CodexCompatibleAppSpec): Record<string, string> {
|
||||
const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const realCliPath = profile.codexCliPath?.trim() || bundledCodexCliPath(appExecutable, spec) || spec.defaultCliCommand;
|
||||
|
|
@ -424,7 +655,7 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co
|
|||
CODEXL_ZCODE_WORKSPACE_NAME: profile.name || providerId
|
||||
};
|
||||
}
|
||||
return {
|
||||
const codexEnv = {
|
||||
...(profile.model.trim() ? { CCR_CODEX_MODEL: profile.model.trim() } : {}),
|
||||
...(process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG?.trim()
|
||||
? { CCR_CODEX_CLI_MIDDLEWARE_LOG: process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG.trim() }
|
||||
|
|
@ -442,6 +673,22 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co
|
|||
CODEXL_CODEX_WORKSPACE_NAME: profile.name || providerId,
|
||||
CODEXL_REAL_CODEX_CLI_PATH: realCliPath
|
||||
};
|
||||
if (spec.kind !== "workbuddy") {
|
||||
return codexEnv;
|
||||
}
|
||||
return {
|
||||
...codexEnv,
|
||||
...(profile.model.trim() ? { CCR_WORKBUDDY_MODEL: profile.model.trim() } : {}),
|
||||
CCR_REAL_WORKBUDDY_CLI_PATH: realCliPath,
|
||||
CCR_WORKBUDDY_MODEL_PROVIDER: providerId,
|
||||
CCR_WORKBUDDY_PROFILE: providerId,
|
||||
CCR_WORKBUDDY_REMOTE_FRONTEND_MODE: remoteFrontendMode,
|
||||
CODEXL_REAL_WORKBUDDY_CLI_PATH: realCliPath,
|
||||
CODEXL_WORKBUDDY_CORE_MODE: remoteFrontendMode,
|
||||
CODEXL_WORKBUDDY_MODEL_PROVIDER: providerId,
|
||||
CODEXL_WORKBUDDY_PROFILE: providerId,
|
||||
CODEXL_WORKBUDDY_WORKSPACE_NAME: profile.name || providerId
|
||||
};
|
||||
}
|
||||
|
||||
function codexSharedChatGptAuthEnv(): Record<string, string> {
|
||||
|
|
@ -468,28 +715,136 @@ function codexAppAgentEnv(
|
|||
launcher: string,
|
||||
home: string,
|
||||
userDataDir: string,
|
||||
modelCatalogFile: string
|
||||
modelCatalogFile: string,
|
||||
workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult
|
||||
): Record<string, string> {
|
||||
return spec.kind === "zcode"
|
||||
? {
|
||||
if (spec.kind === "zcode") {
|
||||
return {
|
||||
CCR_ZCODE_MODEL_CATALOG_FILE: modelCatalogFile,
|
||||
CODEXL_ZCODE_MODEL_CATALOG_FILE: modelCatalogFile,
|
||||
ZCODE_CLI_PATH: launcher,
|
||||
ZCODE_ELECTRON_USER_DATA_PATH: userDataDir,
|
||||
ZCODE_HOME: home,
|
||||
ZCODE_STORAGE_DIR: home
|
||||
}
|
||||
: {
|
||||
CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile,
|
||||
CODEX_CLI_PATH: launcher,
|
||||
CODEX_ELECTRON_USER_DATA_PATH: userDataDir,
|
||||
CODEX_HOME: home,
|
||||
CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile
|
||||
};
|
||||
}
|
||||
const codexEnv = {
|
||||
CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile,
|
||||
CODEX_CLI_PATH: launcher,
|
||||
CODEX_ELECTRON_USER_DATA_PATH: userDataDir,
|
||||
CODEX_HOME: home,
|
||||
CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile
|
||||
};
|
||||
if (spec.kind !== "workbuddy") {
|
||||
return codexEnv;
|
||||
}
|
||||
return {
|
||||
...codexEnv,
|
||||
CCR_WORKBUDDY_MODEL_CATALOG_FILE: modelCatalogFile,
|
||||
...(workbuddyVirtualAuth ? workbuddyVirtualAuthEnv(workbuddyVirtualAuth) : {}),
|
||||
CODEBUDDY_CLI_PATH: launcher,
|
||||
CODEBUDDY_CONFIG_DIR: home,
|
||||
CODEBUDDY_ELECTRON_USER_DATA_PATH: userDataDir,
|
||||
CODEBUDDY_HOME: home,
|
||||
CODEXL_WORKBUDDY_MODEL_CATALOG_FILE: modelCatalogFile,
|
||||
WORKBUDDY_CLI_PATH: launcher,
|
||||
WORKBUDDY_CONFIG_DIR: home,
|
||||
WORKBUDDY_ELECTRON_USER_DATA_PATH: userDataDir,
|
||||
WORKBUDDY_HOME: home
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareWorkbuddyAppVirtualAuth(
|
||||
configDir: string,
|
||||
userDataDir: string,
|
||||
profile: Pick<ProfileConfig, "id" | "name">
|
||||
): WorkbuddyVirtualAuthResult {
|
||||
const homeDir = workbuddyAppVirtualHomeDir(configDir);
|
||||
const authFile = workbuddyAppVirtualAuthFile(homeDir);
|
||||
const account = workbuddyVirtualAuthAccount(profile);
|
||||
const session = {
|
||||
account,
|
||||
accounts: [account],
|
||||
allAccounts: [account],
|
||||
auth: {
|
||||
accessToken: workbuddyVirtualAuthToken,
|
||||
domain: "www.workbuddy.ai",
|
||||
expiresAt: workbuddyVirtualAuthExpiresAt,
|
||||
expiresIn: workbuddyVirtualAuthExpiresAt,
|
||||
lastRefreshTime: Date.now(),
|
||||
refreshExpiresAt: workbuddyVirtualAuthExpiresAt,
|
||||
refreshExpiresIn: workbuddyVirtualAuthExpiresAt,
|
||||
refreshToken: "",
|
||||
scope: "",
|
||||
tokenType: "Bearer"
|
||||
}
|
||||
};
|
||||
const content = `${JSON.stringify(session, null, 2)}\n`;
|
||||
mkdirSync(path.dirname(authFile), { recursive: true });
|
||||
const logoutMarker = `${authFile}.logged-out`;
|
||||
if (existsSync(logoutMarker)) {
|
||||
unlinkSync(logoutMarker);
|
||||
}
|
||||
const previous = existsSync(authFile) ? readFileSync(authFile, "utf8") : undefined;
|
||||
if (previous !== content) {
|
||||
writeFileSync(authFile, content, "utf8");
|
||||
}
|
||||
return {
|
||||
authFile,
|
||||
changed: previous !== content,
|
||||
configDir,
|
||||
homeDir,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
function workbuddyVirtualAuthAccount(profile: Pick<ProfileConfig, "id" | "name">): Record<string, unknown> {
|
||||
return {
|
||||
avatarUrl: "",
|
||||
lastLogin: true,
|
||||
nickname: profile.name?.trim() || "Claude Code Router",
|
||||
pluginEnabled: true,
|
||||
type: "personal",
|
||||
uid: workbuddyVirtualAuthUserId
|
||||
};
|
||||
}
|
||||
|
||||
function workbuddyVirtualAuthEnv(result: WorkbuddyVirtualAuthResult): Record<string, string> {
|
||||
return {
|
||||
APPDATA: path.join(result.homeDir, "AppData", "Roaming"),
|
||||
CCR_WORKBUDDY_VIRTUAL_AUTH_FILE: result.authFile,
|
||||
CODEXL_WORKBUDDY_VIRTUAL_AUTH_FILE: result.authFile,
|
||||
HOME: result.homeDir,
|
||||
LOCALAPPDATA: path.join(result.homeDir, "AppData", "Local"),
|
||||
USERPROFILE: result.homeDir,
|
||||
WORKBUDDY_USER_DATA_DIR: result.userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
function workbuddyAppVirtualHomeDir(configDir: string): string {
|
||||
return path.join(configDir, ".claude-code-router", "workbuddy-app-home");
|
||||
}
|
||||
|
||||
function workbuddyAppVirtualAuthFile(homeDir: string): string {
|
||||
return path.join(workbuddySharedAuthDir(homeDir), `${workbuddyVirtualAuthId}.info`);
|
||||
}
|
||||
|
||||
function workbuddySharedAuthDir(homeDir: string): string {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(homeDir, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(homeDir, "AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth");
|
||||
}
|
||||
return path.join(homeDir, ".local", "share", "CodeBuddyExtension", "Data", "Public", "auth");
|
||||
}
|
||||
|
||||
function sanitizeCodexCompatibleAppEnv(env: NodeJS.ProcessEnv, kind: CodexCompatibleAppKind): void {
|
||||
const blockedPrefixes = kind === "zcode" ? ["CCR_CODEX_", "CODEXL_CODEX_"] : ["CCR_ZCODE_", "CODEXL_ZCODE_"];
|
||||
const blockedPrefixes = kind === "zcode"
|
||||
? ["CCR_CODEX_", "CODEXL_CODEX_", "CCR_WORKBUDDY_", "CODEXL_WORKBUDDY_"]
|
||||
: kind === "workbuddy"
|
||||
? ["CCR_ZCODE_", "CODEXL_ZCODE_"]
|
||||
: ["CCR_ZCODE_", "CODEXL_ZCODE_", "CCR_WORKBUDDY_", "CODEXL_WORKBUDDY_"];
|
||||
for (const key of Object.keys(env)) {
|
||||
if (blockedPrefixes.some((prefix) => key.startsWith(prefix))) {
|
||||
delete env[key];
|
||||
|
|
@ -499,12 +854,28 @@ function sanitizeCodexCompatibleAppEnv(env: NodeJS.ProcessEnv, kind: CodexCompat
|
|||
delete env.CODEX_CLI_PATH;
|
||||
delete env.CODEX_ELECTRON_USER_DATA_PATH;
|
||||
delete env.CODEX_HOME;
|
||||
delete env.WORKBUDDY_CLI_PATH;
|
||||
delete env.WORKBUDDY_ELECTRON_USER_DATA_PATH;
|
||||
delete env.WORKBUDDY_HOME;
|
||||
delete env.CODEBUDDY_CLI_PATH;
|
||||
delete env.CODEBUDDY_CONFIG_DIR;
|
||||
delete env.CODEBUDDY_ELECTRON_USER_DATA_PATH;
|
||||
delete env.CODEBUDDY_HOME;
|
||||
return;
|
||||
}
|
||||
delete env.ZCODE_CLI_PATH;
|
||||
delete env.ZCODE_ELECTRON_USER_DATA_PATH;
|
||||
delete env.ZCODE_HOME;
|
||||
delete env.ZCODE_STORAGE_DIR;
|
||||
if (kind === "codex") {
|
||||
delete env.WORKBUDDY_CLI_PATH;
|
||||
delete env.WORKBUDDY_ELECTRON_USER_DATA_PATH;
|
||||
delete env.WORKBUDDY_HOME;
|
||||
delete env.CODEBUDDY_CLI_PATH;
|
||||
delete env.CODEBUDDY_CONFIG_DIR;
|
||||
delete env.CODEBUDDY_ELECTRON_USER_DATA_PATH;
|
||||
delete env.CODEBUDDY_HOME;
|
||||
}
|
||||
}
|
||||
|
||||
function bundledCodexCliPath(appExecutable: string, spec: CodexCompatibleAppSpec): string | undefined {
|
||||
|
|
|
|||
|
|
@ -4941,6 +4941,15 @@ class RemoteSyncClient {
|
|||
async function readRemoteSyncApiKey() {
|
||||
const direct = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY");
|
||||
if (direct) return direct;
|
||||
const file = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY_FILE");
|
||||
if (file) {
|
||||
try {
|
||||
const content = fs.readFileSync(expandHome(file), "utf8");
|
||||
return String(content || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
|
||||
} catch (error) {
|
||||
log("remote_sync_api_key_file_failed", { error: formatError(error), file });
|
||||
}
|
||||
}
|
||||
const helper = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY_HELPER");
|
||||
if (!helper) return "";
|
||||
return new Promise((resolve) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { codexDefaultBaseUrl, codexImportedModelContextWindow, readCodexLocalMod
|
|||
import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared";
|
||||
import { normalizeProviderBaseUrl } from "@ccr/core/providers/url";
|
||||
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
|
||||
import { filterModelIdsByAllowedModels } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
const fusionModelProviderName = "Fusion";
|
||||
const codexDefaultContextWindow = 128_000;
|
||||
|
|
@ -19,6 +20,10 @@ export type CodexModelCatalog = {
|
|||
models: CodexModelCatalogItem[];
|
||||
};
|
||||
|
||||
export type CodexModelCatalogOptions = {
|
||||
allowedModels?: string[];
|
||||
};
|
||||
|
||||
export type CodexModelCatalogItem = {
|
||||
additional_speed_tiers: unknown[];
|
||||
apply_patch_tool_type: string | null;
|
||||
|
|
@ -57,13 +62,21 @@ export type CodexModelCatalogItem = {
|
|||
web_search_tool_type: string;
|
||||
};
|
||||
|
||||
export function buildCodexModelCatalog(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): CodexModelCatalog {
|
||||
export function buildCodexModelCatalog(
|
||||
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
|
||||
selectedModel?: string,
|
||||
options: CodexModelCatalogOptions = {}
|
||||
): CodexModelCatalog {
|
||||
return {
|
||||
models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config))
|
||||
models: buildCodexModelCatalogIds(config, selectedModel, options).map((model, index) => codexModelCatalogItem(model, index, config))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string[] {
|
||||
export function buildCodexModelCatalogIds(
|
||||
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
|
||||
selectedModel?: string,
|
||||
options: CodexModelCatalogOptions = {}
|
||||
): string[] {
|
||||
const ids: string[] = [];
|
||||
pushUniqueModel(ids, normalizeModelSelector(selectedModel));
|
||||
|
||||
|
|
@ -106,15 +119,30 @@ export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Prov
|
|||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
return filterModelIdsByAllowedModels(
|
||||
config ? {
|
||||
Providers: config.Providers ?? [],
|
||||
virtualModelProfiles: config.virtualModelProfiles ?? []
|
||||
} : undefined,
|
||||
ids,
|
||||
options.allowedModels
|
||||
);
|
||||
}
|
||||
|
||||
export function codexModelCatalogJson(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string {
|
||||
return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`;
|
||||
export function codexModelCatalogJson(
|
||||
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
|
||||
selectedModel?: string,
|
||||
options: CodexModelCatalogOptions = {}
|
||||
): string {
|
||||
return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel, options), null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel);
|
||||
export function codexModelCatalogBase64(
|
||||
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
|
||||
selectedModel?: string,
|
||||
options: CodexModelCatalogOptions = {}
|
||||
): string {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel, options);
|
||||
return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog";
|
||||
import { parseJsoncRecord } from "@ccr/core/agents/local-providers/shared";
|
||||
import { isGatewayProviderEnabled, type AppConfig, type ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
export type KiloProfileConfigWriteResult = {
|
||||
backupFile?: string;
|
||||
|
|
@ -93,7 +94,7 @@ function kiloGatewayOverrides(config: AppConfig, profile: ProfileConfig, token:
|
|||
const providerName = profile.providerName?.trim() || "Claude Code Router";
|
||||
const model = normalizeClientModel(profile.model) || defaultClientModel(config);
|
||||
const modelRef = `${providerId}/${model}`;
|
||||
const models = buildCodexModelCatalogIds(config, model);
|
||||
const models = buildCodexModelCatalogIds(config, model, { allowedModels: profileAllowedModels({ ...profile, model }) });
|
||||
return {
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
model: modelRef,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { isGatewayProviderEnabled, type AppConfig, type GatewayProviderConfig, t
|
|||
import { findModelCatalogEntry, type ModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens } from "@ccr/core/gateway/model-catalog";
|
||||
import { modelRegistryForConfig } from "@ccr/core/routing/model-registry";
|
||||
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
|
||||
import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
export type OpenCodeProfileConfigWriteResult = {
|
||||
backupFile?: string;
|
||||
|
|
@ -97,7 +98,7 @@ function openCodeGatewayOverrides(config: AppConfig, profile: ProfileConfig, tok
|
|||
const providerName = profile.providerName?.trim() || "Claude Code Router";
|
||||
const model = normalizeClientModel(profile.model) || defaultClientModel(config);
|
||||
const modelRef = `${providerId}/${model}`;
|
||||
const models = openCodeModelConfigs(config, model);
|
||||
const models = openCodeModelConfigs(config, { ...profile, model }, model);
|
||||
return {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: modelRef,
|
||||
|
|
@ -120,8 +121,8 @@ function openCodeGatewayOverrides(config: AppConfig, profile: ProfileConfig, tok
|
|||
};
|
||||
}
|
||||
|
||||
function openCodeModelConfigs(config: AppConfig, selectedModel: string): Record<string, unknown> {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel);
|
||||
function openCodeModelConfigs(config: AppConfig, profile: ProfileConfig, selectedModel: string): Record<string, unknown> {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel, { allowedModels: profileAllowedModels(profile) });
|
||||
const resolutionConfig: OpenCodeModelResolutionConfig = {
|
||||
Providers: config.Providers ?? [],
|
||||
virtualModelProfiles: config.virtualModelProfiles ?? []
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog";
|
||||
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
export type PiProfileConfigWriteResult = {
|
||||
changed: boolean;
|
||||
|
|
@ -49,7 +50,7 @@ export function writePiGatewayConfig(
|
|||
const sessionDir = resolvePiSessionDir(configDir, profile);
|
||||
const file = path.join(profileHome, "models.json");
|
||||
const providerId = sanitizeProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const models = piProfileModels(config, defaultModel);
|
||||
const models = piProfileModels(config, profile, defaultModel);
|
||||
const model = models.includes(defaultModel) ? defaultModel : models[0] || defaultModel || "default";
|
||||
const content = `${JSON.stringify(piModelsJson(config, profile, providerId, token, models), null, 2)}\n`;
|
||||
const changed = writeJsonFileIfChanged(file, content);
|
||||
|
|
@ -97,10 +98,10 @@ function piModelConfig(model: string): Record<string, unknown> {
|
|||
};
|
||||
}
|
||||
|
||||
function piProfileModels(config: AppConfig, defaultModel: string): string[] {
|
||||
function piProfileModels(config: AppConfig, profile: ProfileConfig, defaultModel: string): string[] {
|
||||
return uniqueStrings([
|
||||
defaultModel,
|
||||
...buildCodexModelCatalogIds(config, defaultModel)
|
||||
...buildCodexModelCatalogIds(config, defaultModel, { allowedModels: profileAllowedModels({ ...profile, model: defaultModel }) })
|
||||
].filter(Boolean));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import {
|
||||
buildCodexModelCatalog,
|
||||
type CodexModelCatalog
|
||||
type CodexModelCatalog,
|
||||
type CodexModelCatalogOptions
|
||||
} from "@ccr/core/agents/codex/model-catalog";
|
||||
import {
|
||||
findModelCatalogEntry,
|
||||
|
|
@ -23,9 +24,10 @@ type ZcodeModelResolutionConfig = Pick<
|
|||
|
||||
export function buildZcodeModelCatalog(
|
||||
config?: ZcodeModelCatalogConfig,
|
||||
selectedModel?: string
|
||||
selectedModel?: string,
|
||||
options: CodexModelCatalogOptions = {}
|
||||
): CodexModelCatalog {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel);
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel, options);
|
||||
const resolutionConfig = config
|
||||
? {
|
||||
Providers: config.Providers ?? [],
|
||||
|
|
@ -74,7 +76,8 @@ function zcodeModelCatalogEntry(
|
|||
|
||||
export function zcodeModelCatalogJson(
|
||||
config?: ZcodeModelCatalogConfig,
|
||||
selectedModel?: string
|
||||
selectedModel?: string,
|
||||
options: CodexModelCatalogOptions = {}
|
||||
): string {
|
||||
return `${JSON.stringify(buildZcodeModelCatalog(config, selectedModel), null, 2)}\n`;
|
||||
return `${JSON.stringify(buildZcodeModelCatalog(config, selectedModel, options), null, 2)}\n`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import { isGatewayProviderEnabled, type AppConfig, type ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog";
|
||||
import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
export type ZcodeProfileConfigWriteResult = {
|
||||
backupFile?: string;
|
||||
|
|
@ -53,7 +54,7 @@ export function writeZcodeGatewayConfig(
|
|||
const file = resolveZcodeConfigFile(profile);
|
||||
const model = normalizeClientModel(profile.model) || defaultClientModel(config);
|
||||
const providerId = sanitizeZcodeProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const modelCatalog = buildZcodeModelCatalog(config, model);
|
||||
const modelCatalog = buildZcodeModelCatalog(config, model, { allowedModels: profileAllowedModels({ ...profile, model }) });
|
||||
const values: ZcodeGatewayConfigValues = {
|
||||
baseUrl: gatewayEndpoint(config),
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -740,7 +740,7 @@ function sanitizeProfileConfigForDisk(profile: AppConfig["profile"]): AppConfig[
|
|||
...synchronizedProfile,
|
||||
codex,
|
||||
profiles: synchronizedProfile.profiles.map((profileItem) => {
|
||||
if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "kilo" && profileItem.agent !== "zcode") {
|
||||
if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "kilo" && profileItem.agent !== "workbuddy" && profileItem.agent !== "zcode") {
|
||||
return profileItem;
|
||||
}
|
||||
const {
|
||||
|
|
@ -1433,6 +1433,8 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
|
|||
icon: readString(item.icon),
|
||||
id: readString(item.id),
|
||||
enabled: item.enabled === false ? false : undefined,
|
||||
autoFetchModels: readBoolean(item.autoFetchModels ?? item.auto_fetch_models ?? item.autoRefreshModels ?? item.auto_refresh_models),
|
||||
autoFetchKnownModels: parseStringArray(item.autoFetchKnownModels ?? item.auto_fetch_known_models ?? item.autoRefreshKnownModels ?? item.auto_refresh_known_models),
|
||||
modelDescriptions,
|
||||
modelDisplayNames,
|
||||
modelMetadata,
|
||||
|
|
@ -3562,13 +3564,13 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
|
|||
const id = readString(item.id) || `profile-${index + 1}`;
|
||||
const name = readString(item.name) || defaultProfileAgentName(agent);
|
||||
const model = readString(item.model) ?? "";
|
||||
const availableModels = uniqueStrings([
|
||||
model,
|
||||
...parseStringList(item.availableModels ?? item.available_models ?? item.models)
|
||||
]);
|
||||
const parsedAvailableModels = parseStringList(item.availableModels ?? item.available_models ?? item.models);
|
||||
const availableModels = parsedAvailableModels.length > 0
|
||||
? uniqueStrings([model, ...parsedAvailableModels].filter(Boolean))
|
||||
: undefined;
|
||||
const env = parseStringRecord(item.env) ?? {};
|
||||
const parsedSurface = parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto";
|
||||
const surface = agent === "zcode" || agent === CLAUDE_DESIGN_PLUGIN_ID
|
||||
const surface = agent === "workbuddy" || agent === "zcode" || agent === CLAUDE_DESIGN_PLUGIN_ID
|
||||
? "app"
|
||||
: agent === "pi" || agent === "kilo"
|
||||
? "cli"
|
||||
|
|
@ -3594,6 +3596,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
|
|||
haikuModel: readString(item.haikuModel) || readString(item.defaultHaikuModel) || readString(item.smallFastModel) || readString(item.smallModel) || "",
|
||||
id,
|
||||
...(managedCompact !== undefined ? { managedCompact } : {}),
|
||||
...(availableModels ? { availableModels } : {}),
|
||||
model,
|
||||
name,
|
||||
opusModel: readString(item.opusModel) || readString(item.defaultOpusModel) || "",
|
||||
|
|
@ -3609,7 +3612,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
|
|||
if (agent === "grok" || agent === "kimi" || agent === "pi") {
|
||||
return {
|
||||
agent,
|
||||
...(agent === "kimi" ? { availableModels } : {}),
|
||||
...(availableModels ? { availableModels } : {}),
|
||||
enabled,
|
||||
env: codexCompatibleProfileEnv(env),
|
||||
id,
|
||||
|
|
@ -3636,7 +3639,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
|
|||
}
|
||||
|
||||
const appPath = readProfileAppPath(item, agent);
|
||||
const showAllSessions = agent === "zcode" || agent === "opencode" || agent === "kilo"
|
||||
const showAllSessions = agent === "zcode" || agent === "opencode" || agent === "kilo" || agent === "workbuddy"
|
||||
? false
|
||||
: typeof item.showAllSessions === "boolean"
|
||||
? item.showAllSessions
|
||||
|
|
@ -3657,6 +3660,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
|
|||
env: codexCompatibleProfileEnv(env),
|
||||
id,
|
||||
...(managedCompact !== undefined ? { managedCompact } : {}),
|
||||
...(availableModels ? { availableModels } : {}),
|
||||
model,
|
||||
name,
|
||||
providerId: readString(item.providerId) || readString(item.provider) || "claude-code-router",
|
||||
|
|
@ -3720,7 +3724,9 @@ function readProfileAppPath(item: Record<string, unknown>, agent: ProfileConfig[
|
|||
? readString(item.chatgptAppPath) || readString(item.chatgpt_app_path) || readString(item.codexAppPath) || readString(item.codex_app_path)
|
||||
: agent === "opencode"
|
||||
? readString(item.openCodeAppPath) || readString(item.opencodeAppPath) || readString(item.opencode_app_path)
|
||||
: readString(item.zcodeAppPath) || readString(item.zcode_app_path));
|
||||
: agent === "workbuddy"
|
||||
? readString(item.workbuddyAppPath) || readString(item.workbuddy_app_path) || readString(item.workBuddyAppPath) || readString(item.work_buddy_app_path)
|
||||
: readString(item.zcodeAppPath) || readString(item.zcode_app_path));
|
||||
}
|
||||
|
||||
function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined {
|
||||
|
|
@ -3749,6 +3755,9 @@ function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined {
|
|||
if (normalized === "pi" || normalized === "pi-agent" || normalized === "pi agent" || normalized === "pi-coding-agent" || normalized === "pi coding agent") {
|
||||
return "pi";
|
||||
}
|
||||
if (normalized === "workbuddy" || normalized === "work-buddy" || normalized === "work buddy" || normalized === "workbuddy-agent" || normalized === "workbuddy agent") {
|
||||
return "workbuddy";
|
||||
}
|
||||
if (normalized === "zcode" || normalized === "z-code" || normalized === "z code") {
|
||||
return "zcode";
|
||||
}
|
||||
|
|
@ -3790,6 +3799,9 @@ function defaultProfileAgentName(agent: ProfileConfig["agent"]): string {
|
|||
if (agent === "pi") {
|
||||
return "Pi";
|
||||
}
|
||||
if (agent === "workbuddy") {
|
||||
return "Workbuddy";
|
||||
}
|
||||
if (agent === CLAUDE_DESIGN_PLUGIN_ID) {
|
||||
return "Claude Design";
|
||||
}
|
||||
|
|
@ -3803,6 +3815,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string {
|
|||
? "~/.config/opencode/opencode.jsonc"
|
||||
: agent === "kilo"
|
||||
? "~/.config/kilo/kilo.jsonc"
|
||||
: agent === "workbuddy"
|
||||
? "~/.workbuddy/config.toml"
|
||||
: agent === "pi"
|
||||
? "~/.pi/agent"
|
||||
: agent === CLAUDE_DESIGN_PLUGIN_ID
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const PROXY_CA_CERT_FILE = path.join(CERTDIR, "ca.pem");
|
|||
export const PROXY_CA_CERT_DER_FILE = path.join(CERTDIR, "ca.cer");
|
||||
export const PROXY_CA_KEY_FILE = path.join(CERTDIR, "key.pem");
|
||||
export const REQUEST_LOGS_DB_FILE = path.join(DATADIR, "request-logs.sqlite");
|
||||
export const REQUEST_LOG_BODIES_DIR = path.join(DATADIR, "request-log-bodies");
|
||||
export const CONTEXT_ARCHIVE_DB_FILE = path.join(DATADIR, "context-archive.sqlite");
|
||||
export const RAW_TRACE_SPOOL_DIR = path.join(DATADIR, "raw-trace-spool");
|
||||
export const USAGE_DB_FILE = path.join(DATADIR, "usage.sqlite");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export type AppInfo = {
|
|||
platform: string;
|
||||
usageDbFile: string;
|
||||
version: string;
|
||||
workbuddyAppPath?: string;
|
||||
};
|
||||
|
||||
export type AppDataExportResult = {
|
||||
|
|
@ -172,6 +173,8 @@ export type GatewayProviderConfig = {
|
|||
icon?: string;
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
autoFetchModels?: boolean;
|
||||
autoFetchKnownModels?: string[];
|
||||
modelDescriptions?: Record<string, string>;
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
modelMetadata?: Record<string, ProviderModelMetadata>;
|
||||
|
|
@ -656,7 +659,7 @@ export type RouterRuleRewrite = {
|
|||
};
|
||||
|
||||
export const ROUTER_SCRIPT_API_VERSION = 1 as const;
|
||||
export const ROUTER_SCRIPT_MAX_SOURCE_BYTES = 64 * 1024;
|
||||
export const ROUTER_SCRIPT_MAX_SOURCE_BYTES = 5 * 1024 * 1024;
|
||||
export const ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS = 2_000;
|
||||
export const ROUTER_SCRIPT_MAX_TIMEOUT_MS = 30_000;
|
||||
|
||||
|
|
@ -1401,7 +1404,7 @@ export const DEFAULT_TRAY_WIDGETS: TrayWidgetConfig[] = [
|
|||
{ id: "model-share", type: "model-share", variant: DEFAULT_TRAY_COMPONENT_VARIANTS.modelShare }
|
||||
];
|
||||
|
||||
export type ProfileClientKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "zcode" | "claude-design";
|
||||
export type ProfileClientKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "workbuddy" | "zcode" | "claude-design";
|
||||
export type CodexProfileConfigFormat = "legacy" | "separate_profile_files";
|
||||
export type CodexRemoteFrontendMode = "app" | "cli" | "claude-code";
|
||||
export type ProfileScope = "ccr" | "global" | "custom";
|
||||
|
|
@ -1807,6 +1810,7 @@ export type GatewayStatus = {
|
|||
coreEndpoint: string;
|
||||
coreManagedExternally?: boolean;
|
||||
endpoint: string;
|
||||
gatewayManagedExternally?: boolean;
|
||||
lastError?: string;
|
||||
lastStartedAt?: string;
|
||||
networkEndpoints: GatewayNetworkEndpoint[];
|
||||
|
|
@ -1931,10 +1935,12 @@ export type ProxyCertificateInstallResult = {
|
|||
export type ProxyNetworkCaptureState = "complete" | "error" | "pending";
|
||||
|
||||
export type ProxyNetworkBody = {
|
||||
bodyRef?: string;
|
||||
contentType?: string;
|
||||
decodedFrom?: string;
|
||||
encoding: "base64" | "utf8";
|
||||
error?: string;
|
||||
preview?: boolean;
|
||||
sizeBytes: number;
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
|
|
@ -1989,6 +1995,28 @@ export type RequestLogDetailRequest = {
|
|||
|
||||
export type RequestLogBody = ProxyNetworkBody;
|
||||
|
||||
export type RequestLogBodySide = "request" | "response";
|
||||
|
||||
export type RequestLogBodyChunkRequest = {
|
||||
id: number;
|
||||
length?: number;
|
||||
offset?: number;
|
||||
side: RequestLogBodySide;
|
||||
};
|
||||
|
||||
export type RequestLogBodyChunk = {
|
||||
bodyRef?: string;
|
||||
contentType?: string;
|
||||
encoding: "base64" | "utf8";
|
||||
eof: boolean;
|
||||
length: number;
|
||||
nextOffset?: number;
|
||||
offset: number;
|
||||
sizeBytes: number;
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type RequestLogRetryAttempt = {
|
||||
attempt: number;
|
||||
delayMs: number;
|
||||
|
|
@ -2190,7 +2218,7 @@ export type UsageStatsSnapshot = {
|
|||
totals: UsageTotals;
|
||||
};
|
||||
|
||||
export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "zcode" | "claude-design" | "unknown";
|
||||
export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "workbuddy" | "zcode" | "claude-design" | "unknown";
|
||||
|
||||
export type AgentAnalysisFilter = {
|
||||
agent?: AgentKind | "all";
|
||||
|
|
@ -2463,6 +2491,8 @@ export type AgentAnalysisSnapshot = {
|
|||
range: UsageStatsRange;
|
||||
recentRequests: AgentAnalysisRequestRow[];
|
||||
routes: AgentObservabilityRouteRow[];
|
||||
requestScanLimit: number;
|
||||
requestScanTruncated: boolean;
|
||||
scannedRequestCount: number;
|
||||
selectedSession?: AgentAnalysisSessionDetail;
|
||||
sessions: AgentAnalysisSessionRow[];
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const IPC_CHANNELS = {
|
|||
appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures",
|
||||
appGetProxyStatus: "ccr:app:get-proxy-status",
|
||||
appGetRequestLogDetail: "ccr:app:get-request-log-detail",
|
||||
appGetRequestLogBodyChunk: "ccr:app:get-request-log-body-chunk",
|
||||
appGetRequestLogs: "ccr:app:get-request-logs",
|
||||
appGetUpdateStatus: "ccr:app:get-update-status",
|
||||
appGetUsageStats: "ccr:app:get-usage-stats",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ import { randomUUID } from "node:crypto";
|
|||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { ApiKeyConfig, AppConfig, GatewayStatus, RouteScriptTestRequest, RouteScriptTestResult, RouteScriptValidationRequest, RouteScriptValidationResult, RouterRule } from "@ccr/core/contracts/app";
|
||||
import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels } from "@ccr/core/contracts/app";
|
||||
import { loadAppConfig } from "@ccr/core/config/config";
|
||||
import { backendService } from "@ccr/core/plugins/backend-service";
|
||||
import { getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler";
|
||||
import { isAddressInUseMessage, probeExistingCcrGateway, reloadExistingCcrGatewayConfig } from "@ccr/core/gateway/existing-gateway-probe";
|
||||
import { closeServer, formatError } from "@ccr/core/gateway/http/io";
|
||||
import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync";
|
||||
|
|
@ -20,6 +22,8 @@ import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared";
|
|||
import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared";
|
||||
import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline";
|
||||
import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler";
|
||||
import { gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control";
|
||||
import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change";
|
||||
import { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime";
|
||||
import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context";
|
||||
import { compileRouterConfig } from "@ccr/core/routing/config-compiler";
|
||||
|
|
@ -60,6 +64,10 @@ class GatewayService {
|
|||
getBrowserAutomationMcpIntegration: () => this.browserAutomationMcpIntegration,
|
||||
getConfig: () => this.config,
|
||||
getPlugin: () => this.plugin,
|
||||
getRuntimeConfigControlStatus: () => ({
|
||||
...(this.runtimeConfigReloadError ? { lastError: this.runtimeConfigReloadError } : {}),
|
||||
revision: gatewayRuntimeConfigRevision(this.config)
|
||||
}),
|
||||
getStatus: () => ({
|
||||
coreEndpoint: this.status.coreEndpoint,
|
||||
coreManagedExternally: this.status.coreManagedExternally,
|
||||
|
|
@ -69,6 +77,9 @@ class GatewayService {
|
|||
handleRawTraceSync: (request, response) => this.rawTraceSynchronizer.handle(request, response),
|
||||
handleBillingUsageSync: (request, response) => this.billingSynchronizer.handle(request, response),
|
||||
proxyRequest: (request, response, path, apiKey) => this.proxyRequest(request, response, path, apiKey),
|
||||
requestRuntimeConfigReload: (expectedRevision, forceRestart) => {
|
||||
this.schedulePersistedRuntimeConfigReload(expectedRevision, forceRestart);
|
||||
},
|
||||
replayContextArchive: (input) => this.requestPipeline.replayContextArchive(input)
|
||||
});
|
||||
|
||||
|
|
@ -89,11 +100,14 @@ class GatewayService {
|
|||
private child?: ChildProcess;
|
||||
private config?: AppConfig;
|
||||
private coreAuthToken = "";
|
||||
private externalGatewayApiKey?: string;
|
||||
private plugin?: ClaudeCodeRouterPlugin;
|
||||
private readonly rawTraceSynchronizer = new RawTraceSynchronizer({
|
||||
getConfig: () => this.config
|
||||
});
|
||||
private readonly routeScriptRuntime = new RouteScriptRuntime();
|
||||
private runtimeConfigReloadError?: string;
|
||||
private runtimeConfigReloadQueue: Promise<void> = Promise.resolve();
|
||||
private server?: Server;
|
||||
private status: GatewayStatus = {
|
||||
coreEndpoint: "",
|
||||
|
|
@ -210,10 +224,12 @@ class GatewayService {
|
|||
this.status = {
|
||||
...this.status,
|
||||
coreManagedExternally: this.status.coreManagedExternally,
|
||||
gatewayManagedExternally: undefined,
|
||||
lastStartedAt: new Date().toISOString(),
|
||||
pid: this.child?.pid,
|
||||
state: "running"
|
||||
};
|
||||
this.runtimeConfigReloadError = undefined;
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
await this.stop();
|
||||
|
|
@ -226,10 +242,60 @@ class GatewayService {
|
|||
}
|
||||
}
|
||||
|
||||
async ensureStarted(config: AppConfig): Promise<GatewayStatus> {
|
||||
const desiredEndpoint = endpoint(config.gateway.host, config.gateway.port);
|
||||
const currentStatus = this.getStatus();
|
||||
if (currentStatus.state === "running" && currentStatus.endpoint === desiredEndpoint) {
|
||||
if (currentStatus.gatewayManagedExternally) {
|
||||
const existingGateway = await probeExistingCcrGateway(config);
|
||||
if (existingGateway.state === "usable") {
|
||||
this.markExternalGatewayRunning(config, existingGateway.endpoint, existingGateway.apiKey);
|
||||
try {
|
||||
await this.reloadExternalGatewayConfig(config, false);
|
||||
} catch {
|
||||
return this.getStatus();
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
}
|
||||
if (!currentStatus.gatewayManagedExternally) {
|
||||
await this.updateConfig(config);
|
||||
return this.getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
const status = await this.start(config);
|
||||
if (status.state !== "error" || !isAddressInUseMessage(status.lastError)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const existingGateway = await probeExistingCcrGateway(config);
|
||||
if (existingGateway.state !== "usable") {
|
||||
return status;
|
||||
}
|
||||
|
||||
this.markExternalGatewayRunning(config, existingGateway.endpoint, existingGateway.apiKey);
|
||||
try {
|
||||
await this.reloadExternalGatewayConfig(config, false);
|
||||
} catch {
|
||||
return this.getStatus();
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async restart(config: AppConfig): Promise<GatewayStatus> {
|
||||
if (this.status.gatewayManagedExternally) {
|
||||
await this.reloadExternalGatewayConfig(config, true);
|
||||
return this.getStatus();
|
||||
}
|
||||
return this.start(config);
|
||||
}
|
||||
|
||||
async stop(options: GatewayStopOptions = {}): Promise<GatewayStatus> {
|
||||
const child = this.child;
|
||||
this.child = undefined;
|
||||
this.coreAuthToken = "";
|
||||
this.externalGatewayApiKey = undefined;
|
||||
if (child && !child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
|
|
@ -259,6 +325,7 @@ class GatewayService {
|
|||
this.status = {
|
||||
...this.status,
|
||||
coreManagedExternally: undefined,
|
||||
gatewayManagedExternally: undefined,
|
||||
pid: undefined,
|
||||
state: "stopped"
|
||||
};
|
||||
|
|
@ -276,6 +343,11 @@ class GatewayService {
|
|||
|
||||
async updateConfig(config: AppConfig): Promise<void> {
|
||||
assertLoopbackCoreHost(config.gateway.coreHost);
|
||||
if (this.status.gatewayManagedExternally) {
|
||||
await this.reloadExternalGatewayConfig(config, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptValidationErrors = await this.routeScriptRuntime.prepare(config.Router.rules);
|
||||
const nextPlugin = new ClaudeCodeRouterPlugin(config, {
|
||||
scriptRuntime: this.routeScriptRuntime,
|
||||
|
|
@ -293,6 +365,7 @@ class GatewayService {
|
|||
...this.status,
|
||||
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
|
||||
endpoint: endpoint(config.gateway.host, config.gateway.port),
|
||||
gatewayManagedExternally: undefined,
|
||||
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port)
|
||||
};
|
||||
}
|
||||
|
|
@ -412,6 +485,74 @@ class GatewayService {
|
|||
return this.requestPipeline.proxyRequest(request, response, path, apiKey);
|
||||
}
|
||||
|
||||
private async reloadExternalGatewayConfig(config: AppConfig, forceRestart: boolean): Promise<void> {
|
||||
const currentEndpoint = this.status.endpoint || endpoint(config.gateway.host, config.gateway.port);
|
||||
try {
|
||||
const externalGateway = await reloadExistingCcrGatewayConfig(
|
||||
currentEndpoint,
|
||||
config,
|
||||
this.externalGatewayApiKey,
|
||||
{ forceRestart }
|
||||
);
|
||||
this.markExternalGatewayRunning(config, externalGateway.endpoint, externalGateway.apiKey);
|
||||
} catch (error) {
|
||||
const message = `Failed to update the externally managed CCR gateway: ${formatError(error)}`;
|
||||
this.status = {
|
||||
...this.status,
|
||||
lastError: message,
|
||||
state: "error"
|
||||
};
|
||||
throw new Error(message, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePersistedRuntimeConfigReload(expectedRevision: string, forceRestart: boolean): void {
|
||||
this.runtimeConfigReloadError = undefined;
|
||||
this.runtimeConfigReloadQueue = this.runtimeConfigReloadQueue.then(
|
||||
() => this.reloadPersistedRuntimeConfig(expectedRevision, forceRestart),
|
||||
() => this.reloadPersistedRuntimeConfig(expectedRevision, forceRestart)
|
||||
);
|
||||
}
|
||||
|
||||
private async reloadPersistedRuntimeConfig(expectedRevision: string, forceRestart: boolean): Promise<void> {
|
||||
try {
|
||||
const nextConfig = await loadAppConfig();
|
||||
const actualRevision = gatewayRuntimeConfigRevision(nextConfig);
|
||||
if (actualRevision !== expectedRevision) {
|
||||
throw new Error(`Persisted configuration revision ${actualRevision || "(missing)"} does not match the requested revision ${expectedRevision}.`);
|
||||
}
|
||||
const restartRequired = forceRestart || !this.config ||
|
||||
shouldRestartGatewayForRuntimeConfigChange(this.config, nextConfig);
|
||||
if (restartRequired) {
|
||||
const status = await this.start(nextConfig);
|
||||
if (status.state === "error") {
|
||||
throw new Error(status.lastError || "CCR gateway failed to restart with the updated configuration.");
|
||||
}
|
||||
} else {
|
||||
await this.updateConfig(nextConfig);
|
||||
}
|
||||
this.runtimeConfigReloadError = undefined;
|
||||
} catch (error) {
|
||||
this.runtimeConfigReloadError = formatError(error);
|
||||
console.error(`[gateway] Failed to reload persisted runtime configuration: ${this.runtimeConfigReloadError}`);
|
||||
}
|
||||
}
|
||||
|
||||
private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string, apiKey?: string): void {
|
||||
this.config = config;
|
||||
this.child = undefined;
|
||||
this.server = undefined;
|
||||
this.coreAuthToken = "";
|
||||
this.externalGatewayApiKey = apiKey;
|
||||
this.status = {
|
||||
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
|
||||
endpoint: externalEndpoint,
|
||||
gatewayManagedExternally: true,
|
||||
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port),
|
||||
state: "running"
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function assertManagedGatewayStartupContinues(child: ChildProcess, startupFailure: Error | undefined): void {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ import { timingSafeEqual } from "node:crypto";
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
|
||||
import { loadPersistedApiKeys } from "@ccr/core/config/config-repository";
|
||||
import { formatError, readAuthToken, readRemoteControlQueryAuthToken, sendJson } from "@ccr/core/gateway/http/io";
|
||||
import { formatError, readAuthToken, readRemoteControlQueryAuthToken, readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
|
||||
import { estimateLimitUsage, limitRules, readWindowCounter } from "@ccr/core/gateway/limits/window-limiter";
|
||||
import type { ApiKeyAuthorizationResult, ApiKeyLimitRule, ApiKeyLimitUsage } from "@ccr/core/gateway/internal/shared";
|
||||
|
||||
export const claudeCodeWifTokenPath = "/v1/oauth/token";
|
||||
const claudeCodeWifGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
||||
const claudeCodeWifTokenExpiresInSeconds = 3600;
|
||||
const persistedApiKeyCacheTtlMs = 1000;
|
||||
let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined;
|
||||
|
||||
|
|
@ -42,6 +45,50 @@ export async function authorize(
|
|||
return { ok: false };
|
||||
}
|
||||
|
||||
export async function handleClaudeCodeWifTokenRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
config: AppConfig
|
||||
): Promise<void> {
|
||||
const result = await exchangeClaudeCodeWifToken(config, await readRequestBody(request));
|
||||
sendJson(response, result.statusCode, result.payload);
|
||||
}
|
||||
|
||||
export async function exchangeClaudeCodeWifToken(
|
||||
config: AppConfig,
|
||||
requestBody: Buffer
|
||||
): Promise<{ payload: Record<string, unknown>; statusCode: number }> {
|
||||
const body = parseClaudeCodeWifTokenRequestBody(requestBody);
|
||||
const grantType = stringField(body.grant_type);
|
||||
if (grantType !== claudeCodeWifGrantType) {
|
||||
return oauthTokenError(400, "unsupported_grant_type", "Claude Code WIF requires the JWT bearer grant type.");
|
||||
}
|
||||
|
||||
const assertion = stringField(body.assertion);
|
||||
if (!assertion) {
|
||||
return oauthTokenError(400, "invalid_request", "Claude Code WIF assertion is missing.");
|
||||
}
|
||||
|
||||
let apiKeys = await configuredApiKeys(config);
|
||||
let apiKey = findApiKeyByToken(apiKeys, assertion);
|
||||
if (!apiKey) {
|
||||
apiKeys = await configuredApiKeys(config, { refresh: true });
|
||||
apiKey = findApiKeyByToken(apiKeys, assertion);
|
||||
}
|
||||
if (!apiKey || isApiKeyExpired(apiKey)) {
|
||||
return oauthTokenError(401, "invalid_grant", "Claude Code WIF assertion is invalid or expired.");
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
access_token: apiKey.key,
|
||||
expires_in: claudeCodeWifTokenExpiresInSeconds,
|
||||
token_type: "Bearer"
|
||||
},
|
||||
statusCode: 200
|
||||
};
|
||||
}
|
||||
|
||||
export function reserveApiKeyLimits(
|
||||
apiKey: ApiKeyConfig | undefined,
|
||||
request: IncomingMessage,
|
||||
|
|
@ -107,6 +154,48 @@ async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean
|
|||
return result;
|
||||
}
|
||||
|
||||
function parseClaudeCodeWifTokenRequestBody(buffer: Buffer): Record<string, unknown> {
|
||||
const text = buffer.toString("utf8").trim();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
if (text.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(text);
|
||||
const result: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function oauthTokenError(
|
||||
statusCode: number,
|
||||
error: string,
|
||||
errorDescription: string
|
||||
): { payload: Record<string, unknown>; statusCode: number } {
|
||||
return {
|
||||
payload: {
|
||||
error,
|
||||
error_description: errorDescription
|
||||
},
|
||||
statusCode
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise<ApiKeyConfig[]> {
|
||||
const now = Date.now();
|
||||
if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context";
|
|||
import { normalizeRouteScriptResult } from "@ccr/core/routing/route-script-result";
|
||||
import type { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime";
|
||||
import { profileApiKeyId } from "@ccr/core/profiles/api-key";
|
||||
import { isModelAllowedForProfile } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
export { normalizeRouteSelector } from "@ccr/core/routing/model-registry";
|
||||
|
||||
|
|
@ -76,7 +77,8 @@ export class ClaudeCodeRouterPlugin {
|
|||
};
|
||||
applyAgentRequestEnrichers(request, [{
|
||||
enrich: (matchedRequest) => {
|
||||
injectClaudeCodeAgentToolDescription(matchedRequest.body, this.config);
|
||||
const profile = resolveBuiltInAgentProfile(matchedRequest, this.config, "claude-code");
|
||||
injectClaudeCodeAgentToolDescription(matchedRequest.body, this.config, profile);
|
||||
injectClaudeCodeToolHubInstructions(matchedRequest.body, this.config);
|
||||
matchedRequest.builtInClaudeCodeSubagent = removeClaudeCodeBillingSystemHeader(matchedRequest.body);
|
||||
matchedRequest.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(matchedRequest.body);
|
||||
|
|
@ -842,12 +844,12 @@ function systemContainsInstruction(system: unknown, marker: string): boolean {
|
|||
: isRecord(block) && typeof block.text === "string" && block.text.includes(marker));
|
||||
}
|
||||
|
||||
function injectClaudeCodeAgentToolDescription(body: Record<string, unknown>, config: AppConfig): void {
|
||||
function injectClaudeCodeAgentToolDescription(body: Record<string, unknown>, config: AppConfig, profile?: ProfileConfig): void {
|
||||
if (!Array.isArray(body.tools)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instructions = claudeCodeAgentToolInstructions(config);
|
||||
const instructions = claudeCodeAgentToolInstructions(config, profile);
|
||||
if (!instructions) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -914,8 +916,8 @@ function appendDescriptionInstruction(description: string | undefined, instructi
|
|||
return existing ? `${existing}\n\n${instruction}` : instruction;
|
||||
}
|
||||
|
||||
function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; tool: string; workflow: string } | undefined {
|
||||
const modelRows = configuredSubagentModelDescriptionRows(config);
|
||||
function claudeCodeAgentToolInstructions(config: AppConfig, profile?: ProfileConfig): { prompt: string; tool: string; workflow: string } | undefined {
|
||||
const modelRows = configuredSubagentModelDescriptionRows(config, profile);
|
||||
if (modelRows.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -942,9 +944,12 @@ function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; t
|
|||
};
|
||||
}
|
||||
|
||||
function configuredSubagentModelDescriptionRows(config: AppConfig): string[] {
|
||||
function configuredSubagentModelDescriptionRows(config: AppConfig, profile?: ProfileConfig): string[] {
|
||||
const routeByTarget = new Map<string, ClaudeAppGatewayModelRoute>();
|
||||
for (const route of buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions)) {
|
||||
if (!isModelAllowedForProfile(config, profile, route.targetModel)) {
|
||||
continue;
|
||||
}
|
||||
const key = route.targetModel.toLowerCase();
|
||||
const current = routeByTarget.get(key);
|
||||
if (!current || (current.oneMillionContext && !route.oneMillionContext)) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ export const replayableArchiveProtocols: GatewayProviderProtocol[] = [
|
|||
"openai_responses"
|
||||
];
|
||||
|
||||
const compactOutputTokenLimitKeys = [
|
||||
"max_completion_tokens",
|
||||
"maxCompletionTokens",
|
||||
"max_output_tokens",
|
||||
"maxOutputTokens",
|
||||
"max_tokens",
|
||||
"maxTokens"
|
||||
];
|
||||
|
||||
export function parseArchiveBody(body: Buffer | undefined): JsonObject | undefined {
|
||||
if (!body?.length) {
|
||||
return undefined;
|
||||
|
|
@ -59,6 +68,10 @@ function appendTask(
|
|||
removeCodexCompactionTriggers(next);
|
||||
}
|
||||
|
||||
if (options.compactHandoff && replaceClaudeCodeAutoCompactPrompt(next, protocol, task)) {
|
||||
return Buffer.from(`${JSON.stringify(next)}\n`, "utf8");
|
||||
}
|
||||
|
||||
if (protocol === "openai_responses") {
|
||||
if (Array.isArray(next.input)) {
|
||||
next.input = [
|
||||
|
|
@ -98,10 +111,22 @@ export function compactHandoffTask(input: {
|
|||
const footer = archiveHandoffFooter(input);
|
||||
return [
|
||||
"CCR compact handoff task:",
|
||||
"You are the previous-context agent. Produce a concise handoff for a successor agent that will start with a fresh context.",
|
||||
"Preserve the current goal, user constraints, decisions, changed files, completed verification, unresolved problems, and the exact next action.",
|
||||
"Do not continue the task and do not call tools. Do not invent details.",
|
||||
"End the handoff with the following archive access block exactly as written:",
|
||||
"You are the previous-context agent. Produce a concise, action-oriented handoff for a successor agent that will start with a fresh context.",
|
||||
"Do not solve new work. Do not call tools. Do not invent details. Use only the conversation and tool results already in context.",
|
||||
"Do not expand scope: do not propose broad refactors, exhaustive new test suites, documentation work, or extra validation unless the user explicitly requested it or a failing check already requires it.",
|
||||
"Keep the handoff under 1200 words unless an exact failing error needs more space.",
|
||||
"",
|
||||
"Write the handoff with these sections, in this order:",
|
||||
"1. Current objective and status: restate the user's original goal, current branch/deliverable, and whether work is blocked, passing local checks, or unfinished.",
|
||||
"2. Exact remaining blocker: include the latest failing command/test/error if any. If none is known, say none known.",
|
||||
"3. Original acceptance points that still matter: at most 8 bullets copied from explicit user requirements or failing checks. Preserve public API/runtime-shape requirements literally, especially signatures, exports, named fields like TypeName(a, b), callback/event behavior, ordering, and error semantics. Do not invent extra goals.",
|
||||
"4. Work completed (descriptive, not authoritative): changed files and important APIs/classes/functions added or modified. Mark uncertain implementation choices as current implementation details, not requirements.",
|
||||
"5. Verification performed: exact commands/tests run and pass/fail results.",
|
||||
"6. Exact next action: one to three smallest steps. If a blocker exists, fix that first. If local checks pass, do one focused review against the original acceptance points, including public runtime API shape (constructability, isinstance/class checks, attribute vs mapping access, exports), then finish the requested deliverable.",
|
||||
"",
|
||||
"Important: the successor must treat original user requirements and failing checks as higher authority than implementation details in this handoff. If they conflict, revise the implementation instead of preserving the summarized choice.",
|
||||
"If a required detail is important but too large to include, name it explicitly and say that the successor must query archived history before acting on it.",
|
||||
"Include the following archive access block exactly as written:",
|
||||
"",
|
||||
footer
|
||||
].join("\n");
|
||||
|
|
@ -188,6 +213,50 @@ function hasClaudeCodeAutoCompactPrompt(body: JsonObject): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function replaceClaudeCodeAutoCompactPrompt(
|
||||
body: JsonObject,
|
||||
protocol: GatewayProviderProtocol,
|
||||
task: string
|
||||
): boolean {
|
||||
if (protocol !== "anthropic_messages" && protocol !== "openai_chat_completions") {
|
||||
return false;
|
||||
}
|
||||
const messages = Array.isArray(body.messages) ? [...body.messages] : undefined;
|
||||
if (!messages) {
|
||||
return false;
|
||||
}
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
!isRecord(message) ||
|
||||
String(message.role ?? "") !== "user" ||
|
||||
!collectText(message.content).some(isClaudeCodeAutoCompactPromptText)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const nextMessage = cloneJsonObject(message);
|
||||
nextMessage.content = protocol === "anthropic_messages"
|
||||
? [{ text: claudeCodeCompactHandoffTask(task), type: "text" }]
|
||||
: claudeCodeCompactHandoffTask(task);
|
||||
messages[index] = nextMessage;
|
||||
body.messages = messages;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function claudeCodeCompactHandoffTask(task: string): string {
|
||||
return [
|
||||
"CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.",
|
||||
"",
|
||||
"Your response must be plain text: an <analysis> block followed by a <summary> block.",
|
||||
"Keep <analysis> brief: identify what the continuation summary must preserve, but do not solve the task.",
|
||||
"Put the CCR handoff in <summary> and follow this task exactly:",
|
||||
"",
|
||||
task
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isClaudeCodeAutoCompactPromptText(text: string): boolean {
|
||||
const normalized = normalizeWhitespace(text);
|
||||
return normalized.includes("CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.") &&
|
||||
|
|
@ -369,6 +438,7 @@ function assertAppendableTurn(body: JsonObject, protocol: GatewayProviderProtoco
|
|||
function sanitizeCompactHandoffRequest(body: JsonObject, protocol: GatewayProviderProtocol): void {
|
||||
removeCompactSignals(body);
|
||||
removeKeys(body, [
|
||||
...compactOutputTokenLimitKeys,
|
||||
"response_format",
|
||||
"responseFormat",
|
||||
"stop",
|
||||
|
|
@ -385,7 +455,6 @@ function sanitizeCompactHandoffRequest(body: JsonObject, protocol: GatewayProvid
|
|||
"tools"
|
||||
]);
|
||||
normalizeOpenAiResponsesTextFormat(body);
|
||||
raiseMinimumNumericField(body, ["max_output_tokens", "maxOutputTokens", "max_tokens", "maxTokens"], 2048);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +468,6 @@ function sanitizeCompactHandoffRequest(body: JsonObject, protocol: GatewayProvid
|
|||
"toolChoice",
|
||||
"tools"
|
||||
]);
|
||||
raiseMinimumNumericField(body, ["max_tokens", "maxTokens"], 2048);
|
||||
}
|
||||
|
||||
function removeCompactSignals(body: JsonObject): void {
|
||||
|
|
@ -468,14 +536,6 @@ function removeKeys(body: JsonObject, keys: string[]): void {
|
|||
}
|
||||
}
|
||||
|
||||
function raiseMinimumNumericField(body: JsonObject, keys: string[], minimum: number): void {
|
||||
for (const key of keys) {
|
||||
if (typeof body[key] === "number" && Number.isFinite(body[key]) && body[key] < minimum) {
|
||||
body[key] = minimum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendFooterToJson(
|
||||
value: unknown,
|
||||
protocol: GatewayProviderProtocol,
|
||||
|
|
|
|||
|
|
@ -522,7 +522,15 @@ function mergeNoProxy(current: string | undefined, values: string[]): string {
|
|||
}
|
||||
|
||||
export function endpoint(host: string, port: number): string {
|
||||
const endpointHost = host === "0.0.0.0" ? "127.0.0.1" : host;
|
||||
const unwrappedHost = host.startsWith("[") && host.endsWith("]")
|
||||
? host.slice(1, -1)
|
||||
: host;
|
||||
const connectHost = !unwrappedHost || unwrappedHost === "0.0.0.0"
|
||||
? "127.0.0.1"
|
||||
: unwrappedHost === "::"
|
||||
? "::1"
|
||||
: unwrappedHost;
|
||||
const endpointHost = connectHost.includes(":") ? `[${connectHost}]` : connectHost;
|
||||
return `http://${endpointHost}:${port}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
275
packages/core/src/gateway/existing-gateway-probe.ts
Normal file
275
packages/core/src/gateway/existing-gateway-probe.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
|
||||
import { endpoint as gatewayEndpoint } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { gatewayRuntimeConfigControlPath, gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control";
|
||||
|
||||
export type ExistingCcrGatewayProbe =
|
||||
| { endpoint: string; reason?: string; state: "unavailable" }
|
||||
| { endpoint: string; status?: number; state: "not-ccr" }
|
||||
| { endpoint: string; message?: string; status: number; state: "unauthorized" }
|
||||
| { endpoint: string; status: number; state: "unusable" }
|
||||
| { apiKey?: string; endpoint: string; state: "usable" };
|
||||
|
||||
type ExistingGatewayHttpProbe = {
|
||||
payload?: unknown;
|
||||
reason?: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
const existingGatewayFetchAttempts = 3;
|
||||
const runtimeConfigReloadTimeoutMs = 20_000;
|
||||
|
||||
export async function probeExistingCcrGateway(
|
||||
config: Pick<AppConfig, "APIKEY" | "APIKEYS" | "gateway">
|
||||
): Promise<ExistingCcrGatewayProbe> {
|
||||
const endpoint = publicGatewayEndpoint(config);
|
||||
const health = await fetchExistingGateway(endpoint, "/health");
|
||||
let ccrGateway = isCcrGatewayHealth(health.payload);
|
||||
let root: ExistingGatewayHttpProbe | undefined;
|
||||
|
||||
if (!ccrGateway) {
|
||||
root = await fetchExistingGateway(endpoint, "/");
|
||||
ccrGateway = isCcrGatewayRoot(root.payload);
|
||||
}
|
||||
if (!ccrGateway) {
|
||||
if (health.status === undefined && root?.status === undefined) {
|
||||
return { endpoint, reason: health.reason || root?.reason, state: "unavailable" };
|
||||
}
|
||||
return { endpoint, status: health.status ?? root?.status, state: "not-ccr" };
|
||||
}
|
||||
|
||||
const candidates = existingGatewayApiKeyCandidates(config);
|
||||
if (candidates.length === 0) {
|
||||
return { endpoint, message: "No configured CCR API key is available.", status: 401, state: "unauthorized" };
|
||||
}
|
||||
|
||||
let lastUnauthorized: ExistingGatewayHttpProbe | undefined;
|
||||
for (const apiKey of candidates) {
|
||||
const models = await fetchExistingGateway(endpoint, "/v1/models", {
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey.key}`,
|
||||
"user-agent": "Claude Code"
|
||||
}
|
||||
});
|
||||
if (models.status === 200) {
|
||||
return { apiKey: apiKey.key, endpoint, state: "usable" };
|
||||
}
|
||||
if (models.status === 401 || models.status === 403) {
|
||||
lastUnauthorized = models;
|
||||
continue;
|
||||
}
|
||||
return { endpoint, status: models.status ?? 0, state: "unusable" };
|
||||
}
|
||||
|
||||
if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) {
|
||||
return {
|
||||
endpoint,
|
||||
message: readGatewayErrorMessage(lastUnauthorized.payload),
|
||||
status: lastUnauthorized.status,
|
||||
state: "unauthorized"
|
||||
};
|
||||
}
|
||||
|
||||
return { endpoint, status: 0, state: "unusable" };
|
||||
}
|
||||
|
||||
export function publicGatewayEndpoint(config: Pick<AppConfig, "gateway">): string {
|
||||
const host = probeGatewayHost(config.gateway.host);
|
||||
return gatewayEndpoint(host, config.gateway.port);
|
||||
}
|
||||
|
||||
export async function reloadExistingCcrGatewayConfig(
|
||||
currentEndpoint: string,
|
||||
config: AppConfig,
|
||||
currentApiKey: string | undefined,
|
||||
options: { forceRestart?: boolean } = {}
|
||||
): Promise<{ apiKey: string; endpoint: string }> {
|
||||
const configRevision = gatewayRuntimeConfigRevision(config);
|
||||
if (!configRevision) {
|
||||
throw new Error("Cannot determine the saved CCR configuration revision.");
|
||||
}
|
||||
const currentKey = currentApiKey?.trim();
|
||||
if (!currentKey) {
|
||||
throw new Error("The API key accepted by the externally managed CCR gateway is unavailable.");
|
||||
}
|
||||
|
||||
if (options.forceRestart !== true) {
|
||||
const currentStatus = await fetchExistingGateway(currentEndpoint, gatewayRuntimeConfigControlPath, {
|
||||
headers: { authorization: `Bearer ${currentKey}` }
|
||||
}, 1, 700);
|
||||
if (currentStatus.status === 200 && isRecord(currentStatus.payload) &&
|
||||
currentStatus.payload.revision === configRevision) {
|
||||
return { apiKey: currentKey, endpoint: publicGatewayEndpoint(config) };
|
||||
}
|
||||
}
|
||||
|
||||
const submission = await fetchExistingGateway(currentEndpoint, gatewayRuntimeConfigControlPath, {
|
||||
body: JSON.stringify({
|
||||
configRevision,
|
||||
forceRestart: options.forceRestart === true
|
||||
}),
|
||||
headers: {
|
||||
authorization: `Bearer ${currentKey}`,
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
if (submission.status === 404) {
|
||||
throw new Error("The running CCR gateway does not support runtime configuration reloads. Restart that gateway process once, then try again.");
|
||||
}
|
||||
if (submission.status !== 200 && submission.status !== 202) {
|
||||
const detail = readGatewayErrorMessage(submission.payload) || submission.reason ||
|
||||
`HTTP ${submission.status ?? 0}`;
|
||||
throw new Error(`The running CCR gateway rejected the configuration reload request: ${detail}`);
|
||||
}
|
||||
|
||||
const expectedEndpoint = publicGatewayEndpoint(config);
|
||||
const apiKeys = externalGatewayReloadApiKeys(config, currentKey);
|
||||
const deadline = Date.now() + runtimeConfigReloadTimeoutMs;
|
||||
let lastReason: string | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
for (const apiKey of apiKeys) {
|
||||
const status = await fetchExistingGateway(expectedEndpoint, gatewayRuntimeConfigControlPath, {
|
||||
headers: { authorization: `Bearer ${apiKey}` }
|
||||
}, 1, 700);
|
||||
if (status.status === 200 && isRecord(status.payload)) {
|
||||
if (status.payload.revision === configRevision) {
|
||||
return { apiKey, endpoint: expectedEndpoint };
|
||||
}
|
||||
if (typeof status.payload.lastError === "string" && status.payload.lastError.trim()) {
|
||||
throw new Error(`The running CCR gateway could not apply the saved configuration: ${status.payload.lastError}`);
|
||||
}
|
||||
}
|
||||
if (status.status !== 401 && status.status !== 403) {
|
||||
lastReason = status.reason || (status.status ? `HTTP ${status.status}` : lastReason);
|
||||
}
|
||||
}
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(`Timed out waiting for the running CCR gateway to load configuration revision ${configRevision}${lastReason ? ` (${lastReason})` : ""}.`);
|
||||
}
|
||||
|
||||
export function isAddressInUseMessage(message: string | undefined): boolean {
|
||||
return /\bEADDRINUSE\b/i.test(message || "");
|
||||
}
|
||||
|
||||
async function fetchExistingGateway(
|
||||
endpoint: string,
|
||||
pathname: string,
|
||||
init: RequestInit = {},
|
||||
attempts = existingGatewayFetchAttempts,
|
||||
timeoutMs = 1200
|
||||
): Promise<ExistingGatewayHttpProbe> {
|
||||
let reason: string | undefined;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(new URL(pathname, endpoint).toString(), {
|
||||
...init,
|
||||
signal: controller.signal
|
||||
});
|
||||
return {
|
||||
payload: await readResponseJson(response),
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
reason = formatError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
return { reason };
|
||||
}
|
||||
|
||||
function externalGatewayReloadApiKeys(
|
||||
config: Pick<AppConfig, "APIKEY" | "APIKEYS">,
|
||||
currentApiKey: string
|
||||
): string[] {
|
||||
const apiKeys = existingGatewayApiKeyCandidates(config).map((candidate) => candidate.key);
|
||||
if (!apiKeys.includes(currentApiKey)) {
|
||||
apiKeys.push(currentApiKey);
|
||||
}
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
function existingGatewayApiKeyCandidates(config: Pick<AppConfig, "APIKEY" | "APIKEYS">): ApiKeyConfig[] {
|
||||
const candidates = [
|
||||
...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),
|
||||
...(config.APIKEY ? [{ createdAt: "", id: "legacy", key: config.APIKEY, name: "Legacy API Key" }] : [])
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const result: ApiKeyConfig[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const key = candidate.key?.trim();
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push({ ...candidate, key });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCcrGatewayHealth(value: unknown): boolean {
|
||||
return isRecord(value) &&
|
||||
typeof value.status === "string" &&
|
||||
typeof value.core === "string";
|
||||
}
|
||||
|
||||
function isCcrGatewayRoot(value: unknown): boolean {
|
||||
return isRecord(value) &&
|
||||
(value.name === "claude-code-router" ||
|
||||
value.plugin === "claude-code-router" ||
|
||||
(value.core === "next-ai-gateway" && Array.isArray(value.endpoints)));
|
||||
}
|
||||
|
||||
function readGatewayErrorMessage(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const error = value.error;
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (isRecord(error) && typeof error.message === "string") {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof value.message === "string") {
|
||||
return value.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readResponseJson(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function probeGatewayHost(host: string): string {
|
||||
if (!host || host === "0.0.0.0") {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
if (host === "::") {
|
||||
return "::1";
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function wait(milliseconds: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
|
@ -624,7 +624,10 @@ function contextArchiveToolContinuationGuidance(toolName: string): string {
|
|||
return [
|
||||
"CCR context archive is available for this compacted continuation.",
|
||||
`If the compact handoff indicates missing historical details are stored in archived history, use the ${toolName} tool when that history is needed.`,
|
||||
"Use ordinary task judgment: answer directly when the compact handoff and retained tail are sufficient; call the history tool when exact pre-compaction details are needed."
|
||||
"Use ordinary task judgment: answer directly when the compact handoff and retained tail are sufficient; call the history tool when exact pre-compaction details are needed.",
|
||||
"Before implementing or finalizing behavior that depends on pre-compaction requirements, tests, file edits, failure output, or edge cases that are not explicit in the handoff, ask the archive for the missing details instead of relying on memory.",
|
||||
"Treat the handoff's implementation details as evidence, not authority: original user requirements and failing checks take precedence, especially for public API runtime shape such as constructors, isinstance/class checks, attribute access, exports, ordering, callbacks, and error semantics.",
|
||||
"After compact, prefer the shortest path to the user's explicit deliverable: fix known failures, run focused verification, and avoid starting broad new test plans, refactors, or extra validation unless they are already required by the handoff or a failing check."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { isGatewayProviderEnabled } from "@ccr/core/contracts/app";
|
||||
import type { ApiKeyConfig, AppConfig, ProviderModelMetadata } from "@ccr/core/contracts/app";
|
||||
import type { ApiKeyConfig, AppConfig, ProfileConfig, ProviderModelMetadata } from "@ccr/core/contracts/app";
|
||||
import { buildClaudeAppGatewayModelRoutes, resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes";
|
||||
import { modelRegistryForConfig, normalizeRouteSelector } from "@ccr/core/routing/model-registry";
|
||||
import { findModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens, readCatalogCapability, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog";
|
||||
|
|
@ -16,6 +16,7 @@ import { parseJsonObjectSafe, serializeJsonBodyWithModel } from "@ccr/core/gatew
|
|||
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
|
||||
import { contextArchiveConfigForApiKey, contextArchiveMcpEnabled } from "@ccr/core/gateway/context-archive";
|
||||
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
|
||||
import { filterModelIdsForProfile, isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
|
||||
export function shouldServeGatewayModelsResponse(method: string, path: string): boolean {
|
||||
|
|
@ -24,6 +25,12 @@ export function shouldServeGatewayModelsResponse(method: string, path: string):
|
|||
}
|
||||
|
||||
|
||||
export function shouldServeClaudeCliBootstrapResponse(method: string, path: string): boolean {
|
||||
return (method || "GET").toUpperCase() === "GET" &&
|
||||
normalizeGatewayPathname(path) === "/api/claude_cli/bootstrap";
|
||||
}
|
||||
|
||||
|
||||
export function prepareClaudeCodeDiscoveredModelRequest(
|
||||
config: AppConfig,
|
||||
headers: IncomingHttpHeaders,
|
||||
|
|
@ -92,18 +99,112 @@ export function prepareClaudeAppDiscoveredModelRequest(
|
|||
export function createGatewayModelsResponse(config: AppConfig, headers: IncomingHttpHeaders, apiKey?: ApiKeyConfig): Record<string, unknown> {
|
||||
const contextArchiveConfig = contextArchiveConfigForApiKey(config, apiKey);
|
||||
const contextArchiveCompact = Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig));
|
||||
const profile = profileForApiKey(config, apiKey);
|
||||
if (isClaudeAppApiKey(apiKey)) {
|
||||
return createClaudeAppGatewayModelsResponse(config, { contextArchiveCompact });
|
||||
return createClaudeAppGatewayModelsResponse(config, { contextArchiveCompact, profile });
|
||||
}
|
||||
if (isClaudeCodeUserAgent(headers)) {
|
||||
return createClaudeAppGatewayModelsResponse(config, { claudeCode: true, contextArchiveCompact });
|
||||
return createClaudeAppGatewayModelsResponse(config, { claudeCode: true, contextArchiveCompact, profile });
|
||||
}
|
||||
return createOpenAICompatibleGatewayModelsResponse(config);
|
||||
return createOpenAICompatibleGatewayModelsResponse(config, profile);
|
||||
}
|
||||
|
||||
|
||||
function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record<string, unknown> {
|
||||
const data = buildGatewayDiscoverableModelIds(config).map((id) => {
|
||||
export function createClaudeCliBootstrapResponse(config: AppConfig, apiKey?: ApiKeyConfig): Record<string, unknown> {
|
||||
const profile = profileForApiKey(config, apiKey);
|
||||
const windows = createClaudeCliAutoCompactWindows(config, profile);
|
||||
return {
|
||||
additional_model_options: createClaudeCliAdditionalModelOptions(config, profile),
|
||||
auto_compact_windows: windows,
|
||||
client_data: {
|
||||
rowan_thicket: { ...windows }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
type ClaudeCliAdditionalModelOption = {
|
||||
capabilities: Record<string, unknown>;
|
||||
created_at: string;
|
||||
description: string;
|
||||
display_name: string;
|
||||
id: string;
|
||||
max_input_tokens: number;
|
||||
max_tokens: number;
|
||||
model: string;
|
||||
name: string;
|
||||
type: "model";
|
||||
};
|
||||
|
||||
|
||||
function createClaudeCliAdditionalModelOptions(config: AppConfig, profile?: ProfileConfig): ClaudeCliAdditionalModelOption[] {
|
||||
const options: ClaudeCliAdditionalModelOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions);
|
||||
for (const route of routes) {
|
||||
if (!isModelAllowedForProfile(config, profile, route.targetModel)) {
|
||||
continue;
|
||||
}
|
||||
const id = route.oneMillionContext ? claudeCodeOneMillionContextModelId(route.id) : route.id;
|
||||
const normalized = id.trim();
|
||||
const key = normalized.toLowerCase();
|
||||
if (!normalized || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel);
|
||||
const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId);
|
||||
const maxInputTokens = claudeGatewayModelContextWindow(
|
||||
modelDiscovery.catalogEntry,
|
||||
route.oneMillionContext,
|
||||
modelDiscovery.metadata
|
||||
);
|
||||
const maxOutputTokens = claudeGatewayModelMaxOutputTokens(modelDiscovery.catalogEntry, modelDiscovery.metadata);
|
||||
const name = route.oneMillionContext ? `${route.displayName} (1M context)` : route.displayName;
|
||||
options.push({
|
||||
capabilities: createClaudeCodeModelCapabilities(modelDiscovery.catalogEntry, {
|
||||
maxInputTokens,
|
||||
oneMillionContext: route.oneMillionContext,
|
||||
...providerModelCapabilityOverrides(modelDiscovery.metadata)
|
||||
}),
|
||||
created_at: "1970-01-01T00:00:00Z",
|
||||
description: formatClaudeCliAdditionalModelDescription(maxInputTokens),
|
||||
display_name: name,
|
||||
id: normalized,
|
||||
max_input_tokens: maxInputTokens,
|
||||
max_tokens: maxOutputTokens,
|
||||
model: normalized,
|
||||
name,
|
||||
type: "model"
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
function formatClaudeCliAdditionalModelDescription(maxInputTokens: number): string {
|
||||
const tokens = positiveInteger(maxInputTokens);
|
||||
if (!tokens) {
|
||||
return "CCR gateway model";
|
||||
}
|
||||
return `${formatCompactTokenCount(tokens)} context window`;
|
||||
}
|
||||
|
||||
|
||||
function formatCompactTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000 && tokens % 1_000_000 === 0) {
|
||||
return `${tokens / 1_000_000}M`;
|
||||
}
|
||||
if (tokens >= 1_000 && tokens % 1_000 === 0) {
|
||||
return `${tokens / 1_000}k`;
|
||||
}
|
||||
return `${tokens.toLocaleString("en-US")} tokens`;
|
||||
}
|
||||
|
||||
|
||||
function createOpenAICompatibleGatewayModelsResponse(config: AppConfig, profile?: ProfileConfig): Record<string, unknown> {
|
||||
const data = buildGatewayDiscoverableModelIds(config, profile).map((id) => {
|
||||
const catalogEntry = findModelCatalogEntry(id);
|
||||
return {
|
||||
id,
|
||||
|
|
@ -122,18 +223,74 @@ function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record<
|
|||
}
|
||||
|
||||
|
||||
export function createClaudeCliAutoCompactWindows(config: AppConfig, profile?: ProfileConfig): Record<string, number> {
|
||||
const windows: Record<string, number> = {};
|
||||
const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions);
|
||||
for (const route of routes) {
|
||||
if (!isModelAllowedForProfile(config, profile, route.targetModel)) {
|
||||
continue;
|
||||
}
|
||||
const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel);
|
||||
const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId);
|
||||
const maxInputTokens = claudeGatewayModelContextWindow(
|
||||
modelDiscovery.catalogEntry,
|
||||
route.oneMillionContext,
|
||||
modelDiscovery.metadata
|
||||
);
|
||||
const compactWindow = claudeCliAutoCompactWindow(maxInputTokens);
|
||||
if (!compactWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routeId = route.oneMillionContext ? claudeCodeOneMillionContextModelId(route.id) : route.id;
|
||||
for (const id of uniqueStrings([routeId, route.id, route.legacyId, ...(route.legacyIds ?? [])])) {
|
||||
const key = id.trim();
|
||||
if (key) {
|
||||
assignClaudeCliAutoCompactWindow(windows, key, compactWindow);
|
||||
if (compactWindow > claudeCodeUnknownModelDefaultContextWindow) {
|
||||
assignClaudeCliAutoCompactWindow(windows, claudeCodeOneMillionContextModelId(key), compactWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
|
||||
function assignClaudeCliAutoCompactWindow(windows: Record<string, number>, key: string, compactWindow: number): void {
|
||||
windows[key] = compactWindow;
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey !== key) {
|
||||
windows[lowerKey] = compactWindow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const claudeCodeUnknownModelDefaultContextWindow = 200_000;
|
||||
|
||||
|
||||
function claudeCliAutoCompactWindow(maxInputTokens: number): number | undefined {
|
||||
const window = positiveInteger(maxInputTokens);
|
||||
if (!window) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.min(1_000_000, Math.max(100_000, window));
|
||||
}
|
||||
|
||||
|
||||
function createClaudeAppGatewayModelsResponse(
|
||||
config: AppConfig,
|
||||
options: { claudeCode?: boolean; contextArchiveCompact?: boolean } = {}
|
||||
options: { claudeCode?: boolean; contextArchiveCompact?: boolean; profile?: ProfileConfig } = {}
|
||||
): Record<string, unknown> {
|
||||
const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions);
|
||||
const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions)
|
||||
.filter((route) => isModelAllowedForProfile(config, options.profile, route.targetModel));
|
||||
const data = routes.map((route) => {
|
||||
const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel);
|
||||
const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId);
|
||||
const catalogEntry = modelDiscovery.catalogEntry;
|
||||
const modelMetadata = modelDiscovery.metadata;
|
||||
const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext, modelMetadata);
|
||||
const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry);
|
||||
const maxOutputTokens = claudeGatewayModelMaxOutputTokens(catalogEntry, modelMetadata);
|
||||
const exposeOneMillionContextVariant = options.claudeCode && route.oneMillionContext;
|
||||
return {
|
||||
id: exposeOneMillionContextVariant ? claudeCodeOneMillionContextModelId(route.id) : route.id,
|
||||
|
|
@ -162,8 +319,12 @@ function createClaudeAppGatewayModelsResponse(
|
|||
}
|
||||
|
||||
|
||||
function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact = false): Record<string, unknown> {
|
||||
const models = buildClaudeCodeDiscoverableModels(config);
|
||||
function createClaudeCodeModelsResponse(
|
||||
config: AppConfig,
|
||||
contextArchiveCompact = false,
|
||||
profile?: ProfileConfig
|
||||
): Record<string, unknown> {
|
||||
const models = buildClaudeCodeDiscoverableModels(config, profile);
|
||||
const data = models.map((model) => {
|
||||
const claudeId = claudeCodeDiscoveryModelId(model.id);
|
||||
const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id);
|
||||
|
|
@ -171,7 +332,7 @@ function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact
|
|||
const catalogEntry = modelDiscovery.catalogEntry;
|
||||
const modelMetadata = modelDiscovery.metadata;
|
||||
const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext, modelMetadata);
|
||||
const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry);
|
||||
const maxOutputTokens = claudeGatewayModelMaxOutputTokens(catalogEntry, modelMetadata);
|
||||
return {
|
||||
id: claudeId,
|
||||
capabilities: createClaudeCodeModelCapabilities(catalogEntry, {
|
||||
|
|
@ -198,7 +359,11 @@ function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact
|
|||
|
||||
export function createClaudeCodeModelsResponseForTest(config: AppConfig, apiKey?: ApiKeyConfig): Record<string, unknown> {
|
||||
const contextArchiveConfig = contextArchiveConfigForApiKey(config, apiKey);
|
||||
return createClaudeCodeModelsResponse(config, Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig)));
|
||||
return createClaudeCodeModelsResponse(
|
||||
config,
|
||||
Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig)),
|
||||
profileForApiKey(config, apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -219,6 +384,14 @@ function claudeGatewayModelContextWindow(
|
|||
}
|
||||
|
||||
|
||||
function claudeGatewayModelMaxOutputTokens(
|
||||
entry: ModelCatalogEntry | undefined,
|
||||
metadata?: ProviderModelMetadata
|
||||
): number {
|
||||
return positiveInteger(metadata?.maxOutputTokens) ?? modelCatalogMaxOutputTokens(entry);
|
||||
}
|
||||
|
||||
|
||||
function effectiveProviderContextWindow(metadata: ProviderModelMetadata | undefined): number | undefined {
|
||||
const contextWindow = positiveInteger(metadata?.contextWindow) ?? positiveInteger(metadata?.maxContextWindow);
|
||||
if (!contextWindow) {
|
||||
|
|
@ -302,12 +475,12 @@ function positiveInteger(value: number | undefined): number | undefined {
|
|||
}
|
||||
|
||||
|
||||
function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] {
|
||||
return buildGatewayDiscoverableModelIds(config);
|
||||
function buildClaudeCodeDiscoverableModelIds(config: AppConfig, profile?: ProfileConfig): string[] {
|
||||
return buildGatewayDiscoverableModelIds(config, profile);
|
||||
}
|
||||
|
||||
|
||||
function buildGatewayDiscoverableModelIds(config: AppConfig): string[] {
|
||||
function buildGatewayDiscoverableModelIds(config: AppConfig, profile?: ProfileConfig): string[] {
|
||||
const baseEntries: Array<{ modelName: string; providerName: string }> = [];
|
||||
for (const provider of config.Providers) {
|
||||
const providerName = provider.name?.trim();
|
||||
|
|
@ -324,19 +497,19 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] {
|
|||
}
|
||||
|
||||
const ids = baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`);
|
||||
for (const profile of config.virtualModelProfiles ?? []) {
|
||||
if (!isVisibleVirtualModelProfile(profile)) {
|
||||
for (const virtualProfile of config.virtualModelProfiles ?? []) {
|
||||
if (!isVisibleVirtualModelProfile(virtualProfile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of baseEntries) {
|
||||
for (const prefix of profile.match?.prefixes ?? []) {
|
||||
for (const prefix of virtualProfile.match?.prefixes ?? []) {
|
||||
const normalizedPrefix = prefix.trim();
|
||||
if (normalizedPrefix) {
|
||||
ids.push(`${entry.providerName}/${normalizedPrefix}${entry.modelName}`);
|
||||
}
|
||||
}
|
||||
for (const suffix of profile.match?.suffixes ?? []) {
|
||||
for (const suffix of virtualProfile.match?.suffixes ?? []) {
|
||||
const normalizedSuffix = suffix.trim();
|
||||
if (normalizedSuffix) {
|
||||
ids.push(`${entry.providerName}/${entry.modelName}${normalizedSuffix}`);
|
||||
|
|
@ -344,7 +517,7 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
for (const alias of profile.match?.exactAliases ?? []) {
|
||||
for (const alias of virtualProfile.match?.exactAliases ?? []) {
|
||||
const normalizedAlias = alias.trim();
|
||||
if (!normalizedAlias) {
|
||||
continue;
|
||||
|
|
@ -353,7 +526,7 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
return uniqueStrings(ids);
|
||||
return filterModelIdsForProfile(config, uniqueStrings(ids), profile);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -363,7 +536,7 @@ function gatewayModelOwner(id: string): string {
|
|||
}
|
||||
|
||||
|
||||
function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscoverableModel[] {
|
||||
function buildClaudeCodeDiscoverableModels(config: AppConfig, profile?: ProfileConfig): ClaudeCodeDiscoverableModel[] {
|
||||
const seen = new Set<string>();
|
||||
const models: ClaudeCodeDiscoverableModel[] = [];
|
||||
|
||||
|
|
@ -380,7 +553,7 @@ function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscove
|
|||
models.push({ id: normalized, oneMillionContext });
|
||||
};
|
||||
|
||||
for (const id of buildClaudeCodeDiscoverableModelIds(config)) {
|
||||
for (const id of buildClaudeCodeDiscoverableModelIds(config, profile)) {
|
||||
pushModel(id, hasClaudeCodeOneMillionContextSuffix(id));
|
||||
const baseId = stripClaudeCodeOneMillionContextSuffix(id);
|
||||
if (!hasClaudeCodeOneMillionContextSuffix(id) && gatewayModelSupportsOneMillionContext(config, baseId)) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { LEGACY_GROK_MEDIA_MCP_PATH, MEDIA_TOOLS_MCP_PATH } from "@ccr/core/mcp/
|
|||
import { BROWSER_AUTOMATION_MCP_PATH, browserAutomationMcpEnabled } from "@ccr/core/mcp/toolhub-config";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
import { createClaudeCliBootstrapResponse, shouldServeClaudeCliBootstrapResponse } from "@ccr/core/gateway/features/model-discovery";
|
||||
import {
|
||||
contextArchiveConfigForApiKey,
|
||||
handleContextArchiveMcpRequest,
|
||||
|
|
@ -13,9 +14,12 @@ import {
|
|||
type ContextArchiveReplayExecutor
|
||||
} from "@ccr/core/gateway/context-archive";
|
||||
import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service";
|
||||
import { authorize, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer";
|
||||
import { gatewayRuntimeConfigControlPath } from "@ccr/core/gateway/runtime-config-control";
|
||||
import { authorize, claudeCodeWifTokenPath, handleClaudeCodeWifTokenRequest, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer";
|
||||
import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
|
||||
import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { requestLogRequestedModel } from "@ccr/core/observability/request-log-model";
|
||||
import { isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist";
|
||||
import { applyCors, shouldServeGatewayRequest } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { billingUsageSyncPath, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared";
|
||||
import type { BrowserAutomationMcpIntegration } from "@ccr/core/gateway/internal/shared";
|
||||
|
|
@ -24,10 +28,12 @@ export type GatewayHttpRequestHandlerDependencies = {
|
|||
getBrowserAutomationMcpIntegration: () => BrowserAutomationMcpIntegration | undefined;
|
||||
getConfig: () => AppConfig | undefined;
|
||||
getPlugin: () => ClaudeCodeRouterPlugin | undefined;
|
||||
getRuntimeConfigControlStatus: () => { lastError?: string; revision?: string };
|
||||
getStatus: () => { coreEndpoint: string; coreManagedExternally?: boolean; endpoint: string; state: string };
|
||||
handleBillingUsageSync: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
||||
handleRawTraceSync: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
||||
proxyRequest: (request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) => Promise<void>;
|
||||
requestRuntimeConfigReload: (expectedRevision: string, forceRestart: boolean) => void;
|
||||
replayContextArchive: ContextArchiveReplayExecutor;
|
||||
};
|
||||
|
||||
|
|
@ -37,10 +43,12 @@ export class GatewayHttpRequestHandler {
|
|||
private get browserAutomationMcpIntegration() { return this.dependencies.getBrowserAutomationMcpIntegration(); }
|
||||
private get config() { return this.dependencies.getConfig(); }
|
||||
private get plugin() { return this.dependencies.getPlugin(); }
|
||||
private get runtimeConfigControlStatus() { return this.dependencies.getRuntimeConfigControlStatus(); }
|
||||
private get status() { return this.dependencies.getStatus(); }
|
||||
private handleBillingUsageSync(request: IncomingMessage, response: ServerResponse) { return this.dependencies.handleBillingUsageSync(request, response); }
|
||||
private handleRawTraceSync(request: IncomingMessage, response: ServerResponse) { return this.dependencies.handleRawTraceSync(request, response); }
|
||||
private proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) { return this.dependencies.proxyRequest(request, response, path, apiKey); }
|
||||
private requestRuntimeConfigReload(expectedRevision: string, forceRestart: boolean) { return this.dependencies.requestRuntimeConfigReload(expectedRevision, forceRestart); }
|
||||
private replayContextArchive(input: Parameters<ContextArchiveReplayExecutor>[0]) { return this.dependencies.replayContextArchive(input); }
|
||||
|
||||
async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
|
|
@ -72,6 +80,46 @@ export class GatewayHttpRequestHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
if (path === gatewayRuntimeConfigControlPath) {
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
if (!this.config.APIKEY || authorization.apiKey?.key !== this.config.APIKEY) {
|
||||
sendJson(response, 403, { error: { message: "The primary CCR API key is required for runtime configuration control." } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET") {
|
||||
sendJson(response, 200, {
|
||||
...this.runtimeConfigControlStatus,
|
||||
state: this.status.state
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
sendJson(response, 405, { error: { message: "Method not allowed." } });
|
||||
return;
|
||||
}
|
||||
const body = parseJsonObject(await readRequestBody(request));
|
||||
const expectedRevision = typeof body.configRevision === "string"
|
||||
? body.configRevision.trim()
|
||||
: "";
|
||||
if (!/^[a-f0-9]{64}$/i.test(expectedRevision)) {
|
||||
sendJson(response, 400, { error: { message: "A valid configRevision is required." } });
|
||||
return;
|
||||
}
|
||||
const forceRestart = body.forceRestart === true;
|
||||
response.once("finish", () => {
|
||||
this.requestRuntimeConfigReload(expectedRevision, forceRestart);
|
||||
});
|
||||
sendJson(response, 202, {
|
||||
accepted: true,
|
||||
configRevision: expectedRevision,
|
||||
restarting: forceRestart
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) {
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
|
|
@ -191,7 +239,7 @@ export class GatewayHttpRequestHandler {
|
|||
if (path === "/") {
|
||||
sendJson(response, 200, {
|
||||
core: "next-ai-gateway",
|
||||
endpoints: ["POST /mcp", "POST /v1/messages", "POST /v1/messages/count_tokens", "GET /models", "GET /v1/models"],
|
||||
endpoints: ["POST /v1/oauth/token", "GET /api/claude_cli/bootstrap", "POST /mcp", "POST /v1/messages", "POST /v1/messages/count_tokens", "GET /models", "GET /v1/models"],
|
||||
name: "claude-code-router",
|
||||
plugin: "claude-code-router",
|
||||
wrapperPlugins: this.config.plugins.filter((plugin) => plugin.enabled !== false).map((plugin) => plugin.id)
|
||||
|
|
@ -199,14 +247,36 @@ export class GatewayHttpRequestHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && (path === claudeCodeWifTokenPath || path === `${claudeCodeWifTokenPath}/`)) {
|
||||
await handleClaudeCodeWifTokenRequest(request, response, this.config);
|
||||
return;
|
||||
}
|
||||
|
||||
const claudeCliBootstrapRequest = shouldServeClaudeCliBootstrapResponse(request.method ?? "GET", path);
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (claudeCliBootstrapRequest) {
|
||||
sendJson(response, 200, createClaudeCliBootstrapResponse(this.config, authorization.apiKey));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && path === "/v1/messages/count_tokens") {
|
||||
const requestBody = await readRequestBody(request);
|
||||
const body = parseJsonObject(requestBody);
|
||||
const requestedModel = requestLogRequestedModel(requestBody, path);
|
||||
const profile = profileForApiKey(this.config, authorization.apiKey);
|
||||
if (requestedModel && !isModelAllowedForProfile(this.config, profile, requestedModel)) {
|
||||
sendJson(response, 403, {
|
||||
error: {
|
||||
code: "profile_model_not_allowed",
|
||||
message: `Model "${requestedModel}" is not allowed for this profile.`
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!reserveApiKeyLimits(authorization.apiKey, request, response, requestBody)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import type { ApiKeyConfig, AppConfig, RequestRouteTraceChange } from "@ccr/core/contracts/app";
|
||||
import type { ApiKeyConfig, AppConfig, ProfileConfig, RequestRouteTraceChange, RouterFallbackConfig } from "@ccr/core/contracts/app";
|
||||
import {
|
||||
createSseErrorDetector,
|
||||
markGatewayRequestLogDropped,
|
||||
|
|
@ -46,6 +46,7 @@ import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, Up
|
|||
import { cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor";
|
||||
import { requestProtocolForPath, shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints";
|
||||
import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, hostedWebSearchProtocolResponseStream, hostedWebSearchUnavailableMessage, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/index";
|
||||
import { isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist";
|
||||
|
||||
export type GatewayRequestPipelineDependencies = {
|
||||
getBrowserWebSearchMcpIntegration: () => BrowserWebSearchMcpIntegration | undefined;
|
||||
|
|
@ -91,6 +92,7 @@ export class GatewayRequestPipeline {
|
|||
sendJson(response, 503, { error: { message: "Gateway service is not configured." } });
|
||||
return;
|
||||
}
|
||||
const activeConfig = this.config;
|
||||
|
||||
const method = request.method ?? "GET";
|
||||
const requestBody = await readRequestBody(request);
|
||||
|
|
@ -158,6 +160,7 @@ export class GatewayRequestPipeline {
|
|||
let routedModel: string | undefined;
|
||||
let codexApplyPatchBridgeActive = false;
|
||||
let codexMultiAgentBridgeActive = false;
|
||||
const authenticatedProfile = profileForApiKey(activeConfig, apiKey);
|
||||
const claudeModelRewriteStartedAt = Date.now();
|
||||
const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward);
|
||||
if (claudeModelRewrite) {
|
||||
|
|
@ -194,9 +197,7 @@ export class GatewayRequestPipeline {
|
|||
target: routedModel ? { model: routedModel } : undefined
|
||||
});
|
||||
}
|
||||
if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) {
|
||||
return;
|
||||
}
|
||||
const modelBeforeRouting = requestLogRequestedModel(bodyToForward ?? requestBody, path);
|
||||
const usageAttributionConfig = coreGatewayUsageAttributionConfig(this.config);
|
||||
const recordUsage = (input: Omit<UsageCaptureInput, "config">) => {
|
||||
void recordGatewayUsageCapture({ ...input, config: usageAttributionConfig });
|
||||
|
|
@ -288,6 +289,9 @@ export class GatewayRequestPipeline {
|
|||
|
||||
const shouldCaptureUsage = shouldCaptureGatewayUsage(method, path);
|
||||
if (shouldServeGatewayModelsResponse(method, path)) {
|
||||
if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) {
|
||||
return;
|
||||
}
|
||||
const responseText = `${JSON.stringify(createGatewayModelsResponse(this.config, request.headers, apiKey))}\n`;
|
||||
const modelHeaders = new Headers({
|
||||
"cache-control": "no-store, max-age=0",
|
||||
|
|
@ -355,6 +359,22 @@ export class GatewayRequestPipeline {
|
|||
});
|
||||
}
|
||||
|
||||
routeFallback = profileAllowedRouteFallback(activeConfig, authenticatedProfile, routeFallback);
|
||||
const effectiveModel = routedModel ?? requestLogRequestedModel(bodyToForward ?? requestBody, path);
|
||||
const deniedModel = profileDeniedModel(activeConfig, authenticatedProfile, modelBeforeRouting, effectiveModel);
|
||||
if (deniedModel) {
|
||||
sendJson(response, 403, {
|
||||
error: {
|
||||
code: "profile_model_not_allowed",
|
||||
message: `Model "${deniedModel}" is not allowed for this profile.`
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const codexBridgeStartedAt = Date.now();
|
||||
const codexApplyPatchBridgeRequest = prepareCodexApplyPatchBridgeRequest({
|
||||
body: bodyToForward,
|
||||
|
|
@ -937,3 +957,34 @@ export class GatewayRequestPipeline {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
function profileDeniedModel(
|
||||
config: AppConfig,
|
||||
profile: ProfileConfig | undefined,
|
||||
modelBeforeRouting: string | undefined,
|
||||
effectiveModel: string | undefined
|
||||
): string | undefined {
|
||||
const modelToAuthorize = effectiveModel ?? modelBeforeRouting;
|
||||
return modelToAuthorize && !isModelAllowedForProfile(config, profile, modelToAuthorize)
|
||||
? modelToAuthorize
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function profileAllowedRouteFallback(
|
||||
config: AppConfig,
|
||||
profile: ProfileConfig | undefined,
|
||||
fallback: RouterFallbackConfig
|
||||
): RouterFallbackConfig {
|
||||
if (fallback.mode !== "model-chain") {
|
||||
return fallback;
|
||||
}
|
||||
const models = fallback.models.filter((model) =>
|
||||
isModelAllowedForProfile(config, profile, model)
|
||||
);
|
||||
return models.length === fallback.models.length
|
||||
? fallback
|
||||
: { ...fallback, models };
|
||||
}
|
||||
|
||||
export const profileDeniedModelForTest = profileDeniedModel;
|
||||
export const profileAllowedRouteFallbackForTest = profileAllowedRouteFallback;
|
||||
|
|
|
|||
28
packages/core/src/gateway/runtime-config-control.ts
Normal file
28
packages/core/src/gateway/runtime-config-control.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
export const gatewayRuntimeConfigControlPath = "/__ccr/runtime/config";
|
||||
|
||||
export function gatewayRuntimeConfigRevision(config: AppConfig | undefined): string | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
return createHash("sha256")
|
||||
.update(stableJson(config))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableJson).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
suppressRequestLogRawTraceBodies,
|
||||
type RequestLogEnqueueResult
|
||||
} from "@ccr/core/observability/request-log-runtime";
|
||||
import { resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-limits";
|
||||
import { rawTraceMaxPartBytes, resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-limits";
|
||||
import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { formatError, parseJsonObject, readHeader, readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
|
||||
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
|
|
@ -1415,7 +1415,7 @@ export function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string
|
|||
return {
|
||||
deleteLocalAfterUpload: false,
|
||||
enabled,
|
||||
maxPartBytes: maxBodyBytes,
|
||||
maxPartBytes: rawTraceMaxPartBytes,
|
||||
mode: "wire_raw",
|
||||
spoolDir: RAW_TRACE_SPOOL_DIR,
|
||||
sync: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// Raw traces are produced outside the request-log runtime, so keep an
|
||||
// independent hard ceiling as defense in depth if configuration validation is
|
||||
// bypassed or its public range grows in the future.
|
||||
// Keep the public body-capture setting bounded for in-memory previews and
|
||||
// admission accounting. Raw trace sources can write larger parts because the
|
||||
// request-log store moves them into sidecar files instead of keeping them
|
||||
// inline in SQLite.
|
||||
export const rawTraceHardMaxBodyBytes = 50 * 1024 * 1024;
|
||||
export const maxRequestLogBodyBytes = rawTraceHardMaxBodyBytes;
|
||||
export const defaultRequestLogBodyBytes = rawTraceHardMaxBodyBytes;
|
||||
export const rawTraceMaxPartBytes = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export function resolveRawTraceBodyLimit(value: number | undefined): number {
|
||||
const configured = value ?? defaultRequestLogBodyBytes;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import type {
|
|||
AgentAnalysisTracePayloadFullResult,
|
||||
AgentAnalysisTracePayloadRequest,
|
||||
RequestLogDetailRequest,
|
||||
RequestLogBodyChunk,
|
||||
RequestLogBodyChunkRequest,
|
||||
RequestLogEntry,
|
||||
RequestLogListFilter,
|
||||
RequestLogPage
|
||||
|
|
@ -309,6 +311,10 @@ export class RequestLogRuntime {
|
|||
return await this.query<RequestLogEntry | undefined>("getDetail", [request]);
|
||||
}
|
||||
|
||||
async getBodyChunk(request: RequestLogBodyChunkRequest): Promise<RequestLogBodyChunk | undefined> {
|
||||
return await this.query<RequestLogBodyChunk | undefined>("getBodyChunk", [request]);
|
||||
}
|
||||
|
||||
async analyze(filter?: AgentAnalysisFilter): Promise<AgentAnalysisSnapshot> {
|
||||
return await this.query<AgentAnalysisSnapshot>("analyze", [filter]);
|
||||
}
|
||||
|
|
@ -1027,12 +1033,17 @@ function constrainRawTraceFiles(
|
|||
export function suppressRequestLogRawTraceBodies(
|
||||
input: RequestLogRawTraceUpdateInput
|
||||
): RequestLogRawTraceUpdateInput {
|
||||
const {
|
||||
requestBodyRef: _requestBodyRef,
|
||||
responseBodyRef: _responseBodyRef,
|
||||
...metadata
|
||||
} = input;
|
||||
const requestSize = input.requestBodySizeBytes ??
|
||||
(input.requestBodyText === undefined ? undefined : Buffer.byteLength(input.requestBodyText));
|
||||
const responseSize = input.responseBodySizeBytes ??
|
||||
(input.responseBodyText === undefined ? undefined : Buffer.byteLength(input.responseBodyText));
|
||||
return {
|
||||
...input,
|
||||
...metadata,
|
||||
...(requestSize === undefined ? {} : {
|
||||
requestBodySizeBytes: requestSize,
|
||||
requestBodyText: "",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants";
|
|||
import {
|
||||
RequestLogStore,
|
||||
type RequestLogRawTraceFile,
|
||||
type RequestLogRawTraceFiles,
|
||||
type RequestLogRecordInput,
|
||||
type RequestLogStoreWriteCommand
|
||||
} from "@ccr/core/observability/request-log-store";
|
||||
|
|
@ -13,6 +14,11 @@ import { resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-li
|
|||
import { compactBase64ImagePayloads } from "@ccr/core/observability/request-log-body";
|
||||
import { preloadUsagePriceCatalog } from "@ccr/core/models/pricing-service";
|
||||
|
||||
type RevivedRawTraceBody = RequestLogRawTraceFile & {
|
||||
text: string;
|
||||
textTruncated: boolean;
|
||||
};
|
||||
|
||||
type WorkerConfiguration = {
|
||||
dbFile: string;
|
||||
mode: "query" | "writer";
|
||||
|
|
@ -80,6 +86,9 @@ async function handleMessage(message: WorkerMessage): Promise<void> {
|
|||
case "getDetail":
|
||||
result = await store.getDetail(args[0] as Parameters<RequestLogStore["getDetail"]>[0]);
|
||||
break;
|
||||
case "getBodyChunk":
|
||||
result = await store.getBodyChunk(args[0] as Parameters<RequestLogStore["getBodyChunk"]>[0]);
|
||||
break;
|
||||
case "getTracePayload":
|
||||
result = await store.getTracePayload(args[0] as Parameters<RequestLogStore["getTracePayload"]>[0]);
|
||||
break;
|
||||
|
|
@ -140,9 +149,16 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
|||
if (command.kind === "raw-trace-update") {
|
||||
const input = { ...command.input };
|
||||
const maxBodyBytes = resolveRawTraceBodyLimit(command.rawTraceFiles?.maxBodyBytes);
|
||||
const rawTraceFiles: RequestLogRawTraceFiles | undefined = command.rawTraceFiles
|
||||
? {
|
||||
cleanupDirectory: command.rawTraceFiles.cleanupDirectory,
|
||||
maxBodyBytes: command.rawTraceFiles.maxBodyBytes
|
||||
}
|
||||
: undefined;
|
||||
if (command.rawTraceFiles?.requestBody) {
|
||||
const body = readRawTraceBody(command.rawTraceFiles.requestBody, maxBodyBytes);
|
||||
if (body) {
|
||||
if (rawTraceFiles) rawTraceFiles.requestBody = fileMetadataFromRawTraceBody(body);
|
||||
input.requestBodyContentType = body.contentType ?? input.requestBodyContentType;
|
||||
input.requestBodySizeBytes = body.sizeBytes;
|
||||
input.requestBodyTruncated = body.truncated;
|
||||
|
|
@ -152,6 +168,7 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
|||
if (command.rawTraceFiles?.responseBody) {
|
||||
const body = readRawTraceBody(command.rawTraceFiles.responseBody, maxBodyBytes);
|
||||
if (body) {
|
||||
if (rawTraceFiles) rawTraceFiles.responseBody = fileMetadataFromRawTraceBody(body);
|
||||
input.responseBodyContentType = body.contentType ?? input.responseBodyContentType;
|
||||
input.responseBodySizeBytes = body.sizeBytes;
|
||||
input.responseBodyTruncated = body.truncated;
|
||||
|
|
@ -161,6 +178,7 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
|||
return {
|
||||
input,
|
||||
kind: "raw-trace-update",
|
||||
...(rawTraceFiles ? { rawTraceFiles } : {}),
|
||||
sequence: command.sequence
|
||||
};
|
||||
}
|
||||
|
|
@ -174,10 +192,19 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
|||
};
|
||||
}
|
||||
|
||||
function fileMetadataFromRawTraceBody(body: RevivedRawTraceBody): RequestLogRawTraceFile {
|
||||
return {
|
||||
contentType: body.contentType,
|
||||
filePath: body.filePath,
|
||||
sizeBytes: body.sizeBytes,
|
||||
truncated: body.truncated
|
||||
};
|
||||
}
|
||||
|
||||
function readRawTraceBody(
|
||||
file: RequestLogRawTraceFile,
|
||||
maxBodyBytes: number
|
||||
): (RequestLogRawTraceFile & { text: string }) | undefined {
|
||||
): RevivedRawTraceBody | undefined {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const filePath = verifiedRawTracePath(file.filePath);
|
||||
|
|
@ -193,13 +220,14 @@ function readRawTraceBody(
|
|||
}
|
||||
const captured = offset === buffer.byteLength ? buffer : buffer.subarray(0, offset);
|
||||
const compacted = compactBase64ImagePayloads(captured);
|
||||
const sourceSizeBytes = Math.max(file.sizeBytes, storedBytes);
|
||||
return {
|
||||
...file,
|
||||
filePath,
|
||||
sizeBytes: Math.max(file.sizeBytes, storedBytes),
|
||||
sizeBytes: sourceSizeBytes,
|
||||
text: new StringDecoder("utf8").write(compacted.buffer),
|
||||
truncated: Boolean(file.truncated) || compacted.compacted ||
|
||||
offset < Math.max(file.sizeBytes, storedBytes)
|
||||
textTruncated: offset < storedBytes,
|
||||
truncated: Boolean(file.truncated) || storedBytes < sourceSizeBytes
|
||||
};
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT") return undefined;
|
||||
|
|
@ -209,8 +237,8 @@ function readRawTraceBody(
|
|||
}
|
||||
}
|
||||
|
||||
function shouldApplyRawTraceBodyText(body: RequestLogRawTraceFile & { text: string }): boolean {
|
||||
if (!body.truncated) return true;
|
||||
function shouldApplyRawTraceBodyText(body: RevivedRawTraceBody): boolean {
|
||||
if (!body.truncated && !body.textTruncated) return true;
|
||||
const contentType = body.contentType?.split(";", 1)[0].trim().toLowerCase() ?? "";
|
||||
const firstContentIndex = skipJsonWhitespace(body.text, 0);
|
||||
const firstContent = body.text.charCodeAt(firstContentIndex);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function findProfileForOpen(config: Pick<AppConfig, "profile">, profileRe
|
|||
}
|
||||
|
||||
export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[] {
|
||||
if (profile.agent === "zcode" || profile.agent === "claude-design") {
|
||||
if (profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design") {
|
||||
return ["app"];
|
||||
}
|
||||
if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi" || profile.agent === "kilo") {
|
||||
|
|
@ -77,7 +77,7 @@ export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: Prof
|
|||
}
|
||||
|
||||
export function defaultProfileOpenSurface(profile: Pick<ProfileConfig, "agent">): ProfileOpenSurface {
|
||||
return profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli";
|
||||
return profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli";
|
||||
}
|
||||
|
||||
export function shouldAutoStartProfileGateway(
|
||||
|
|
@ -334,12 +334,14 @@ function buildClaudeCodeLaunchPlan(
|
|||
}
|
||||
|
||||
function isCodexCompatibleAgent(agent: ProfileConfig["agent"]): boolean {
|
||||
return agent === "codex" || agent === "zcode";
|
||||
return agent === "codex" || agent === "workbuddy" || agent === "zcode";
|
||||
}
|
||||
|
||||
function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string {
|
||||
return agent === "zcode"
|
||||
? "~/.zcode/cli/config.json"
|
||||
: agent === "workbuddy"
|
||||
? "~/.workbuddy/config.toml"
|
||||
: agent === "pi"
|
||||
? "~/.pi/agent"
|
||||
: agent === "claude-design"
|
||||
|
|
@ -348,7 +350,7 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string {
|
|||
}
|
||||
|
||||
function codexConfigSubdir(agent: ProfileConfig["agent"]): string {
|
||||
return agent === "zcode" ? "zcode" : "codex";
|
||||
return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : "codex";
|
||||
}
|
||||
|
||||
function claudeCodeWrapperFilename(profile: ProfileConfig): string {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@ import { assertAvailableGatewayModels, type AppConfig, type ProfileConfig, type
|
|||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "@ccr/core/agents/claude-app/gateway-service";
|
||||
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch";
|
||||
import { resolveClaudeCodeGatewayAuthMode } from "@ccr/core/agents/claude-code/auth-mode";
|
||||
import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment";
|
||||
import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch";
|
||||
import { codexDesktopAppName, launchCodexAppProfile, launchWorkbuddyAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles, workbuddyDesktopAppName } from "@ccr/core/agents/codex/app-launch";
|
||||
import { CodexAppMediaPreviewBridge, shouldEnableCodexMediaPreviewBridge } from "@ccr/core/agents/codex/media-preview-bridge";
|
||||
import { findRunningOpenCodeAppPid, launchOpenCodeAppProfile, openCodeAppLaunchSignature } from "@ccr/core/agents/opencode/app-launch";
|
||||
import { writeOpenCodeGatewayConfig } from "@ccr/core/agents/opencode/profile-config";
|
||||
import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { gatewayService } from "@ccr/core/gateway/service";
|
||||
import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config";
|
||||
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
|
||||
|
|
@ -127,7 +129,7 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen
|
|||
if (profile.agent === "claude-code" && surface === "app") {
|
||||
return openClaudeAppProfile(config, profile);
|
||||
}
|
||||
if ((profile.agent === "codex" || profile.agent === "zcode") && surface === "app") {
|
||||
if ((profile.agent === "codex" || profile.agent === "workbuddy" || profile.agent === "zcode") && surface === "app") {
|
||||
return await openCodexAppProfile(config, profile);
|
||||
}
|
||||
if (profile.agent === "opencode" && surface === "app") {
|
||||
|
|
@ -260,7 +262,11 @@ async function openOpenCodeAppProfile(config: AppConfig, profile: ReturnType<typ
|
|||
}
|
||||
|
||||
async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): Promise<ProfileOpenResult> {
|
||||
const appName = profile.agent === "zcode" ? "ZCode App" : codexDesktopAppName;
|
||||
const appName = profile.agent === "zcode"
|
||||
? "ZCode App"
|
||||
: profile.agent === "workbuddy"
|
||||
? workbuddyDesktopAppName
|
||||
: codexDesktopAppName;
|
||||
const profileGatewayConfig = await ensureProfileGateway(config, profile, appName);
|
||||
const existing = runningProfileApp(profile.id, "app");
|
||||
if (existing) {
|
||||
|
|
@ -297,7 +303,9 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof
|
|||
}
|
||||
const launch = profile.agent === "zcode"
|
||||
? launchZcodeAppProfile(CONFIGDIR, profile, profileGatewayConfig)
|
||||
: launchCodexAppProfile(CONFIGDIR, profile, profileGatewayConfig);
|
||||
: profile.agent === "workbuddy"
|
||||
? launchWorkbuddyAppProfile(CONFIGDIR, profile, profileGatewayConfig)
|
||||
: launchCodexAppProfile(CONFIGDIR, profile, profileGatewayConfig);
|
||||
const entry = registerProfileApp(profile, "app", launch);
|
||||
const started = await waitForProfileAppStart(entry, 12000);
|
||||
if (!started) {
|
||||
|
|
@ -427,6 +435,7 @@ async function ensureGatewayConfigRunning(
|
|||
|
||||
type ExistingProfileGatewayProbe =
|
||||
| { endpoint: string; reason?: string; state: "unavailable" }
|
||||
| { endpoint: string; message: string; state: "incompatible" }
|
||||
| { endpoint: string; status?: number; state: "not-ccr" }
|
||||
| { endpoint: string; message?: string; status: number; state: "unauthorized" }
|
||||
| { endpoint: string; status: number; state: "unusable" }
|
||||
|
|
@ -470,6 +479,16 @@ async function probeExistingProfileGateway(
|
|||
}
|
||||
const models = await fetchExistingGateway(endpoint, "/v1/models", { headers });
|
||||
if (models.status === 200) {
|
||||
if (requiresClaudeCodeWifGateway(profile)) {
|
||||
const rootProbe = root ?? await fetchExistingGateway(endpoint, "/");
|
||||
if (!rootSupportsClaudeCodeWif(rootProbe.payload)) {
|
||||
return {
|
||||
endpoint,
|
||||
message: "The running CCR gateway does not advertise the Claude Code WIF token endpoint. Restart CCR Desktop or run ccr start to use WIF authentication.",
|
||||
state: "incompatible"
|
||||
};
|
||||
}
|
||||
}
|
||||
return { apiKey, endpoint, state: "usable" };
|
||||
}
|
||||
if (models.status === 401 || models.status === 403) {
|
||||
|
|
@ -543,6 +562,19 @@ function isCcrGatewayHealth(value: unknown): boolean {
|
|||
return typeof value.core === "string" && typeof value.status === "string" && typeof value.timestamp === "string";
|
||||
}
|
||||
|
||||
function rootSupportsClaudeCodeWif(value: unknown): boolean {
|
||||
if (!isRecord(value) || !Array.isArray(value.endpoints)) {
|
||||
return false;
|
||||
}
|
||||
return value.endpoints.some((endpoint) =>
|
||||
typeof endpoint === "string" && endpoint.toLowerCase().includes("/v1/oauth/token")
|
||||
);
|
||||
}
|
||||
|
||||
function requiresClaudeCodeWifGateway(profile: ReturnType<typeof findProfileForOpen>): boolean {
|
||||
return profile.agent === "claude-code" && resolveClaudeCodeGatewayAuthMode(profile) === "wif";
|
||||
}
|
||||
|
||||
function readGatewayErrorMessage(value: unknown): string | undefined {
|
||||
if (!isRecord(value) || !isRecord(value.error)) {
|
||||
return undefined;
|
||||
|
|
@ -561,7 +593,7 @@ function existingGatewayApiKeyCandidates(
|
|||
candidateConfig.APIKEY,
|
||||
...(Array.isArray(candidateConfig.APIKEYS) ? candidateConfig.APIKEYS.map((apiKey) => apiKey.key) : []),
|
||||
...readClaudeAppGatewayApiKeyCandidates(),
|
||||
...readClaudeCodeApiKeyHelperCandidates(profile)
|
||||
...readClaudeCodeProfileTokenCandidates(profile)
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
|
@ -576,16 +608,39 @@ function existingGatewayApiKeyCandidates(
|
|||
return result.length > 0 ? result : [undefined];
|
||||
}
|
||||
|
||||
function readClaudeCodeApiKeyHelperCandidates(profile: ReturnType<typeof findProfileForOpen>): string[] {
|
||||
const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile));
|
||||
function readClaudeCodeProfileTokenCandidates(profile: ReturnType<typeof findProfileForOpen>): string[] {
|
||||
return uniqueStrings([
|
||||
...readClaudeCodeWifTokenCandidates(profile),
|
||||
...readClaudeCodeLegacyApiKeyHelperCandidates(profile)
|
||||
]);
|
||||
}
|
||||
|
||||
function readClaudeCodeWifTokenCandidates(profile: ReturnType<typeof findProfileForOpen>): string[] {
|
||||
const file = path.join(CONFIGDIR, "bin", claudeCodeWifIdentityTokenFilename(profile));
|
||||
const files = [
|
||||
file,
|
||||
...readBackupFiles(file)
|
||||
];
|
||||
return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken));
|
||||
return uniqueStrings(files.map(readFirstLineToken));
|
||||
}
|
||||
|
||||
function claudeCodeApiKeyHelperFilename(profile: ReturnType<typeof findProfileForOpen>): string {
|
||||
function claudeCodeWifIdentityTokenFilename(profile: ReturnType<typeof findProfileForOpen>): string {
|
||||
const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
|
||||
return process.platform === "win32"
|
||||
? `ccr-claude-code-wif-token-${slug}.txt`
|
||||
: `ccr-claude-code-wif-token-${slug}`;
|
||||
}
|
||||
|
||||
function readClaudeCodeLegacyApiKeyHelperCandidates(profile: ReturnType<typeof findProfileForOpen>): string[] {
|
||||
const file = path.join(CONFIGDIR, "bin", claudeCodeLegacyApiKeyHelperFilename(profile));
|
||||
const files = [
|
||||
file,
|
||||
...readBackupFiles(file)
|
||||
];
|
||||
return uniqueStrings(files.map(readClaudeCodeLegacyApiKeyHelperToken));
|
||||
}
|
||||
|
||||
function claudeCodeLegacyApiKeyHelperFilename(profile: ReturnType<typeof findProfileForOpen>): string {
|
||||
const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
|
||||
return process.platform === "win32"
|
||||
? `ccr-claude-code-api-key-${slug}.cmd`
|
||||
|
|
@ -606,7 +661,21 @@ function readBackupFiles(file: string): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
function readClaudeCodeApiKeyHelperToken(file: string): string {
|
||||
function readFirstLineToken(file: string): string {
|
||||
if (!existsSync(file)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return readFileSync(file, "utf8")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readClaudeCodeLegacyApiKeyHelperToken(file: string): string {
|
||||
if (!existsSync(file)) {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -651,6 +720,9 @@ function existingGatewayConflictMessage(probe: ExistingProfileGatewayProbe, appN
|
|||
if (probe.state === "unusable") {
|
||||
return `CCR gateway is already running at ${probe.endpoint}, but it cannot serve ${appName} right now (HTTP ${probe.status}). Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`;
|
||||
}
|
||||
if (probe.state === "incompatible") {
|
||||
return `CCR gateway is already running at ${probe.endpoint}, but it is not compatible with ${appName}. ${probe.message}`;
|
||||
}
|
||||
if (probe.state === "not-ccr") {
|
||||
return `Port ${probe.endpoint} is already in use by a non-CCR service. Stop that process or change the CCR gateway port.`;
|
||||
}
|
||||
|
|
@ -666,7 +738,7 @@ function isAddressInUseError(message: string | undefined): boolean {
|
|||
|
||||
function profileGatewayEndpoint(config: AppConfig): string {
|
||||
const host = probeGatewayHost(config.gateway.host);
|
||||
return `http://${formatEndpointHost(host)}:${config.gateway.port}/`;
|
||||
return endpoint(host, config.gateway.port);
|
||||
}
|
||||
|
||||
function probeGatewayHost(host: string): string {
|
||||
|
|
@ -679,10 +751,6 @@ function probeGatewayHost(host: string): string {
|
|||
return host;
|
||||
}
|
||||
|
||||
function formatEndpointHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function profileGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): AppConfig {
|
||||
const token = findProfileApiKey(config, profile);
|
||||
if (!token) {
|
||||
|
|
|
|||
190
packages/core/src/profiles/model-allowlist.ts
Normal file
190
packages/core/src/profiles/model-allowlist.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import type { ApiKeyConfig, AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes";
|
||||
import { profileApiKeyId } from "@ccr/core/profiles/api-key";
|
||||
import { modelRegistryForConfig, normalizeRouteSelector } from "@ccr/core/routing/model-registry";
|
||||
|
||||
export type ProfileModelAllowlistConfig = Pick<AppConfig, "Providers" | "profile" | "virtualModelProfiles">;
|
||||
export type ModelAllowlistResolutionConfig = Pick<AppConfig, "Providers" | "virtualModelProfiles"> & Partial<Pick<AppConfig, "profile">>;
|
||||
|
||||
export function profileForApiKey(
|
||||
config: Partial<Pick<AppConfig, "profile">>,
|
||||
apiKey: ApiKeyConfig | undefined
|
||||
): ProfileConfig | undefined {
|
||||
if (!config.profile || config.profile.enabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
const apiKeyId = apiKey?.id?.trim();
|
||||
if (!apiKeyId) {
|
||||
return undefined;
|
||||
}
|
||||
return config.profile.profiles.find((profile) =>
|
||||
profile.enabled && profileApiKeyId(profile) === apiKeyId
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAllowedModels(profile: ProfileConfig | undefined): string[] | undefined {
|
||||
const explicit = uniqueModels((profile?.availableModels ?? []).map(normalizeAllowlistModel));
|
||||
if (!profile || explicit.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return uniqueModels([
|
||||
normalizeAllowlistModel(profile.model),
|
||||
...explicit
|
||||
]);
|
||||
}
|
||||
|
||||
export function filterModelIdsForProfile(
|
||||
config: ModelAllowlistResolutionConfig,
|
||||
ids: string[],
|
||||
profile: ProfileConfig | undefined
|
||||
): string[] {
|
||||
return filterModelIdsByAllowedModels(config, ids, profileAllowedModels(profile));
|
||||
}
|
||||
|
||||
export function filterModelIdsByAllowedModels(
|
||||
config: ModelAllowlistResolutionConfig | undefined,
|
||||
ids: string[],
|
||||
allowedModels: string[] | undefined
|
||||
): string[] {
|
||||
const allowed = modelAllowlistKeys(config, allowedModels);
|
||||
if (!allowed) {
|
||||
return uniqueModels(ids);
|
||||
}
|
||||
return uniqueModels(ids).filter((id) =>
|
||||
modelKeys(config, id).some((key) => allowed.has(key))
|
||||
);
|
||||
}
|
||||
|
||||
export function isModelAllowedForProfile(
|
||||
config: ModelAllowlistResolutionConfig,
|
||||
profile: ProfileConfig | undefined,
|
||||
model: string | undefined
|
||||
): boolean {
|
||||
const allowed = modelAllowlistKeys(config, profileAllowedModels(profile));
|
||||
if (!allowed) {
|
||||
return true;
|
||||
}
|
||||
const modelKeysToCheck = modelKeys(config, model);
|
||||
return modelKeysToCheck.length === 0 || modelKeysToCheck.some((key) => allowed.has(key));
|
||||
}
|
||||
|
||||
function modelAllowlistKeys(
|
||||
config: ModelAllowlistResolutionConfig | undefined,
|
||||
allowedModels: string[] | undefined
|
||||
): Set<string> | undefined {
|
||||
const normalized = uniqueModels((allowedModels ?? []).map(normalizeAllowlistModel));
|
||||
if (normalized.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const model of normalized) {
|
||||
for (const key of modelKeys(config, model)) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function modelKeys(
|
||||
config: ModelAllowlistResolutionConfig | undefined,
|
||||
model: string | undefined
|
||||
): string[] {
|
||||
const normalized = normalizeAllowlistModel(model);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
const registry = config ? modelRegistryForConfig(config) : undefined;
|
||||
const rawModelIsConfigured = Boolean(registry?.resolve(normalized));
|
||||
const claudeAppTarget = resolveClaudeAppRouteTarget(config, normalized);
|
||||
const seeds = uniqueModels([
|
||||
...modelKeySeeds(normalized, { includeDiscoveryAliases: !rawModelIsConfigured }),
|
||||
...(claudeAppTarget ? [claudeAppTarget] : [])
|
||||
]);
|
||||
const keys = new Set<string>();
|
||||
for (const seed of seeds) {
|
||||
addKey(keys, seed);
|
||||
}
|
||||
if (!config || !registry) {
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
for (const seed of seeds) {
|
||||
const resolved = registry.resolve(seed);
|
||||
if (resolved) {
|
||||
addKey(keys, resolved.selector);
|
||||
addKey(keys, resolved.canonicalSelector);
|
||||
if (resolved.kind === "provider") {
|
||||
addKey(keys, `${resolved.provider.name}/${resolved.model}`);
|
||||
} else {
|
||||
addKey(keys, resolved.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
function resolveClaudeAppRouteTarget(
|
||||
config: ModelAllowlistResolutionConfig | undefined,
|
||||
model: string
|
||||
): string | undefined {
|
||||
if (!config?.profile) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveClaudeAppGatewayRouteModel(model, {
|
||||
Providers: config.Providers,
|
||||
profile: config.profile,
|
||||
virtualModelProfiles: config.virtualModelProfiles
|
||||
});
|
||||
}
|
||||
|
||||
function modelKeySeeds(
|
||||
normalized: string,
|
||||
options: { includeDiscoveryAliases: boolean }
|
||||
): string[] {
|
||||
if (!options.includeDiscoveryAliases) {
|
||||
return [normalized];
|
||||
}
|
||||
const withoutOneMillionContext = stripOneMillionContextSuffix(normalized);
|
||||
const withoutClaudePrefix = stripClaudeDiscoveryPrefix(normalized);
|
||||
const withoutClaudePrefixAndContext = stripOneMillionContextSuffix(withoutClaudePrefix);
|
||||
return uniqueModels([
|
||||
normalized,
|
||||
withoutOneMillionContext,
|
||||
withoutClaudePrefix,
|
||||
withoutClaudePrefixAndContext
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeAllowlistModel(model: string | undefined): string {
|
||||
return normalizeRouteSelector(model) ?? "";
|
||||
}
|
||||
|
||||
function stripClaudeDiscoveryPrefix(model: string): string {
|
||||
return model.toLowerCase().startsWith("claude-") ? model.slice("claude-".length).trim() : model;
|
||||
}
|
||||
|
||||
function stripOneMillionContextSuffix(model: string): string {
|
||||
return model.replace(/\[1m\]$/i, "").trim();
|
||||
}
|
||||
|
||||
function addKey(keys: Set<string>, value: string | undefined): void {
|
||||
const normalized = normalizeAllowlistModel(value);
|
||||
if (normalized) {
|
||||
keys.add(normalized.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const model of models) {
|
||||
const normalized = normalizeAllowlistModel(model);
|
||||
const key = normalized.toLowerCase();
|
||||
if (!normalized || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -2,16 +2,19 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, availableGatewayModelIds, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, isGatewayProviderEnabled, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { CLAUDE_CODE_AUTH_MODE_ENV, resolveClaudeCodeGatewayAuthMode, type ClaudeCodeGatewayAuthMode } from "@ccr/core/agents/claude-code/auth-mode";
|
||||
import { replacePersistedApiKeys } from "@ccr/core/config/config-repository";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import {
|
||||
CLAUDE_CODE_MCP_CONFIG_ENV,
|
||||
CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV,
|
||||
type ClaudeCodeModelSelection,
|
||||
claudeCodeModelEnv,
|
||||
claudeCodeMcpConfigEnv,
|
||||
claudeCodeUtcTimezoneEnvOverride,
|
||||
clearClaudeCodeManagedModelEnv,
|
||||
isClaudeCodeManagedModelEnvKey
|
||||
isClaudeCodeManagedModelEnvKey,
|
||||
normalizeClaudeCodeClientModel
|
||||
} from "@ccr/core/agents/claude-code/environment";
|
||||
import { writeCodexCompatibleAppModelCatalog } from "@ccr/core/agents/codex/app-launch";
|
||||
import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime";
|
||||
|
|
@ -35,8 +38,11 @@ import {
|
|||
} from "@ccr/core/agents/pi/profile-config";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import { pruneInactiveProfileApiKeysFromList, syncProfileApiKeys } from "@ccr/core/profiles/api-key";
|
||||
import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist";
|
||||
import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config";
|
||||
import { CONTEXT_ARCHIVE_MCP_SERVER_NAME, contextArchiveConfigForProfile, contextArchiveMcpServer } from "@ccr/core/gateway/context-archive";
|
||||
import { createClaudeCliAutoCompactWindows } from "@ccr/core/gateway/features/model-discovery";
|
||||
import { claudeCodeOneMillionContextSuffix } from "@ccr/core/gateway/internal/shared";
|
||||
import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin";
|
||||
import { findModelCatalogEntry, modelCatalogMaxInputTokens, readCatalogCapability, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog";
|
||||
import {
|
||||
|
|
@ -68,6 +74,8 @@ const privateDirMode = 0o700;
|
|||
const privateExecutableMode = 0o700;
|
||||
const privateFileMode = 0o600;
|
||||
const publicExecutableMode = 0o755;
|
||||
const claudeCodeWifFederationRuleId = "ccr-local";
|
||||
const claudeCodeWifOrganizationId = "ccr-local";
|
||||
const claudeCodeGatewayEnvKeys = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_API_BASE_URL",
|
||||
|
|
@ -77,6 +85,26 @@ const claudeCodeRemovedAuthEnvKeys = [
|
|||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY"
|
||||
] as const;
|
||||
const claudeCodeFirstPartyProviderEnvKeys = [
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
"CLAUDE_CODE_USE_GATEWAY",
|
||||
"CLAUDE_CODE_USE_MANTLE",
|
||||
"CLAUDE_CODE_USE_ANTHROPIC_AWS",
|
||||
"CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD",
|
||||
...claudeCodeRemovedAuthEnvKeys
|
||||
] as const;
|
||||
const claudeCodeWifEnvKeys = [
|
||||
"ANTHROPIC_FEDERATION_RULE_ID",
|
||||
"ANTHROPIC_ORGANIZATION_ID",
|
||||
"ANTHROPIC_IDENTITY_TOKEN",
|
||||
"ANTHROPIC_IDENTITY_TOKEN_FILE",
|
||||
"ANTHROPIC_SERVICE_ACCOUNT_ID",
|
||||
"ANTHROPIC_WORKSPACE_ID",
|
||||
"ANTHROPIC_SCOPE",
|
||||
"ANTHROPIC_PROFILE"
|
||||
] as const;
|
||||
let ownedGlobalProfileTakeovers: GlobalProfileTakeoverRecord[] | undefined;
|
||||
|
||||
type CodexContextArchiveMcpConfig = {
|
||||
|
|
@ -111,6 +139,7 @@ export async function applyProfileConfig(
|
|||
const profiles = allProfiles.filter((profile) => !excludedAgents.has(profile.agent));
|
||||
cleanupInactiveOpenCodeWrappers(allProfiles);
|
||||
cleanupInactiveKiloWrappers(allProfiles);
|
||||
cleanupInactiveClaudeCodeGeneratedFiles(allProfiles);
|
||||
await pruneInactiveProfileApiKeys(config, allProfiles);
|
||||
const result: ProfileApplyResult = {
|
||||
appliedAt,
|
||||
|
|
@ -134,7 +163,7 @@ export async function applyProfileConfig(
|
|||
const managedCleanupResult = cleanupManagedClaudeCodeToolHubArtifacts(profiles, { includeActive: true });
|
||||
result.clients = profiles.map((profile) => {
|
||||
const cleanupResult = profile.agent === "claude-code"
|
||||
? cleanupClaudeCodeToolHubArtifacts(profile)
|
||||
? cleanupClaudeCodeUnavailableProfileArtifacts(profile)
|
||||
: { ok: true };
|
||||
const status = profile.enabled
|
||||
? unavailableModelStatus(profile, profilePath(profile))
|
||||
|
|
@ -308,6 +337,16 @@ function cleanupClaudeCodeToolHubArtifacts(profile: ProfileConfig): { changed?:
|
|||
}
|
||||
}
|
||||
|
||||
function cleanupClaudeCodeUnavailableProfileArtifacts(profile: ProfileConfig): { changed?: boolean; message?: string; ok: boolean } {
|
||||
const toolHubResult = cleanupClaudeCodeToolHubArtifacts(profile);
|
||||
const generatedResult = cleanupClaudeCodeGeneratedFiles(profile);
|
||||
return {
|
||||
changed: Boolean(toolHubResult.changed || generatedResult.changed),
|
||||
message: toolHubResult.message,
|
||||
ok: toolHubResult.ok
|
||||
};
|
||||
}
|
||||
|
||||
export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileConfig, token: string): ProfileClientApplyStatus {
|
||||
cleanupGeneratedBinBackups();
|
||||
const appliedAt = new Date().toISOString();
|
||||
|
|
@ -344,6 +383,7 @@ function applyClaudeDesignProfile(profile: ProfileConfig, appliedAt: string): Pr
|
|||
function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus {
|
||||
const settingsFile = resolveClaudeCodeSettingsFile(profile);
|
||||
if (!profile.enabled) {
|
||||
cleanupClaudeCodeGeneratedFiles(profile);
|
||||
return restoreDisabledGlobalProfile(profile, settingsFile, "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent);
|
||||
}
|
||||
|
||||
|
|
@ -361,28 +401,44 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
|
|||
env.ANTHROPIC_BASE_URL = endpoint;
|
||||
env.ANTHROPIC_API_BASE_URL = endpoint;
|
||||
env.CLAUDE_AGENT_API_BASE_URL = endpoint;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
for (const key of claudeCodeFirstPartyProviderEnvKeys) {
|
||||
delete env[key];
|
||||
}
|
||||
for (const key of claudeCodeWifEnvKeys) {
|
||||
delete env[key];
|
||||
}
|
||||
clearClaudeCodeManagedModelEnv(env);
|
||||
Object.assign(env, claudeCodeModelEnv(profile));
|
||||
Object.assign(env, claudeCodeProfileModelEnv(config, profile));
|
||||
const toolHubMcpConfigResult = writeClaudeCodeToolHubMcpConfig(config, profile, token);
|
||||
const mcpConfigEnv = claudeCodeMcpConfigEnv(toolHubMcpConfigResult.file);
|
||||
const timezoneEnv = claudeCodeUtcTimezoneEnvOverride();
|
||||
Object.assign(env, mcpConfigEnv, timezoneEnv);
|
||||
const authMode = resolveClaudeCodeGatewayAuthMode(profile);
|
||||
const wifResult = writeClaudeCodeWifIdentityToken(profile, token);
|
||||
const wifEnv = authMode === "wif" ? claudeCodeWifEnv(wifResult.file) : {};
|
||||
Object.assign(env, mcpConfigEnv, timezoneEnv, wifEnv);
|
||||
|
||||
const helperResult = writeClaudeCodeApiKeyHelper(profile, token);
|
||||
const wrapperResult = writeClaudeCodeWrapper(config, profile, helperResult.file, toolHubMcpConfigResult.file);
|
||||
const nextSettings = {
|
||||
...settings,
|
||||
apiKeyHelper: process.platform === "win32" ? `"${helperResult.file}"` : helperResult.file,
|
||||
env
|
||||
};
|
||||
const managedEnvKeys = claudeCodeManagedSettingsEnvKeys(profileEnvValues, mcpConfigEnv, timezoneEnv);
|
||||
const helperResult = authMode === "api-key-helper" ? writeClaudeCodeApiKeyHelper(profile, token) : undefined;
|
||||
const legacyApiKeyHelperCleanupResult = authMode === "wif"
|
||||
? cleanupClaudeCodeLegacyApiKeyHelper(profile)
|
||||
: { changed: false };
|
||||
const wrapperResult = writeClaudeCodeWrapper(config, profile, {
|
||||
remoteSyncApiKeyFile: wifResult.file,
|
||||
wifIdentityTokenFile: authMode === "wif" ? wifResult.file : undefined
|
||||
}, toolHubMcpConfigResult.file);
|
||||
const autoCompactResult = writeClaudeCodeAutoCompactWindowsCache(config, settingsFile);
|
||||
const nextSettings = claudeCodeNextSettings(settings, env, authMode, helperResult?.file);
|
||||
const managedEnvKeys = claudeCodeManagedSettingsEnvKeys(profileEnvValues, mcpConfigEnv, timezoneEnv, wifEnv);
|
||||
const writeResult = writeClaudeCodeSettingsIfManagedChanged(settingsFile, settings, nextSettings, managedEnvKeys);
|
||||
const changed = writeResult.changed || helperResult.changed || wrapperResult.changed || toolHubMcpConfigResult.changed;
|
||||
const changed = writeResult.changed ||
|
||||
Boolean(helperResult?.changed) ||
|
||||
wifResult.changed ||
|
||||
wrapperResult.changed ||
|
||||
toolHubMcpConfigResult.changed ||
|
||||
legacyApiKeyHelperCleanupResult.changed ||
|
||||
autoCompactResult.changed;
|
||||
return {
|
||||
appliedAt,
|
||||
backupFile: writeResult.backupFile ?? helperResult.backupFile ?? wrapperResult.backupFile,
|
||||
backupFile: writeResult.backupFile ?? wrapperResult.backupFile ?? autoCompactResult.backupFile,
|
||||
client: "claude-code",
|
||||
enabled: true,
|
||||
message: changed
|
||||
|
|
@ -422,9 +478,13 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
|
|||
const source = existsSync(configFile) ? readFileSync(configFile, "utf8") : "";
|
||||
const configFormat = normalizeCodexConfigFormat(profile.configFormat);
|
||||
const modelCatalogFile = codexModelCatalogFile(configFile);
|
||||
const modelCatalogResult = writeFileWithBackup(modelCatalogFile, codexModelCatalogJson(config, model));
|
||||
const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, { ...profile, model }, config);
|
||||
const showAllSessions = profile.agent === "zcode" ? false : Boolean(profile.showAllSessions);
|
||||
const profileWithResolvedModel = { ...profile, model };
|
||||
const modelCatalogResult = writeFileWithBackup(
|
||||
modelCatalogFile,
|
||||
codexModelCatalogJson(config, model, { allowedModels: profileAllowedModels(profileWithResolvedModel) })
|
||||
);
|
||||
const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, profileWithResolvedModel, config);
|
||||
const showAllSessions = profile.agent === "zcode" || profile.agent === "workbuddy" ? false : Boolean(profile.showAllSessions);
|
||||
const toolHubMcpResult = writeCodexToolHubMcpRuntimeConfig(config, token);
|
||||
const contextArchiveMcp = profile.agent === "codex"
|
||||
? codexContextArchiveMcpConfig(config, profile, token)
|
||||
|
|
@ -466,6 +526,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
|
|||
const extras = [
|
||||
modelCatalogFile ? `catalog ${modelCatalogFile}` : "",
|
||||
appModelCatalogResult.file ? `app catalog ${appModelCatalogResult.file}` : "",
|
||||
appModelCatalogResult.workbuddyModelsConfig?.file ? `workbuddy models ${appModelCatalogResult.workbuddyModelsConfig.file}` : "",
|
||||
toolHubMcpResult.file ? `toolhub runtime ${toolHubMcpResult.file}` : "",
|
||||
contextArchiveMcp ? "context archive MCP" : "",
|
||||
separateProfileResult?.file ? `profile ${separateProfileResult.file}` : "",
|
||||
|
|
@ -1054,10 +1115,47 @@ function buildSeparateCodexProfileToml(
|
|||
return ensureTrailingNewline(`${rootBlock}${trimLeadingBlankLines(cleanedRoot)}${restSource}`.replace(/\n{4,}/g, "\n\n\n"));
|
||||
}
|
||||
|
||||
function writeClaudeCodeApiKeyHelper(profile: ProfileConfig, token: string): { backupFile?: string; changed: boolean; file: string } {
|
||||
function claudeCodeNextSettings(
|
||||
settings: Record<string, unknown>,
|
||||
env: Record<string, string>,
|
||||
authMode: ClaudeCodeGatewayAuthMode,
|
||||
apiKeyHelperFile: string | undefined
|
||||
): Record<string, unknown> {
|
||||
if (authMode === "api-key-helper" && apiKeyHelperFile) {
|
||||
return {
|
||||
...settings,
|
||||
apiKeyHelper: process.platform === "win32" ? `"${apiKeyHelperFile}"` : apiKeyHelperFile,
|
||||
env
|
||||
};
|
||||
}
|
||||
return {
|
||||
...withoutManagedClaudeCodeApiKeyHelper(settings),
|
||||
env
|
||||
};
|
||||
}
|
||||
|
||||
function writeClaudeCodeWifIdentityToken(profile: ProfileConfig, token: string): { changed: boolean; file: string } {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
mkdirSync(binDir, { mode: privateDirMode, recursive: true });
|
||||
const file = path.join(binDir, claudeCodeApiKeyHelperFilename(profile));
|
||||
const file = path.join(binDir, claudeCodeWifIdentityTokenFilename(profile));
|
||||
const writeResult = writeGeneratedFileIfChanged(file, `${token}\n`, { mode: privateFileMode });
|
||||
return {
|
||||
changed: writeResult.changed,
|
||||
file
|
||||
};
|
||||
}
|
||||
|
||||
function claudeCodeWifIdentityTokenFilename(profile: ProfileConfig): string {
|
||||
const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
|
||||
return process.platform === "win32"
|
||||
? `ccr-claude-code-wif-token-${slug}.txt`
|
||||
: `ccr-claude-code-wif-token-${slug}`;
|
||||
}
|
||||
|
||||
function writeClaudeCodeApiKeyHelper(profile: ProfileConfig, token: string): { changed: boolean; file: string } {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
mkdirSync(binDir, { mode: privateDirMode, recursive: true });
|
||||
const file = path.join(binDir, claudeCodeLegacyApiKeyHelperFilename(profile));
|
||||
const content = process.platform === "win32"
|
||||
? claudeCodeApiKeyHelperCmdScript(token)
|
||||
: claudeCodeApiKeyHelperShellScript(token);
|
||||
|
|
@ -1068,7 +1166,16 @@ function writeClaudeCodeApiKeyHelper(profile: ProfileConfig, token: string): { b
|
|||
};
|
||||
}
|
||||
|
||||
function claudeCodeApiKeyHelperFilename(profile: ProfileConfig): string {
|
||||
function cleanupClaudeCodeLegacyApiKeyHelper(profile: ProfileConfig): { changed: boolean } {
|
||||
const file = path.join(CONFIGDIR, "bin", claudeCodeLegacyApiKeyHelperFilename(profile));
|
||||
if (!existsSync(file)) {
|
||||
return { changed: false };
|
||||
}
|
||||
rmSync(file, { force: true });
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
function claudeCodeLegacyApiKeyHelperFilename(profile: ProfileConfig): string {
|
||||
const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
|
||||
return process.platform === "win32"
|
||||
? `ccr-claude-code-api-key-${slug}.cmd`
|
||||
|
|
@ -1091,15 +1198,42 @@ function claudeCodeApiKeyHelperCmdScript(token: string): string {
|
|||
].join("\r\n");
|
||||
}
|
||||
|
||||
function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig, apiKeyHelperFile: string, mcpConfigFile: string | undefined): { backupFile?: string; changed: boolean; file: string } {
|
||||
function cleanupClaudeCodeGeneratedFiles(profile: ProfileConfig): { changed: boolean } {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
let changed = false;
|
||||
for (const fileName of [claudeCodeLegacyApiKeyHelperFilename(profile), claudeCodeWifIdentityTokenFilename(profile), claudeCodeWrapperFilename(profile)]) {
|
||||
const file = path.join(binDir, fileName);
|
||||
if (!existsSync(file)) {
|
||||
continue;
|
||||
}
|
||||
rmSync(file, { force: true });
|
||||
changed = true;
|
||||
}
|
||||
return { changed };
|
||||
}
|
||||
|
||||
function claudeCodeWifEnv(identityTokenFile: string): Record<string, string> {
|
||||
return {
|
||||
ANTHROPIC_FEDERATION_RULE_ID: claudeCodeWifFederationRuleId,
|
||||
ANTHROPIC_IDENTITY_TOKEN_FILE: identityTokenFile,
|
||||
ANTHROPIC_ORGANIZATION_ID: claudeCodeWifOrganizationId
|
||||
};
|
||||
}
|
||||
|
||||
function writeClaudeCodeWrapper(
|
||||
config: AppConfig,
|
||||
profile: ProfileConfig,
|
||||
auth: { remoteSyncApiKeyFile: string; wifIdentityTokenFile?: string },
|
||||
mcpConfigFile: string | undefined
|
||||
): { backupFile?: string; changed: boolean; file: string } {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
mkdirSync(binDir, { mode: privateDirMode, recursive: true });
|
||||
const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename());
|
||||
const runtimeResult = writeGeneratedFileIfChanged(runtimeFile, codexCliMiddlewareRuntimeScript(), { mode: publicExecutableMode });
|
||||
const file = path.join(binDir, claudeCodeWrapperFilename(profile));
|
||||
const content = process.platform === "win32"
|
||||
? claudeCodeWrapperCmdScript(config, profile, runtimeFile, apiKeyHelperFile, mcpConfigFile)
|
||||
: claudeCodeWrapperShellScript(config, profile, runtimeFile, apiKeyHelperFile, mcpConfigFile);
|
||||
? claudeCodeWrapperCmdScript(config, profile, runtimeFile, auth, mcpConfigFile)
|
||||
: claudeCodeWrapperShellScript(config, profile, runtimeFile, auth, mcpConfigFile);
|
||||
const writeResult = writeGeneratedFileIfChanged(file, content, { mode: privateExecutableMode });
|
||||
return {
|
||||
changed: writeResult.changed || runtimeResult.changed,
|
||||
|
|
@ -1114,19 +1248,26 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string {
|
|||
: `ccr-claude-code-wrapper-${slug}`;
|
||||
}
|
||||
|
||||
function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string, mcpConfigFile: string | undefined): string {
|
||||
function claudeCodeWrapperShellScript(
|
||||
config: AppConfig,
|
||||
profile: ProfileConfig,
|
||||
runtimeFile: string,
|
||||
auth: { remoteSyncApiKeyFile: string; wifIdentityTokenFile?: string },
|
||||
mcpConfigFile: string | undefined
|
||||
): string {
|
||||
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
|
||||
const surface = normalizeProfileSurface(profile.surface);
|
||||
const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
|
||||
const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile));
|
||||
const envExports = Object.entries(profileEnv(profile))
|
||||
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key))
|
||||
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key) && !isClaudeCodeFirstPartyProviderEnvKey(key) && !isClaudeCodeWifEnvKey(key))
|
||||
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
|
||||
const botEnvExports = shellBotGatewayEnvExports(config, profile);
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
...envExports,
|
||||
...shellEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)),
|
||||
...claudeCodeFirstPartyProviderEnvKeys.map((key) => `unset ${key}`),
|
||||
...shellEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir, auth.wifIdentityTokenFile)),
|
||||
...shellEnvExports(claudeCodeMcpConfigEnv(mcpConfigFile)),
|
||||
...shellEnvExports(claudeCodeUtcTimezoneEnvOverride()),
|
||||
`: "\${CCR_PROFILE_SURFACE:=${surface}}"`,
|
||||
|
|
@ -1137,28 +1278,35 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
|
|||
`export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`,
|
||||
`if [ -z "\${CCR_REMOTE_SYNC_ENABLED:-}" ]; then CCR_REMOTE_SYNC_ENABLED=1; fi`,
|
||||
`if [ -z "\${CCR_REMOTE_SYNC_ENDPOINT:-}" ]; then CCR_REMOTE_SYNC_ENDPOINT=${shellQuote(remoteEndpoint)}; fi`,
|
||||
`if [ -z "\${CCR_REMOTE_SYNC_API_KEY_HELPER:-}" ]; then CCR_REMOTE_SYNC_API_KEY_HELPER=${shellQuote(apiKeyHelperFile)}; fi`,
|
||||
`if [ -z "\${CCR_REMOTE_SYNC_API_KEY_FILE:-}" ]; then CCR_REMOTE_SYNC_API_KEY_FILE=${shellQuote(auth.remoteSyncApiKeyFile)}; fi`,
|
||||
`if [ -z "\${CCR_REMOTE_SYNC_PROFILE_ID:-}" ]; then CCR_REMOTE_SYNC_PROFILE_ID=${shellQuote(profile.id || profile.name || "claude-code")}; fi`,
|
||||
`if [ -z "\${CCR_REMOTE_SYNC_PROFILE_NAME:-}" ]; then CCR_REMOTE_SYNC_PROFILE_NAME=${shellQuote(profile.name || profile.id || "Claude Code")}; fi`,
|
||||
"export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_HELPER CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME",
|
||||
"export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_FILE CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME",
|
||||
...nodeRuntimeShellExecLines(runtimeFile),
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string, mcpConfigFile: string | undefined): string {
|
||||
function claudeCodeWrapperCmdScript(
|
||||
config: AppConfig,
|
||||
profile: ProfileConfig,
|
||||
runtimeFile: string,
|
||||
auth: { remoteSyncApiKeyFile: string; wifIdentityTokenFile?: string },
|
||||
mcpConfigFile: string | undefined
|
||||
): string {
|
||||
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
|
||||
const surface = normalizeProfileSurface(profile.surface);
|
||||
const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
|
||||
const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile));
|
||||
const envExports = Object.entries(profileEnv(profile))
|
||||
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key))
|
||||
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key) && !isClaudeCodeFirstPartyProviderEnvKey(key) && !isClaudeCodeWifEnvKey(key))
|
||||
.map(([key, value]) => cmdSetLine(key, value));
|
||||
const botEnvExports = cmdBotGatewayEnvExports(config, profile);
|
||||
return [
|
||||
"@echo off",
|
||||
...envExports,
|
||||
...cmdEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)),
|
||||
...claudeCodeFirstPartyProviderEnvKeys.map((key) => cmdSetLine(key, "")),
|
||||
...cmdEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir, auth.wifIdentityTokenFile)),
|
||||
...cmdEnvExports(claudeCodeMcpConfigEnv(mcpConfigFile)),
|
||||
...cmdEnvExports(claudeCodeUtcTimezoneEnvOverride()),
|
||||
`if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", surface)}`,
|
||||
|
|
@ -1168,7 +1316,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r
|
|||
cmdSetLine("CODEXL_CLAUDE_CODE_BIN", realClaude),
|
||||
`if not defined CCR_REMOTE_SYNC_ENABLED ${cmdSetLine("CCR_REMOTE_SYNC_ENABLED", "1")}`,
|
||||
`if not defined CCR_REMOTE_SYNC_ENDPOINT ${cmdSetLine("CCR_REMOTE_SYNC_ENDPOINT", remoteEndpoint)}`,
|
||||
`if not defined CCR_REMOTE_SYNC_API_KEY_HELPER ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_HELPER", apiKeyHelperFile)}`,
|
||||
`if not defined CCR_REMOTE_SYNC_API_KEY_FILE ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_FILE", auth.remoteSyncApiKeyFile)}`,
|
||||
`if not defined CCR_REMOTE_SYNC_PROFILE_ID ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_ID", profile.id || profile.name || "claude-code")}`,
|
||||
`if not defined CCR_REMOTE_SYNC_PROFILE_NAME ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_NAME", profile.name || profile.id || "Claude Code")}`,
|
||||
...nodeRuntimeCmdExecLines(runtimeFile),
|
||||
|
|
@ -1979,7 +2127,7 @@ function writeCodexCliMiddleware(
|
|||
};
|
||||
}
|
||||
|
||||
function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, settingsDir: string): Record<string, string> {
|
||||
function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, settingsDir: string, wifIdentityTokenFile?: string): Record<string, string> {
|
||||
const endpoint = gatewayEndpoint(config);
|
||||
const env: Record<string, string> = {
|
||||
ANTHROPIC_API_BASE_URL: endpoint,
|
||||
|
|
@ -1987,10 +2135,76 @@ function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, setting
|
|||
CLAUDE_AGENT_API_BASE_URL: endpoint,
|
||||
CLAUDE_CONFIG_DIR: settingsDir
|
||||
};
|
||||
Object.assign(env, claudeCodeModelEnv(profile));
|
||||
if (wifIdentityTokenFile) {
|
||||
Object.assign(env, claudeCodeWifEnv(wifIdentityTokenFile));
|
||||
}
|
||||
Object.assign(env, claudeCodeProfileModelEnv(config, profile));
|
||||
return env;
|
||||
}
|
||||
|
||||
function claudeCodeProfileModelEnv(config: AppConfig, profile: ProfileConfig): Record<string, string> {
|
||||
return claudeCodeModelEnv(claudeCodeProfileModelSelection(config, profile));
|
||||
}
|
||||
|
||||
function claudeCodeProfileModelSelection(config: AppConfig, profile: ProfileConfig): ClaudeCodeModelSelection {
|
||||
const autoCompactWindows = createClaudeCliAutoCompactWindows(config);
|
||||
return {
|
||||
fableModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.fableModel),
|
||||
haikuModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.haikuModel),
|
||||
model: claudeCodeOneMillionContextModel(autoCompactWindows, profile.model),
|
||||
opusModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.opusModel),
|
||||
smallFastModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.smallFastModel),
|
||||
sonnetModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.sonnetModel)
|
||||
};
|
||||
}
|
||||
|
||||
function claudeCodeOneMillionContextModel(autoCompactWindows: Record<string, number>, model: string | undefined): string | undefined {
|
||||
const normalized = normalizeClaudeCodeClientModel(model);
|
||||
if (!normalized) {
|
||||
return model;
|
||||
}
|
||||
if (hasClaudeCodeOneMillionContextSuffix(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
const compactWindow = autoCompactWindows[normalized] ?? autoCompactWindows[normalized.toLowerCase()];
|
||||
return compactWindow !== undefined && compactWindow >= 1_000_000
|
||||
? `${normalized}${claudeCodeOneMillionContextSuffix}`
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function hasClaudeCodeOneMillionContextSuffix(model: string): boolean {
|
||||
return model.trim().toLowerCase().endsWith(claudeCodeOneMillionContextSuffix);
|
||||
}
|
||||
|
||||
function writeClaudeCodeAutoCompactWindowsCache(
|
||||
config: AppConfig,
|
||||
settingsFile: string
|
||||
): { backupFile?: string; changed: boolean; file: string } {
|
||||
const file = path.join(path.dirname(settingsFile), ".claude.json");
|
||||
const current = readClaudeCodeGlobalConfigObject(file);
|
||||
const next = {
|
||||
...current,
|
||||
autoCompactWindowsCache: createClaudeCliAutoCompactWindows(config)
|
||||
};
|
||||
const writeResult = writeFileWithBackup(file, `${JSON.stringify(next, null, 2)}\n`, { mode: privateFileMode });
|
||||
return { ...writeResult, file };
|
||||
}
|
||||
|
||||
function readClaudeCodeGlobalConfigObject(file: string): Record<string, unknown> {
|
||||
if (!existsSync(file)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
||||
if (isRecord(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
throw new Error("root value is not an object");
|
||||
} catch (error) {
|
||||
throw new Error(`Claude Code global config file is not valid JSON: ${file}. ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function codexMiddlewareRuntimeFilename(): string {
|
||||
return "ccr-codex-cli-middleware.js";
|
||||
}
|
||||
|
|
@ -2051,7 +2265,7 @@ function codexMiddlewareShellScript(
|
|||
const codexHome = profile.codexHome?.trim() || defaultCodexCompatibleHome(profile.agent, values.configFile);
|
||||
const resolvedCodexHome = resolveUserPath(codexHome);
|
||||
const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode);
|
||||
const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface);
|
||||
const surface = profile.agent === "workbuddy" || profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface);
|
||||
const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `export ${key}=${shellQuote(value)}`);
|
||||
const botEnvExports = shellBotGatewayEnvExports(config, profile);
|
||||
const agentEnvExports = profile.agent === "zcode"
|
||||
|
|
@ -2082,6 +2296,14 @@ function codexMiddlewareShellScript(
|
|||
]
|
||||
: [
|
||||
`export CODEX_HOME=${shellQuote(resolvedCodexHome)}`,
|
||||
...(profile.agent === "workbuddy"
|
||||
? [
|
||||
`export WORKBUDDY_HOME=${shellQuote(resolvedCodexHome)}`,
|
||||
`export WORKBUDDY_CONFIG_DIR=${shellQuote(resolvedCodexHome)}`,
|
||||
`export CODEBUDDY_CONFIG_DIR=${shellQuote(resolvedCodexHome)}`,
|
||||
`export CODEBUDDY_HOME=${shellQuote(resolvedCodexHome)}`
|
||||
]
|
||||
: []),
|
||||
"if [ -z \"${CCR_REAL_CODEX_CLI_PATH:-}\" ]; then",
|
||||
` CCR_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`,
|
||||
"fi",
|
||||
|
|
@ -2190,7 +2412,7 @@ function codexMiddlewareCmdScript(
|
|||
const codexHome = profile.codexHome?.trim() || defaultCodexCompatibleHome(profile.agent, values.configFile);
|
||||
const resolvedCodexHome = resolveUserPath(codexHome);
|
||||
const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode);
|
||||
const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface);
|
||||
const surface = profile.agent === "workbuddy" || profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface);
|
||||
const workspaceName = profile.name || values.providerId;
|
||||
const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => cmdSetLine(key, value));
|
||||
const botEnvExports = cmdBotGatewayEnvExports(config, profile);
|
||||
|
|
@ -2216,6 +2438,14 @@ function codexMiddlewareCmdScript(
|
|||
]
|
||||
: [
|
||||
cmdSetLine("CODEX_HOME", resolvedCodexHome),
|
||||
...(profile.agent === "workbuddy"
|
||||
? [
|
||||
cmdSetLine("WORKBUDDY_HOME", resolvedCodexHome),
|
||||
cmdSetLine("WORKBUDDY_CONFIG_DIR", resolvedCodexHome),
|
||||
cmdSetLine("CODEBUDDY_CONFIG_DIR", resolvedCodexHome),
|
||||
cmdSetLine("CODEBUDDY_HOME", resolvedCodexHome)
|
||||
]
|
||||
: []),
|
||||
`if not defined CCR_REAL_CODEX_CLI_PATH ${cmdSetLine("CCR_REAL_CODEX_CLI_PATH", codexCli)}`,
|
||||
"if not defined CCR_BUNDLED_CODEX_CLI_PATH set \"CCR_BUNDLED_CODEX_CLI_PATH=%CCR_REAL_CODEX_CLI_PATH%\"",
|
||||
cmdSetLine("CCR_CODEX_PROFILE", values.providerId),
|
||||
|
|
@ -2490,16 +2720,31 @@ function claudeCodeSettingsManagedFieldsChanged(
|
|||
return false;
|
||||
}
|
||||
|
||||
function withoutManagedClaudeCodeApiKeyHelper(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
const nextSettings = { ...settings };
|
||||
if (isManagedClaudeCodeApiKeyHelper(nextSettings.apiKeyHelper)) {
|
||||
delete nextSettings.apiKeyHelper;
|
||||
}
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
function isManagedClaudeCodeApiKeyHelper(value: unknown): boolean {
|
||||
return typeof value === "string" && value.includes("ccr-claude-code-api-key-");
|
||||
}
|
||||
|
||||
function claudeCodeManagedSettingsEnvKeys(
|
||||
profileEnvValues: Record<string, string>,
|
||||
mcpConfigEnv: Record<string, string>,
|
||||
timezoneEnv: Record<string, string>
|
||||
timezoneEnv: Record<string, string>,
|
||||
wifEnv: Record<string, string>
|
||||
): Set<string> {
|
||||
return new Set([
|
||||
...claudeCodeGatewayEnvKeys,
|
||||
...claudeCodeRemovedAuthEnvKeys,
|
||||
...claudeCodeFirstPartyProviderEnvKeys,
|
||||
...claudeCodeWifEnvKeys,
|
||||
...Object.keys(profileEnvValues),
|
||||
...Object.keys(mcpConfigEnv),
|
||||
...Object.keys(wifEnv),
|
||||
...Object.keys(timezoneEnv),
|
||||
CLAUDE_CODE_MCP_CONFIG_ENV,
|
||||
CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV
|
||||
|
|
@ -2508,7 +2753,8 @@ function claudeCodeManagedSettingsEnvKeys(
|
|||
|
||||
function isManagedClaudeCodeSettingsEnvKey(key: string): boolean {
|
||||
return (claudeCodeGatewayEnvKeys as readonly string[]).includes(key) ||
|
||||
(claudeCodeRemovedAuthEnvKeys as readonly string[]).includes(key) ||
|
||||
isClaudeCodeFirstPartyProviderEnvKey(key) ||
|
||||
isClaudeCodeWifEnvKey(key) ||
|
||||
key === CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV ||
|
||||
key === CLAUDE_CODE_MCP_CONFIG_ENV ||
|
||||
key === CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV ||
|
||||
|
|
@ -2516,6 +2762,14 @@ function isManagedClaudeCodeSettingsEnvKey(key: string): boolean {
|
|||
isBotGatewayEnvKey(key);
|
||||
}
|
||||
|
||||
function isClaudeCodeFirstPartyProviderEnvKey(key: string): boolean {
|
||||
return (claudeCodeFirstPartyProviderEnvKeys as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function isClaudeCodeWifEnvKey(key: string): boolean {
|
||||
return (claudeCodeWifEnvKeys as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function writeFileWithBackup(
|
||||
file: string,
|
||||
content: string,
|
||||
|
|
@ -2625,6 +2879,39 @@ function cleanupInactiveKiloWrappers(profiles: ProfileConfig[]): number {
|
|||
return removed;
|
||||
}
|
||||
|
||||
function cleanupInactiveClaudeCodeGeneratedFiles(profiles: ProfileConfig[]): number {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
const activeFiles = new Set(profiles
|
||||
.filter((profile) => profile.agent === "claude-code" && profile.enabled)
|
||||
.flatMap((profile) => [
|
||||
...(resolveClaudeCodeGatewayAuthMode(profile) === "api-key-helper" ? [claudeCodeLegacyApiKeyHelperFilename(profile)] : []),
|
||||
claudeCodeWifIdentityTokenFilename(profile),
|
||||
claudeCodeWrapperFilename(profile)
|
||||
]));
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(binDir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (const entry of entries) {
|
||||
if (!isClaudeCodeGeneratedRuntimeFile(entry) || activeFiles.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
rmSync(path.join(binDir, entry), { force: true });
|
||||
removed += 1;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function isClaudeCodeGeneratedRuntimeFile(fileName: string): boolean {
|
||||
return fileName.startsWith("ccr-claude-code-api-key-") ||
|
||||
fileName.startsWith("ccr-claude-code-wif-token-") ||
|
||||
fileName.startsWith("ccr-claude-code-wrapper-");
|
||||
}
|
||||
|
||||
function generatedBinBackupBaseName(entry: string): string | undefined {
|
||||
const backupMarker = ".ccr-backup-";
|
||||
const backupIndex = entry.indexOf(backupMarker);
|
||||
|
|
@ -2647,6 +2934,7 @@ function isManagedGeneratedBinFile(fileName: string): boolean {
|
|||
normalized === TOOL_HUB_MCP_RUNTIME_FILE_NAME ||
|
||||
normalized === codexMiddlewareRuntimeFilename() ||
|
||||
normalized.startsWith("ccr-claude-code-api-key-") ||
|
||||
normalized.startsWith("ccr-claude-code-wif-token-") ||
|
||||
normalized.startsWith("ccr-claude-code-wrapper-") ||
|
||||
normalized.startsWith("ccr-grok-cli-wrapper-") ||
|
||||
normalized.startsWith("ccr-kimi-cli-wrapper-") ||
|
||||
|
|
@ -2716,10 +3004,11 @@ function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus
|
|||
);
|
||||
}
|
||||
const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const clientName = codexCompatibleClientName(profile.agent);
|
||||
return restoreDisabledGlobalProfile(
|
||||
profile,
|
||||
resolveCodexConfigFile(profile),
|
||||
"Codex profile is disabled.",
|
||||
`${clientName} profile is disabled.`,
|
||||
(content) => isManagedCodexConfigContent(content, providerId)
|
||||
);
|
||||
}
|
||||
|
|
@ -2745,11 +3034,12 @@ export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]):
|
|||
}
|
||||
const codexProfiles = profiles.filter((profile) => profile.agent === "codex");
|
||||
if (codexProfiles.length > 0 && !codexProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) {
|
||||
const providerIds = codexCompatibleProviderIds(codexProfiles);
|
||||
for (const file of uniqueResolvedPaths([
|
||||
...codexProfiles.map(globalCodexConfigCandidate)
|
||||
])) {
|
||||
const restoreResult = restoreGlobalConfigFile(file, {
|
||||
isManagedContent: (content) => isManagedCodexConfigContent(content, "claude-code-router"),
|
||||
isManagedContent: (content) => providerIds.some((providerId) => isManagedCodexConfigContent(content, providerId)),
|
||||
mode: privateFileMode
|
||||
});
|
||||
if (restoreResult.changed || restoreResult.missingBackup) {
|
||||
|
|
@ -2757,6 +3047,21 @@ export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]):
|
|||
}
|
||||
}
|
||||
}
|
||||
const workbuddyProfiles = profiles.filter((profile) => profile.agent === "workbuddy");
|
||||
if (workbuddyProfiles.length > 0 && !workbuddyProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) {
|
||||
const providerIds = codexCompatibleProviderIds(workbuddyProfiles);
|
||||
for (const file of uniqueResolvedPaths([
|
||||
...workbuddyProfiles.map(globalCodexConfigCandidate)
|
||||
])) {
|
||||
const restoreResult = restoreGlobalConfigFile(file, {
|
||||
isManagedContent: (content) => providerIds.some((providerId) => isManagedCodexConfigContent(content, providerId)),
|
||||
mode: privateFileMode
|
||||
});
|
||||
if (restoreResult.changed || restoreResult.missingBackup) {
|
||||
statuses.push(inactiveGlobalCleanupStatus("workbuddy", file, restoreResult));
|
||||
}
|
||||
}
|
||||
}
|
||||
const openCodeProfiles = profiles.filter((profile) => profile.agent === "opencode");
|
||||
if (openCodeProfiles.length > 0 && !openCodeProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) {
|
||||
const providerIds = [...new Set([
|
||||
|
|
@ -2823,7 +3128,14 @@ function globalCodexConfigCandidate(profile: ProfileConfig): string {
|
|||
if (codexHome) {
|
||||
return path.join(resolveUserPath(codexHome), "config.toml");
|
||||
}
|
||||
return profile.configFile || "~/.codex/config.toml";
|
||||
return profile.configFile || defaultCodexConfigFile(profile.agent);
|
||||
}
|
||||
|
||||
function codexCompatibleProviderIds(profiles: ProfileConfig[]): string[] {
|
||||
return [...new Set([
|
||||
"claude-code-router",
|
||||
...profiles.map((profile) => sanitizeCodexProviderId(profile.providerId || "")).filter(Boolean)
|
||||
])];
|
||||
}
|
||||
|
||||
function globalOpenCodeConfigCandidate(profile: ProfileConfig): string {
|
||||
|
|
@ -2931,7 +3243,7 @@ function readGlobalProfileTakeoverMarker(): GlobalProfileTakeoverRecord[] {
|
|||
}
|
||||
return parsed.profiles.filter((value): value is GlobalProfileTakeoverRecord =>
|
||||
isRecord(value) &&
|
||||
(value.agent === "claude-code" || value.agent === "codex" || value.agent === "opencode" || value.agent === "kilo" || value.agent === "zcode") &&
|
||||
(value.agent === "claude-code" || value.agent === "codex" || value.agent === "opencode" || value.agent === "kilo" || value.agent === "workbuddy" || value.agent === "zcode") &&
|
||||
typeof value.id === "string" &&
|
||||
typeof value.name === "string"
|
||||
);
|
||||
|
|
@ -3213,8 +3525,7 @@ function isManagedClaudeCodeSettingsContent(content: string): boolean {
|
|||
if (!isPureManagedClaudeCodeSettings(settings)) {
|
||||
return false;
|
||||
}
|
||||
const apiKeyHelper = typeof settings.apiKeyHelper === "string" ? settings.apiKeyHelper : "";
|
||||
if (apiKeyHelper.includes("ccr-claude-code-api-key-")) {
|
||||
if (isManagedClaudeCodeApiKeyHelper(settings.apiKeyHelper)) {
|
||||
return true;
|
||||
}
|
||||
const env = isRecord(settings.env) ? settings.env : {};
|
||||
|
|
@ -3381,6 +3692,9 @@ function codexCompatibleClientName(agent: ProfileConfig["agent"]): string {
|
|||
if (agent === "pi") {
|
||||
return "Pi";
|
||||
}
|
||||
if (agent === "workbuddy") {
|
||||
return "Workbuddy";
|
||||
}
|
||||
if (agent === "claude-design") {
|
||||
return "Claude Design";
|
||||
}
|
||||
|
|
@ -3392,6 +3706,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string {
|
|||
? "~/.zcode/cli/config.json"
|
||||
: agent === "kilo"
|
||||
? "~/.config/kilo/kilo.jsonc"
|
||||
: agent === "workbuddy"
|
||||
? "~/.workbuddy/config.toml"
|
||||
: agent === "pi"
|
||||
? "~/.pi/agent"
|
||||
: agent === "claude-design"
|
||||
|
|
@ -3400,11 +3716,11 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string {
|
|||
}
|
||||
|
||||
function codexConfigSubdir(agent: ProfileConfig["agent"]): string {
|
||||
return agent === "zcode" ? "zcode" : "codex";
|
||||
return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : "codex";
|
||||
}
|
||||
|
||||
function defaultCodexCliCommand(agent: ProfileConfig["agent"]): string {
|
||||
return agent === "zcode" ? "zcode" : "codex";
|
||||
return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "codebuddy" : "codex";
|
||||
}
|
||||
|
||||
function defaultCodexCompatibleHome(agent: ProfileConfig["agent"], configFile: string): string {
|
||||
|
|
@ -3413,6 +3729,9 @@ function defaultCodexCompatibleHome(agent: ProfileConfig["agent"], configFile: s
|
|||
|
||||
function profileEnv(profile: ProfileConfig): Record<string, string> {
|
||||
return stringRecord(profile.env).filter(([key]) => isEnvName(key)).reduce<Record<string, string>>((result, [key, value]) => {
|
||||
if (key === CLAUDE_CODE_AUTH_MODE_ENV) {
|
||||
return result;
|
||||
}
|
||||
if (profile.agent !== "claude-code" && key === CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV) {
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
712
packages/core/src/providers/model-auto-refresh.ts
Normal file
712
packages/core/src/providers/model-auto-refresh.ts
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
import { loadAppConfig, saveAppConfig } from "@ccr/core/config/config";
|
||||
import {
|
||||
isGatewayProviderEnabled,
|
||||
type AppConfig,
|
||||
type GatewayProviderCapabilityProtocol,
|
||||
type GatewayProviderConfig,
|
||||
type GatewayProviderProtocol,
|
||||
type ProfileConfig,
|
||||
type ProviderCredentialConfig,
|
||||
type ProviderModelMetadata
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { codexDefaultBaseUrl, readCodexLocalModelCatalog } from "@ccr/core/agents/local-providers/codex";
|
||||
import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared";
|
||||
import { modelRegistryForConfig, parseProviderModelSelector, providerRuntimeId } from "@ccr/core/routing/model-registry";
|
||||
import { probeGatewayProvider } from "@ccr/core/providers/probe";
|
||||
import { normalizeProviderBaseUrl } from "@ccr/core/providers/url";
|
||||
|
||||
type ProviderModelProbe = typeof probeGatewayProvider;
|
||||
type ProviderModelCatalogSnapshot = {
|
||||
modelDisplayNames?: Record<string, string>;
|
||||
modelMetadata?: Record<string, ProviderModelMetadata>;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
export const providerModelAutoRefreshIntervalMs = 10 * 60 * 1000;
|
||||
|
||||
export type ProviderModelAutoRefreshProviderResult = {
|
||||
addedModels: string[];
|
||||
error?: string;
|
||||
fetchedModels: string[];
|
||||
provider: string;
|
||||
providerIndex: number;
|
||||
skipped?: boolean;
|
||||
};
|
||||
|
||||
export type ProviderModelAutoRefreshResult = {
|
||||
changed: boolean;
|
||||
config: AppConfig;
|
||||
profileAllowlistsUpdated: number;
|
||||
providers: ProviderModelAutoRefreshProviderResult[];
|
||||
};
|
||||
|
||||
export type ProviderModelAutoRefreshOptions = {
|
||||
logger?: Pick<Console, "warn">;
|
||||
probeProvider?: ProviderModelProbe;
|
||||
};
|
||||
|
||||
export type ProviderModelAutoRefreshRuntimeOptions = ProviderModelAutoRefreshOptions & {
|
||||
loadConfig?: () => Promise<AppConfig>;
|
||||
onConfigChanged?: (config: AppConfig, result: ProviderModelAutoRefreshResult) => Promise<void> | void;
|
||||
saveConfig?: (config: AppConfig) => Promise<AppConfig>;
|
||||
};
|
||||
|
||||
let autoRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let autoRefreshEnabled = false;
|
||||
let autoRefreshInFlight: Promise<ProviderModelAutoRefreshResult> | undefined;
|
||||
let autoRefreshRuntimeOptions: ProviderModelAutoRefreshRuntimeOptions = {};
|
||||
|
||||
export function hasAutoFetchModelProviders(config: Pick<AppConfig, "Providers">): boolean {
|
||||
return config.Providers.some((provider) => provider.autoFetchModels && isGatewayProviderEnabled(provider));
|
||||
}
|
||||
|
||||
export function syncProviderModelAutoRefreshService(
|
||||
config: Pick<AppConfig, "Providers">,
|
||||
options: ProviderModelAutoRefreshRuntimeOptions = {}
|
||||
): void {
|
||||
autoRefreshRuntimeOptions = {
|
||||
...autoRefreshRuntimeOptions,
|
||||
...options
|
||||
};
|
||||
|
||||
if (!hasAutoFetchModelProviders(config)) {
|
||||
stopProviderModelAutoRefreshService();
|
||||
return;
|
||||
}
|
||||
|
||||
autoRefreshEnabled = true;
|
||||
scheduleProviderModelAutoRefresh(0);
|
||||
}
|
||||
|
||||
export function stopProviderModelAutoRefreshService(): void {
|
||||
autoRefreshEnabled = false;
|
||||
if (autoRefreshTimer) {
|
||||
clearTimeout(autoRefreshTimer);
|
||||
autoRefreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderModelAutoRefreshNow(
|
||||
options: ProviderModelAutoRefreshRuntimeOptions = {}
|
||||
): Promise<ProviderModelAutoRefreshResult> {
|
||||
autoRefreshRuntimeOptions = {
|
||||
...autoRefreshRuntimeOptions,
|
||||
...options
|
||||
};
|
||||
if (autoRefreshInFlight) {
|
||||
return autoRefreshInFlight;
|
||||
}
|
||||
|
||||
autoRefreshInFlight = runProviderModelAutoRefresh(autoRefreshRuntimeOptions).finally(() => {
|
||||
autoRefreshInFlight = undefined;
|
||||
});
|
||||
return autoRefreshInFlight;
|
||||
}
|
||||
|
||||
export async function refreshAutoFetchProviderModels(
|
||||
config: AppConfig,
|
||||
options: ProviderModelAutoRefreshOptions = {}
|
||||
): Promise<ProviderModelAutoRefreshResult> {
|
||||
const probeProvider = options.probeProvider ?? probeGatewayProvider;
|
||||
let providers = config.Providers;
|
||||
const providerResults: ProviderModelAutoRefreshProviderResult[] = [];
|
||||
const addedByProvider: Array<{ addedModels: string[]; provider: GatewayProviderConfig }> = [];
|
||||
|
||||
for (const [providerIndex, provider] of config.Providers.entries()) {
|
||||
if (!provider.autoFetchModels || !isGatewayProviderEnabled(provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerName = provider.name.trim();
|
||||
const baseUrl = providerBaseUrl(provider);
|
||||
if (!providerName || !baseUrl) {
|
||||
providerResults.push({
|
||||
addedModels: [],
|
||||
fetchedModels: [],
|
||||
provider: providerName,
|
||||
providerIndex,
|
||||
skipped: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await probeProvider({
|
||||
apiKey: providerApiKeyForModelRefresh(provider),
|
||||
baseUrl,
|
||||
forceRefresh: true,
|
||||
mode: "models",
|
||||
models: provider.models,
|
||||
providerPlugins: providerPluginsForModelRefresh(config, provider),
|
||||
protocols: providerModelRefreshProtocols(provider)
|
||||
});
|
||||
const discovered = mergeProviderModelCatalogSnapshots(probe, localProviderModelCatalogSnapshot(provider));
|
||||
const fetchedModels = uniqueModelList(discovered.models);
|
||||
const knownModels = mergeModelLists(provider.autoFetchKnownModels ?? [], provider.models);
|
||||
const hasKnownModelBaseline = Boolean(provider.autoFetchKnownModels?.length);
|
||||
const addedModels = hasKnownModelBaseline ? modelsNotIn(knownModels, fetchedModels) : [];
|
||||
const mergedModels = mergeModelLists(provider.models, addedModels);
|
||||
const nextKnownModels = mergeModelLists(knownModels, fetchedModels);
|
||||
providerResults.push({
|
||||
addedModels,
|
||||
fetchedModels,
|
||||
provider: providerName,
|
||||
providerIndex
|
||||
});
|
||||
if (addedModels.length === 0 && sameModelList(provider.autoFetchKnownModels, nextKnownModels)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextProvider = {
|
||||
...provider,
|
||||
autoFetchKnownModels: nextKnownModels.length > 0 ? nextKnownModels : undefined,
|
||||
modelDisplayNames: mergeProviderModelDisplayNames(provider.modelDisplayNames, discovered.modelDisplayNames, mergedModels),
|
||||
modelMetadata: mergeProviderModelMetadata(provider.modelMetadata, discovered.modelMetadata, mergedModels),
|
||||
models: mergedModels
|
||||
};
|
||||
providers = replaceAt(providers, providerIndex, nextProvider);
|
||||
if (addedModels.length > 0) {
|
||||
addedByProvider.push({ addedModels, provider });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = formatError(error);
|
||||
options.logger?.warn?.(`[providers] Failed to refresh models for ${providerName}: ${message}`);
|
||||
providerResults.push({
|
||||
addedModels: [],
|
||||
error: message,
|
||||
fetchedModels: [],
|
||||
provider: providerName,
|
||||
providerIndex
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (providers === config.Providers) {
|
||||
return {
|
||||
changed: false,
|
||||
config,
|
||||
profileAllowlistsUpdated: 0,
|
||||
providers: providerResults
|
||||
};
|
||||
}
|
||||
|
||||
const profileUpdate = profileAllowlistsWithAutoFetchedModels(config, addedByProvider);
|
||||
return {
|
||||
changed: true,
|
||||
config: {
|
||||
...config,
|
||||
Providers: providers,
|
||||
profile: {
|
||||
...config.profile,
|
||||
profiles: profileUpdate.profiles
|
||||
}
|
||||
},
|
||||
profileAllowlistsUpdated: profileUpdate.changedCount,
|
||||
providers: providerResults
|
||||
};
|
||||
}
|
||||
|
||||
async function runProviderModelAutoRefresh(
|
||||
options: ProviderModelAutoRefreshRuntimeOptions
|
||||
): Promise<ProviderModelAutoRefreshResult> {
|
||||
const loadConfig = options.loadConfig ?? loadAppConfig;
|
||||
const saveConfig = options.saveConfig ?? saveAppConfig;
|
||||
const config = await loadConfig();
|
||||
const result = await refreshAutoFetchProviderModels(config, options);
|
||||
if (!result.changed) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const latestConfig = await loadConfig();
|
||||
const mergedResult = mergeProviderModelAutoRefreshResult(config, latestConfig, result);
|
||||
if (!mergedResult.changed) {
|
||||
return mergedResult;
|
||||
}
|
||||
|
||||
const savedConfig = await saveConfig(mergedResult.config);
|
||||
const savedResult = {
|
||||
...mergedResult,
|
||||
config: savedConfig
|
||||
};
|
||||
await options.onConfigChanged?.(savedConfig, savedResult);
|
||||
return savedResult;
|
||||
}
|
||||
|
||||
function mergeProviderModelAutoRefreshResult(
|
||||
baseConfig: AppConfig,
|
||||
currentConfig: AppConfig,
|
||||
result: ProviderModelAutoRefreshResult
|
||||
): ProviderModelAutoRefreshResult {
|
||||
let providers = currentConfig.Providers;
|
||||
const providerResults = result.providers.map((item) => ({ ...item }));
|
||||
const addedByProvider: Array<{ addedModels: string[]; provider: GatewayProviderConfig }> = [];
|
||||
|
||||
for (const [resultIndex, providerResult] of result.providers.entries()) {
|
||||
if (providerResult.error || providerResult.skipped) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseProvider = baseConfig.Providers[providerResult.providerIndex];
|
||||
const refreshedProvider = result.config.Providers[providerResult.providerIndex];
|
||||
if (!baseProvider || !refreshedProvider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentProviderIndex = findCurrentRefreshProviderIndex(currentConfig.Providers, baseProvider, providerResult.providerIndex);
|
||||
if (currentProviderIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentProvider = currentConfig.Providers[currentProviderIndex];
|
||||
if (!canApplyProviderModelRefresh(baseConfig, currentConfig, baseProvider, currentProvider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fetchedModels = uniqueModelList(providerResult.fetchedModels);
|
||||
const knownModels = mergeModelLists(currentProvider.autoFetchKnownModels ?? [], currentProvider.models);
|
||||
const hasKnownModelBaseline = Boolean(currentProvider.autoFetchKnownModels?.length);
|
||||
const addedModels = hasKnownModelBaseline ? modelsNotIn(knownModels, fetchedModels) : [];
|
||||
const mergedModels = mergeModelLists(currentProvider.models, addedModels);
|
||||
const nextKnownModels = mergeModelLists(knownModels, fetchedModels);
|
||||
providerResults[resultIndex] = {
|
||||
...providerResult,
|
||||
addedModels,
|
||||
provider: currentProvider.name,
|
||||
providerIndex: currentProviderIndex
|
||||
};
|
||||
|
||||
const nextProvider = {
|
||||
...currentProvider,
|
||||
autoFetchKnownModels: nextKnownModels.length > 0 ? nextKnownModels : undefined,
|
||||
modelDisplayNames: mergeProviderModelDisplayNames(currentProvider.modelDisplayNames, refreshedProvider.modelDisplayNames, mergedModels),
|
||||
modelMetadata: mergeProviderModelMetadata(currentProvider.modelMetadata, refreshedProvider.modelMetadata, mergedModels),
|
||||
models: mergedModels
|
||||
};
|
||||
if (sameAutoRefreshProviderState(currentProvider, nextProvider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
providers = replaceAt(providers, currentProviderIndex, nextProvider);
|
||||
if (addedModels.length > 0) {
|
||||
addedByProvider.push({ addedModels, provider: nextProvider });
|
||||
}
|
||||
}
|
||||
|
||||
if (providers === currentConfig.Providers) {
|
||||
return {
|
||||
...result,
|
||||
changed: false,
|
||||
config: currentConfig,
|
||||
profileAllowlistsUpdated: 0,
|
||||
providers: providerResults
|
||||
};
|
||||
}
|
||||
|
||||
const configWithProviders = {
|
||||
...currentConfig,
|
||||
Providers: providers
|
||||
};
|
||||
const profileUpdate = profileAllowlistsWithAutoFetchedModels(configWithProviders, addedByProvider);
|
||||
return {
|
||||
...result,
|
||||
changed: true,
|
||||
config: {
|
||||
...configWithProviders,
|
||||
profile: {
|
||||
...configWithProviders.profile,
|
||||
profiles: profileUpdate.profiles
|
||||
}
|
||||
},
|
||||
profileAllowlistsUpdated: profileUpdate.changedCount,
|
||||
providers: providerResults
|
||||
};
|
||||
}
|
||||
|
||||
function findCurrentRefreshProviderIndex(
|
||||
providers: GatewayProviderConfig[],
|
||||
baseProvider: GatewayProviderConfig,
|
||||
preferredIndex: number
|
||||
): number {
|
||||
const preferredProvider = providers[preferredIndex];
|
||||
if (providerRefreshTargetMatches(baseProvider, preferredProvider)) {
|
||||
return preferredIndex;
|
||||
}
|
||||
return providers.findIndex((provider) => providerRefreshTargetMatches(baseProvider, provider));
|
||||
}
|
||||
|
||||
function canApplyProviderModelRefresh(
|
||||
baseConfig: AppConfig,
|
||||
currentConfig: AppConfig,
|
||||
baseProvider: GatewayProviderConfig,
|
||||
currentProvider: GatewayProviderConfig
|
||||
): boolean {
|
||||
if (!currentProvider.autoFetchModels || !isGatewayProviderEnabled(currentProvider)) {
|
||||
return false;
|
||||
}
|
||||
if (!providerRefreshTargetMatches(baseProvider, currentProvider)) {
|
||||
return false;
|
||||
}
|
||||
if (providerApiKeyForModelRefresh(baseProvider) !== providerApiKeyForModelRefresh(currentProvider)) {
|
||||
return false;
|
||||
}
|
||||
if (!sameModelList(providerModelRefreshProtocols(baseProvider), providerModelRefreshProtocols(currentProvider))) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(providerPluginsForModelRefresh(baseConfig, baseProvider)) ===
|
||||
JSON.stringify(providerPluginsForModelRefresh(currentConfig, currentProvider));
|
||||
}
|
||||
|
||||
function providerRefreshTargetMatches(
|
||||
baseProvider: GatewayProviderConfig,
|
||||
currentProvider: GatewayProviderConfig | undefined
|
||||
): currentProvider is GatewayProviderConfig {
|
||||
if (!currentProvider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseId = baseProvider.id?.trim().toLowerCase();
|
||||
const currentId = currentProvider.id?.trim().toLowerCase();
|
||||
const identityMatches = baseId && currentId
|
||||
? baseId === currentId
|
||||
: baseProvider.name.trim().toLowerCase() === currentProvider.name.trim().toLowerCase();
|
||||
return Boolean(identityMatches && sameProviderBaseUrl(baseProvider, currentProvider));
|
||||
}
|
||||
|
||||
function sameProviderBaseUrl(left: GatewayProviderConfig, right: GatewayProviderConfig): boolean {
|
||||
const leftBaseUrl = normalizeProviderBaseUrl(providerBaseUrl(left)).toLowerCase();
|
||||
const rightBaseUrl = normalizeProviderBaseUrl(providerBaseUrl(right)).toLowerCase();
|
||||
return Boolean(leftBaseUrl && rightBaseUrl && leftBaseUrl === rightBaseUrl);
|
||||
}
|
||||
|
||||
function sameAutoRefreshProviderState(left: GatewayProviderConfig, right: GatewayProviderConfig): boolean {
|
||||
return sameModelList(left.models, right.models) &&
|
||||
sameModelList(left.autoFetchKnownModels, right.autoFetchKnownModels) &&
|
||||
JSON.stringify(left.modelDisplayNames ?? {}) === JSON.stringify(right.modelDisplayNames ?? {}) &&
|
||||
JSON.stringify(left.modelMetadata ?? {}) === JSON.stringify(right.modelMetadata ?? {});
|
||||
}
|
||||
|
||||
function scheduleProviderModelAutoRefresh(delayMs: number): void {
|
||||
if (!autoRefreshEnabled) {
|
||||
return;
|
||||
}
|
||||
if (autoRefreshTimer) {
|
||||
clearTimeout(autoRefreshTimer);
|
||||
}
|
||||
autoRefreshTimer = setTimeout(() => {
|
||||
autoRefreshTimer = undefined;
|
||||
void runProviderModelAutoRefreshNow().catch((error) => {
|
||||
autoRefreshRuntimeOptions.logger?.warn?.(`[providers] Auto model refresh failed: ${formatError(error)}`);
|
||||
}).finally(() => {
|
||||
if (autoRefreshEnabled) {
|
||||
scheduleProviderModelAutoRefresh(providerModelAutoRefreshIntervalMs);
|
||||
}
|
||||
});
|
||||
}, delayMs);
|
||||
autoRefreshTimer.unref?.();
|
||||
}
|
||||
|
||||
function providerBaseUrl(provider: GatewayProviderConfig): string {
|
||||
return provider.api_base_url || provider.baseUrl || provider.baseurl || "";
|
||||
}
|
||||
|
||||
function providerApiKeyForModelRefresh(provider: GatewayProviderConfig): string {
|
||||
const direct = provider.api_key || provider.apiKey || provider.apikey || "";
|
||||
if (direct.trim()) {
|
||||
return direct.trim();
|
||||
}
|
||||
return provider.credentials
|
||||
?.filter((credential) => credential.enabled !== false)
|
||||
.map(providerCredentialApiKey)
|
||||
.find(Boolean) ?? "";
|
||||
}
|
||||
|
||||
function providerCredentialApiKey(credential: ProviderCredentialConfig): string {
|
||||
return (credential.api_key || credential.apiKey || credential.apikey || "").trim();
|
||||
}
|
||||
|
||||
function providerModelRefreshProtocols(provider: GatewayProviderConfig): GatewayProviderCapabilityProtocol[] {
|
||||
return uniqueProtocols([
|
||||
...(provider.capabilities ?? []).map((capability) => capability.type),
|
||||
toGatewayProviderProtocol(provider.type),
|
||||
toGatewayProviderProtocol(provider.provider)
|
||||
]);
|
||||
}
|
||||
|
||||
function toGatewayProviderProtocol(value: unknown): GatewayProviderProtocol | undefined {
|
||||
return typeof value === "string" && gatewayProviderProtocols.has(value as GatewayProviderProtocol)
|
||||
? value as GatewayProviderProtocol
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const gatewayProviderProtocols = new Set<GatewayProviderProtocol>([
|
||||
"anthropic_messages",
|
||||
"gemini_generate_content",
|
||||
"gemini_interactions",
|
||||
"openai_chat_completions",
|
||||
"openai_responses"
|
||||
]);
|
||||
|
||||
function providerPluginsForModelRefresh(config: AppConfig, provider: GatewayProviderConfig): unknown[] {
|
||||
return (config.providerPlugins ?? []).filter((plugin) =>
|
||||
providerPluginEnabled(plugin) &&
|
||||
localAgentProviderPluginMatches(plugin, provider)
|
||||
);
|
||||
}
|
||||
|
||||
function localAgentProviderPluginMatches(plugin: unknown, provider: GatewayProviderConfig): boolean {
|
||||
if (!isRecord(plugin)) {
|
||||
return false;
|
||||
}
|
||||
const key = readString(plugin.key)?.toLowerCase() ?? "";
|
||||
if (!key.startsWith("ccr-local-agent-")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pluginProviderName = readString(plugin.providerName) || readString(plugin.provider);
|
||||
return Boolean(pluginProviderName && providerPluginAliases(provider).has(pluginProviderName.trim().toLowerCase()));
|
||||
}
|
||||
|
||||
function providerPluginEnabled(plugin: unknown): boolean {
|
||||
return !isRecord(plugin) || plugin.enabled !== false;
|
||||
}
|
||||
|
||||
function providerPluginAliases(provider: GatewayProviderConfig): Set<string> {
|
||||
const aliases = providerAliases(provider);
|
||||
const protocols = providerModelRefreshProtocols(provider);
|
||||
for (const alias of [...aliases]) {
|
||||
for (const protocol of protocols) {
|
||||
aliases.add(`${alias}::${protocol}`);
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function localProviderModelCatalogSnapshot(provider: GatewayProviderConfig): ProviderModelCatalogSnapshot | undefined {
|
||||
if (isLocalCodexProvider(provider)) {
|
||||
return readCodexLocalModelCatalog();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLocalCodexProvider(provider: GatewayProviderConfig): boolean {
|
||||
return providerApiKeyForModelRefresh(provider) === localAgentProviderApiKey &&
|
||||
normalizeProviderBaseUrl(providerBaseUrl(provider)) === normalizeProviderBaseUrl(codexDefaultBaseUrl);
|
||||
}
|
||||
|
||||
function mergeProviderModelCatalogSnapshots(
|
||||
...snapshots: Array<ProviderModelCatalogSnapshot | undefined>
|
||||
): ProviderModelCatalogSnapshot {
|
||||
const models = mergeModelLists(...snapshots.map((snapshot) => snapshot?.models ?? []));
|
||||
const modelDisplayNames = snapshots.reduce<Record<string, string>>((merged, snapshot) => ({
|
||||
...merged,
|
||||
...(snapshot?.modelDisplayNames ?? {})
|
||||
}), {});
|
||||
const modelMetadata = snapshots.reduce<Record<string, ProviderModelMetadata>>((merged, snapshot) => ({
|
||||
...merged,
|
||||
...(snapshot?.modelMetadata ?? {})
|
||||
}), {});
|
||||
|
||||
return {
|
||||
modelDisplayNames: mergeProviderModelDisplayNames(undefined, modelDisplayNames, models),
|
||||
modelMetadata: mergeProviderModelMetadata(undefined, modelMetadata, models),
|
||||
models
|
||||
};
|
||||
}
|
||||
|
||||
function profileAllowlistsWithAutoFetchedModels(
|
||||
config: AppConfig,
|
||||
addedByProvider: Array<{ addedModels: string[]; provider: GatewayProviderConfig }>
|
||||
): { changedCount: number; profiles: ProfileConfig[] } {
|
||||
const registry = modelRegistryForConfig(config);
|
||||
let changedCount = 0;
|
||||
const profiles = config.profile.profiles.map((profile) => {
|
||||
if (!profile.availableModels?.length) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const additions = addedByProvider.flatMap(({ addedModels, provider }) =>
|
||||
profileUsesProvider(config, registry, profile, provider)
|
||||
? addedModels.map((model) => `${provider.name}/${model}`)
|
||||
: []
|
||||
);
|
||||
if (additions.length === 0) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const availableModels = mergeModelLists(profile.availableModels, additions);
|
||||
if (availableModels.length === profile.availableModels.length) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
changedCount += 1;
|
||||
return {
|
||||
...profile,
|
||||
availableModels
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
changedCount,
|
||||
profiles
|
||||
};
|
||||
}
|
||||
|
||||
function profileUsesProvider(
|
||||
config: AppConfig,
|
||||
registry: ReturnType<typeof modelRegistryForConfig>,
|
||||
profile: ProfileConfig,
|
||||
provider: GatewayProviderConfig
|
||||
): boolean {
|
||||
const aliases = providerAliases(provider);
|
||||
if ([profile.providerId, profile.providerName].some((value) => value && aliases.has(value.trim().toLowerCase()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return profileModelSelectors(profile).some((selector) => {
|
||||
const parsed = parseProviderModelSelector(selector);
|
||||
if (parsed && aliases.has(parsed.provider.trim().toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const resolved = registry.resolve(selector);
|
||||
return resolved?.kind === "provider" && providersReferToSameProvider(resolved.provider, provider, config);
|
||||
});
|
||||
}
|
||||
|
||||
function profileModelSelectors(profile: ProfileConfig): string[] {
|
||||
return uniqueModelList([
|
||||
profile.model,
|
||||
profile.fableModel,
|
||||
profile.opusModel,
|
||||
profile.sonnetModel,
|
||||
profile.haikuModel,
|
||||
profile.smallFastModel,
|
||||
...(profile.availableModels ?? [])
|
||||
].filter((value): value is string => Boolean(value)));
|
||||
}
|
||||
|
||||
function providersReferToSameProvider(
|
||||
left: GatewayProviderConfig,
|
||||
right: GatewayProviderConfig,
|
||||
config: Pick<AppConfig, "Providers">
|
||||
): boolean {
|
||||
const leftIndex = config.Providers.indexOf(left);
|
||||
const rightIndex = config.Providers.indexOf(right);
|
||||
if (leftIndex >= 0 && rightIndex >= 0) {
|
||||
return leftIndex === rightIndex;
|
||||
}
|
||||
const leftId = left.id?.trim();
|
||||
const rightId = right.id?.trim();
|
||||
if (leftId && rightId) {
|
||||
return leftId.toLowerCase() === rightId.toLowerCase();
|
||||
}
|
||||
return left.name.trim().toLowerCase() === right.name.trim().toLowerCase() &&
|
||||
providerBaseUrl(left).trim().toLowerCase() === providerBaseUrl(right).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function providerAliases(provider: GatewayProviderConfig): Set<string> {
|
||||
return new Set(
|
||||
[provider.name, provider.id, provider.provider, providerRuntimeId(provider)]
|
||||
.map((value) => value?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
);
|
||||
}
|
||||
|
||||
function mergeProviderModelDisplayNames(
|
||||
current: Record<string, string> | undefined,
|
||||
discovered: Record<string, string> | undefined,
|
||||
models: string[]
|
||||
): Record<string, string> | undefined {
|
||||
const displayNames: Record<string, string> = {};
|
||||
const merged = {
|
||||
...(current ?? {}),
|
||||
...(discovered ?? {})
|
||||
};
|
||||
for (const model of models) {
|
||||
const displayName = merged[model]?.trim();
|
||||
if (displayName && displayName !== model) {
|
||||
displayNames[model] = displayName;
|
||||
}
|
||||
}
|
||||
return Object.keys(displayNames).length > 0 ? displayNames : undefined;
|
||||
}
|
||||
|
||||
function mergeProviderModelMetadata(
|
||||
current: Record<string, ProviderModelMetadata> | undefined,
|
||||
discovered: Record<string, ProviderModelMetadata> | undefined,
|
||||
models: string[]
|
||||
): Record<string, ProviderModelMetadata> | undefined {
|
||||
const metadataByModel: Record<string, ProviderModelMetadata> = {};
|
||||
for (const model of models) {
|
||||
const metadata = {
|
||||
...(current?.[model] ?? {}),
|
||||
...(discovered?.[model] ?? {})
|
||||
};
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
metadataByModel[model] = metadata;
|
||||
}
|
||||
}
|
||||
return Object.keys(metadataByModel).length > 0 ? metadataByModel : undefined;
|
||||
}
|
||||
|
||||
function modelsNotIn(previous: string[], next: string[]): string[] {
|
||||
const previousKeys = new Set(uniqueModelList(previous).map((model) => model.toLowerCase()));
|
||||
return uniqueModelList(next).filter((model) => !previousKeys.has(model.toLowerCase()));
|
||||
}
|
||||
|
||||
function sameModelList(left: string[] | undefined, right: string[] | undefined): boolean {
|
||||
const normalizedLeft = uniqueModelList(left ?? []);
|
||||
const normalizedRight = uniqueModelList(right ?? []);
|
||||
if (normalizedLeft.length !== normalizedRight.length) {
|
||||
return false;
|
||||
}
|
||||
return normalizedLeft.every((model, index) => model.toLowerCase() === normalizedRight[index].toLowerCase());
|
||||
}
|
||||
|
||||
function mergeModelLists(...groups: string[][]): string[] {
|
||||
return uniqueModelList(groups.flat());
|
||||
}
|
||||
|
||||
function uniqueModelList(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
const key = normalized.toLowerCase();
|
||||
if (!normalized || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function uniqueProtocols(values: Array<GatewayProviderCapabilityProtocol | undefined>): GatewayProviderCapabilityProtocol[] {
|
||||
const seen = new Set<string>();
|
||||
const result: GatewayProviderCapabilityProtocol[] = [];
|
||||
for (const value of values) {
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceAt<T>(values: T[], index: number, value: T): T[] {
|
||||
return values.map((item, itemIndex) => itemIndex === index ? value : item);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -349,7 +349,7 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest)
|
|||
const typedModels = uniqueStrings(request.models ?? []);
|
||||
const modelProbe = mode !== "models" || request.skipModelDiscovery
|
||||
? { models: [] }
|
||||
: await probeModels(parsed, request.apiKey, protocols);
|
||||
: await probeModels(parsed, request.apiKey, protocols, request.providerPlugins ?? []);
|
||||
const models = (mode === "connectivity" || mode === "models") && modelProbe.models.length > 0
|
||||
? modelProbe.models
|
||||
: typedModels;
|
||||
|
|
@ -548,13 +548,15 @@ function capabilitiesFromProtocolResults(results: GatewayProviderProbeProtocolRe
|
|||
async function probeModels(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
allowedProtocols: GatewayProviderCapabilityProtocol[] = []
|
||||
allowedProtocols: GatewayProviderCapabilityProtocol[] = [],
|
||||
providerPlugins: unknown[] = []
|
||||
): Promise<ModelProbeResult> {
|
||||
for (const source of orderedModelSources(parsed, allowedProtocols)) {
|
||||
const result = await fetchModelsForSource(parsed, source, apiKey);
|
||||
const result = await fetchModelsForSource(parsed, source, apiKey, providerPlugins);
|
||||
if (result.models.length > 0) {
|
||||
return {
|
||||
baseUrl: result.baseUrl,
|
||||
modelDisplayNames: result.modelDisplayNames,
|
||||
models: result.models,
|
||||
source
|
||||
};
|
||||
|
|
@ -566,15 +568,27 @@ async function probeModels(
|
|||
};
|
||||
}
|
||||
|
||||
async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSource, apiKey: string | undefined): Promise<ModelFetchResult> {
|
||||
async function fetchModelsForSource(
|
||||
parsed: ParsedProviderUrl,
|
||||
source: ModelSource,
|
||||
apiKey: string | undefined,
|
||||
providerPlugins: unknown[] = []
|
||||
): Promise<ModelFetchResult> {
|
||||
if (source === "openai") {
|
||||
for (const baseUrl of parsed.openaiBaseUrlCandidates) {
|
||||
const result = await requestJson(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
...openAiHeaders(apiKey)
|
||||
const request = await providerProbeAuthRequest(
|
||||
`${baseUrl}/models`,
|
||||
{
|
||||
headers: {
|
||||
...openAiHeaders(apiKey)
|
||||
},
|
||||
method: "GET"
|
||||
},
|
||||
method: "GET"
|
||||
});
|
||||
providerPlugins,
|
||||
apiKey,
|
||||
{ model: "" }
|
||||
);
|
||||
const result = await requestJson(request.url, request.init);
|
||||
const modelList = parseModelList(result.payload, "openai");
|
||||
if (modelList.models.length > 0) {
|
||||
return {
|
||||
|
|
@ -591,12 +605,19 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour
|
|||
|
||||
if (source === "anthropic") {
|
||||
for (const baseUrl of parsed.anthropicBaseUrlCandidates) {
|
||||
const result = await requestJson(`${baseUrl}/v1/models`, {
|
||||
headers: {
|
||||
...anthropicHeaders(apiKey)
|
||||
const request = await providerProbeAuthRequest(
|
||||
`${baseUrl}/v1/models`,
|
||||
{
|
||||
headers: {
|
||||
...anthropicHeaders(apiKey)
|
||||
},
|
||||
method: "GET"
|
||||
},
|
||||
method: "GET"
|
||||
});
|
||||
providerPlugins,
|
||||
apiKey,
|
||||
{ model: "" }
|
||||
);
|
||||
const result = await requestJson(request.url, request.init);
|
||||
const modelList = parseModelList(result.payload, "anthropic");
|
||||
if (modelList.models.length > 0) {
|
||||
return {
|
||||
|
|
@ -611,12 +632,19 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour
|
|||
};
|
||||
}
|
||||
|
||||
const result = await requestJson(withGeminiKey(geminiApiEndpoint(parsed.geminiBaseUrl, "models"), apiKey), {
|
||||
headers: {
|
||||
...geminiHeaders(apiKey)
|
||||
const request = await providerProbeAuthRequest(
|
||||
withGeminiKey(geminiApiEndpoint(parsed.geminiBaseUrl, "models"), apiKey),
|
||||
{
|
||||
headers: {
|
||||
...geminiHeaders(apiKey)
|
||||
},
|
||||
method: "GET"
|
||||
},
|
||||
method: "GET"
|
||||
});
|
||||
providerPlugins,
|
||||
apiKey,
|
||||
{ model: "" }
|
||||
);
|
||||
const result = await requestJson(request.url, request.init);
|
||||
return {
|
||||
baseUrl: parsed.geminiBaseUrl,
|
||||
...parseModelList(result.payload, "gemini")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
|
|||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { decodeClaudeAppGatewayRouteId } from "@ccr/core/agents/claude-app/gateway-routes";
|
||||
import { REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants";
|
||||
import { estimateUsageCostUsd, providerModelPricingForUsage } from "@ccr/core/models/pricing-service";
|
||||
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native";
|
||||
|
|
@ -216,6 +217,43 @@ export class UsageStore {
|
|||
usageEvents.emit("recorded");
|
||||
}
|
||||
|
||||
async recordCapture(input: UsageCaptureInput): Promise<void> {
|
||||
const headersUsage = extractUsageFromBillingHeaders(input.responseHeaders);
|
||||
const bodyUsage = extractUsageFromBody(input.bodyText);
|
||||
const usage = normalizeUsageInputTokens(mergeUsageSnapshots(headersUsage, bodyUsage), {
|
||||
path: input.path,
|
||||
providerProtocol: input.providerProtocol,
|
||||
usageHint: bodyUsage
|
||||
});
|
||||
const fallbackAttribution = resolveUsageModelAttribution(input.config, input.fallbackModel);
|
||||
const responseAttribution = resolveUsageResponseModelAttribution(input.config, bodyUsage?.model);
|
||||
const route = splitRouteSelector(input.fallbackModel);
|
||||
const provider =
|
||||
input.providerName ??
|
||||
readHeader(input.responseHeaders, "x-gateway-target-provider-name") ??
|
||||
readHeader(input.responseHeaders, "x-gateway-target-provider") ??
|
||||
responseAttribution.provider ??
|
||||
fallbackAttribution.provider ??
|
||||
route.provider;
|
||||
const model = responseAttribution.model ?? fallbackAttribution.model ?? route.model ?? input.fallbackModel;
|
||||
|
||||
await this.record({
|
||||
durationMs: input.durationMs,
|
||||
method: input.method,
|
||||
logicalModel: fallbackAttribution.logicalModel ?? input.fallbackModel,
|
||||
model,
|
||||
modelIsRouteSelector: false,
|
||||
path: input.path,
|
||||
client: input.client,
|
||||
provider,
|
||||
pricing: providerModelPricingForUsage(input.config, provider, model),
|
||||
credentialId: readCredentialId(input.responseHeaders),
|
||||
requestId: input.requestId,
|
||||
statusCode: input.statusCode,
|
||||
usage
|
||||
});
|
||||
}
|
||||
|
||||
async hasRequestId(requestId: string): Promise<boolean> {
|
||||
const normalizedRequestId = requestId.trim();
|
||||
if (!normalizedRequestId) {
|
||||
|
|
@ -469,49 +507,24 @@ export async function getUsageTotalsSince(since: Date, filter?: UsageStatsFilter
|
|||
|
||||
export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promise<void> {
|
||||
try {
|
||||
const headersUsage = extractUsageFromBillingHeaders(input.responseHeaders);
|
||||
const bodyUsage = extractUsageFromBody(input.bodyText);
|
||||
const usage = normalizeUsageInputTokens(mergeUsageSnapshots(headersUsage, bodyUsage), {
|
||||
path: input.path,
|
||||
providerProtocol: input.providerProtocol,
|
||||
usageHint: bodyUsage
|
||||
});
|
||||
const fallbackAttribution = resolveUsageModelAttribution(input.config, input.fallbackModel);
|
||||
const responseAttribution = resolveUsageModelAttribution(
|
||||
input.config,
|
||||
bodyUsage?.model,
|
||||
{ physicalModel: true }
|
||||
);
|
||||
const route = splitRouteSelector(input.fallbackModel);
|
||||
const provider =
|
||||
input.providerName ??
|
||||
readHeader(input.responseHeaders, "x-gateway-target-provider-name") ??
|
||||
readHeader(input.responseHeaders, "x-gateway-target-provider") ??
|
||||
responseAttribution.provider ??
|
||||
fallbackAttribution.provider ??
|
||||
route.provider;
|
||||
const model = responseAttribution.model ?? fallbackAttribution.model ?? route.model ?? input.fallbackModel;
|
||||
|
||||
await usageStore.record({
|
||||
durationMs: input.durationMs,
|
||||
method: input.method,
|
||||
logicalModel: fallbackAttribution.logicalModel ?? input.fallbackModel,
|
||||
model,
|
||||
modelIsRouteSelector: false,
|
||||
path: input.path,
|
||||
client: input.client,
|
||||
provider,
|
||||
pricing: providerModelPricingForUsage(input.config, provider, model),
|
||||
credentialId: readCredentialId(input.responseHeaders),
|
||||
requestId: input.requestId,
|
||||
statusCode: input.statusCode,
|
||||
usage
|
||||
});
|
||||
await usageStore.recordCapture(input);
|
||||
} catch (error) {
|
||||
console.warn(`[usage] Failed to record usage: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUsageResponseModelAttribution(
|
||||
config: Pick<AppConfig, "Providers" | "virtualModelProfiles"> | undefined,
|
||||
model: string | undefined
|
||||
) {
|
||||
const decodedClaudeRouteModel = model ? decodeClaudeAppGatewayRouteId(model) : undefined;
|
||||
if (decodedClaudeRouteModel) {
|
||||
const attribution = resolveUsageModelAttribution(config, decodedClaudeRouteModel);
|
||||
return !config || attribution.provider ? attribution : {};
|
||||
}
|
||||
return resolveUsageModelAttribution(config, model, { physicalModel: true });
|
||||
}
|
||||
|
||||
function buildUsageWhereClause(
|
||||
since: Date,
|
||||
filter: UsageStatsFilter | null | undefined,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { loadOnboardingFinished, markOnboardingFinished } from "@ccr/core/config
|
|||
import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service";
|
||||
import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service";
|
||||
import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
|
||||
import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch";
|
||||
import { findInstalledCodexAppExecutable, findInstalledWorkbuddyAppExecutable } from "@ccr/core/agents/codex/app-launch";
|
||||
import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch";
|
||||
import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config";
|
||||
import {
|
||||
|
|
@ -33,13 +33,14 @@ import { getLocalAgentProviderCandidates, importLocalAgentProvider, probeLocalAg
|
|||
import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog";
|
||||
import { getProviderPresets } from "@ccr/core/providers/presets/index";
|
||||
import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe";
|
||||
import { stopProviderModelAutoRefreshService, syncProviderModelAutoRefreshService } from "@ccr/core/providers/model-auto-refresh";
|
||||
import { applyProfileConfig } from "@ccr/core/profiles/service";
|
||||
import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service";
|
||||
import { getPluginMarketplace } from "@ccr/core/plugins/marketplace";
|
||||
import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery";
|
||||
import { closeRequestLogRuntime, getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store";
|
||||
import { closeRequestLogRuntime, getAgentAnalysis, getAgentTracePayload, getRequestLogBodyChunk, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store";
|
||||
import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { getUsageStats } from "@ccr/core/usage/store";
|
||||
import { gatewayService } from "@ccr/core/gateway/service";
|
||||
|
|
@ -77,6 +78,7 @@ import type {
|
|||
ProviderCatalogModelsRequest,
|
||||
ProviderIconDetectionRequest,
|
||||
ProviderManifestFetchRequest,
|
||||
RequestLogBodyChunkRequest,
|
||||
RequestLogDetailRequest,
|
||||
RequestLogListFilter,
|
||||
RouteScriptTestRequest,
|
||||
|
|
@ -264,7 +266,17 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
const synced = await syncClaudeAppGatewayConfig(baseConfig);
|
||||
const savedConfig = synced.config;
|
||||
let runtimeStatus = gatewayService.getStatus();
|
||||
if (synced.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") {
|
||||
const restartRequired = synced.configChanged ||
|
||||
shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) ||
|
||||
runtimeStatus.state !== "running";
|
||||
if (runtimeStatus.gatewayManagedExternally) {
|
||||
if (restartRequired) {
|
||||
runtimeStatus = await gatewayService.restart(savedConfig);
|
||||
} else {
|
||||
await gatewayService.updateConfig(savedConfig);
|
||||
runtimeStatus = gatewayService.getStatus();
|
||||
}
|
||||
} else if (restartRequired) {
|
||||
runtimeStatus = await gatewayService.start(savedConfig);
|
||||
} else {
|
||||
await gatewayService.updateConfig(savedConfig);
|
||||
|
|
@ -323,6 +335,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(),
|
||||
getProxyStatus: () => proxyService.getStatus(),
|
||||
getRequestLogDetail: (request) => getRequestLogDetail(request as RequestLogDetailRequest),
|
||||
getRequestLogBodyChunk: (request) => getRequestLogBodyChunk(request as RequestLogBodyChunkRequest),
|
||||
getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined),
|
||||
getUpdateStatus: () => unsupportedUpdateStatus,
|
||||
getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined),
|
||||
|
|
@ -365,7 +378,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
openProfile: async (request) => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
if (status.state !== "running") {
|
||||
throw new Error(status.lastError || "CCR gateway did not start.");
|
||||
}
|
||||
|
|
@ -381,14 +394,14 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
restartGateway: async () => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.restart(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
return status;
|
||||
},
|
||||
restartProxy: async () => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.restart(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
return proxyService.getStatus();
|
||||
},
|
||||
|
|
@ -419,7 +432,16 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
|
||||
savedConfig = syncedClaudeAppConfig.config;
|
||||
let runtimeStatus = gatewayService.getStatus();
|
||||
if (syncedClaudeAppConfig.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig)) {
|
||||
const restartRequired = syncedClaudeAppConfig.configChanged ||
|
||||
shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig);
|
||||
if (runtimeStatus.gatewayManagedExternally) {
|
||||
if (restartRequired) {
|
||||
runtimeStatus = await gatewayService.restart(savedConfig);
|
||||
} else {
|
||||
await gatewayService.updateConfig(savedConfig);
|
||||
runtimeStatus = gatewayService.getStatus();
|
||||
}
|
||||
} else if (restartRequired) {
|
||||
runtimeStatus = await gatewayService.start(savedConfig);
|
||||
} else {
|
||||
await gatewayService.updateConfig(savedConfig);
|
||||
|
|
@ -428,6 +450,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
await applyProfileIfServiceRunning(savedConfig, runtimeStatus);
|
||||
}
|
||||
invalidateProviderAccountSnapshotCache();
|
||||
syncProviderModelAutoRefresh(savedConfig);
|
||||
return savedConfig;
|
||||
},
|
||||
scanBotHandoffBluetoothTargets: () => scanBotHandoffBluetoothTargets(),
|
||||
|
|
@ -442,7 +465,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
startGateway: async () => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
return status;
|
||||
},
|
||||
|
|
@ -467,7 +490,7 @@ async function startConfiguredServices(reason: string): Promise<void> {
|
|||
} catch (error) {
|
||||
console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
|
||||
}
|
||||
const status = await gatewayService.start(config);
|
||||
const status = await gatewayService.ensureStarted(config);
|
||||
if (status.state === "error") {
|
||||
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
|
||||
}
|
||||
|
|
@ -482,12 +505,14 @@ async function startConfiguredServices(reason: string): Promise<void> {
|
|||
console.error(`Proxy mode is enabled, but system proxy is ${proxyStatus.systemProxy.state} during ${reason}${details}`);
|
||||
}
|
||||
}
|
||||
syncProviderModelAutoRefresh(config);
|
||||
} catch (error) {
|
||||
console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopConfiguredServices(): Promise<void> {
|
||||
stopProviderModelAutoRefreshService();
|
||||
await gatewayService.stop({ proxyRestoreTimeoutMs: 30_000 }).catch((error) => {
|
||||
console.error(`Failed to stop gateway: ${formatError(error)}`);
|
||||
});
|
||||
|
|
@ -505,6 +530,17 @@ async function applyProfileIfServiceRunning(config: AppConfig, status: GatewaySt
|
|||
logProfileApplyResult(await applyProfileConfig(config));
|
||||
}
|
||||
|
||||
function syncProviderModelAutoRefresh(config: AppConfig): void {
|
||||
syncProviderModelAutoRefreshService(config, {
|
||||
logger: console,
|
||||
onConfigChanged: async (nextConfig) => {
|
||||
await gatewayService.updateConfig(nextConfig);
|
||||
await applyProfileIfServiceRunning(nextConfig, gatewayService.getStatus());
|
||||
invalidateProviderAccountSnapshotCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function logProfileApplyResult(result: ProfileApplyResult): void {
|
||||
for (const client of result.clients) {
|
||||
if (!client.ok) {
|
||||
|
|
@ -516,6 +552,7 @@ function logProfileApplyResult(result: ProfileApplyResult): void {
|
|||
function getCliAppInfo(): AppInfo {
|
||||
const chatgptAppPath = findInstalledCodexAppExecutable().executable;
|
||||
const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable;
|
||||
const workbuddyAppPath = findInstalledWorkbuddyAppExecutable().executable;
|
||||
return {
|
||||
...(chatgptAppPath ? { chatgptAppPath } : {}),
|
||||
configDbFile: APP_CONFIG_DB_FILE,
|
||||
|
|
@ -528,7 +565,8 @@ function getCliAppInfo(): AppInfo {
|
|||
platform: process.platform,
|
||||
requestLogsDbFile: REQUEST_LOGS_DB_FILE,
|
||||
usageDbFile: USAGE_DB_FILE,
|
||||
version: packageJson.version
|
||||
version: packageJson.version,
|
||||
...(workbuddyAppPath ? { workbuddyAppPath } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,318 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler.ts";
|
||||
import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline.ts";
|
||||
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin.ts";
|
||||
import { profileApiKeyId } from "@ccr/core/profiles/api-key.ts";
|
||||
import { waitForTcpListener } from "../../support/loopback-listener.mjs";
|
||||
|
||||
test("gateway HTTP model discovery and request access honor profile model allowlists", async (t) => {
|
||||
const config = createProfileAllowlistHttpTestConfig();
|
||||
const plugin = new ClaudeCodeRouterPlugin(config);
|
||||
const runtimeConfigRevision = "a".repeat(64);
|
||||
const runtimeConfigReloads = [];
|
||||
const status = {
|
||||
coreEndpoint: "http://127.0.0.1:1",
|
||||
endpoint: "http://127.0.0.1:0",
|
||||
state: "running"
|
||||
};
|
||||
const pipeline = new GatewayRequestPipeline({
|
||||
getBrowserWebSearchMcpIntegration: () => undefined,
|
||||
getConfig: () => config,
|
||||
getCoreAuthToken: () => "test-core-token",
|
||||
getPlugin: () => plugin,
|
||||
getStatus: () => status
|
||||
});
|
||||
const handler = new GatewayHttpRequestHandler({
|
||||
getBrowserAutomationMcpIntegration: () => undefined,
|
||||
getConfig: () => config,
|
||||
getPlugin: () => plugin,
|
||||
getRuntimeConfigControlStatus: () => ({ revision: runtimeConfigRevision }),
|
||||
getStatus: () => status,
|
||||
handleBillingUsageSync: unsupportedHandler,
|
||||
handleRawTraceSync: unsupportedHandler,
|
||||
proxyRequest: (request, response, path, apiKey) => pipeline.proxyRequest(request, response, path, apiKey),
|
||||
requestRuntimeConfigReload: (expectedRevision, forceRestart) => {
|
||||
runtimeConfigReloads.push({ expectedRevision, forceRestart });
|
||||
},
|
||||
replayContextArchive: async () => ({ ok: false, statusCode: 404, message: "not configured" })
|
||||
});
|
||||
const server = createServer((request, response) => {
|
||||
void handler.handleRequest(request, response).catch((error) => {
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(502, { "content-type": "application/json" });
|
||||
}
|
||||
response.end(JSON.stringify({ error: { message: String(error?.message ?? error) } }));
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
await listen(server);
|
||||
} catch (error) {
|
||||
if (isLocalListenUnavailable(error)) {
|
||||
t.skip(`Local HTTP listen is unavailable: ${formatError(error)}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await waitForTcpListener(server);
|
||||
const endpoint = `http://127.0.0.1:${serverPort(server)}`;
|
||||
status.endpoint = endpoint;
|
||||
|
||||
const runtimeConfigStatus = await fetchJson(`${endpoint}/__ccr/runtime/config`, {
|
||||
headers: authHeaders("gateway-key")
|
||||
});
|
||||
assert.equal(runtimeConfigStatus.revision, runtimeConfigRevision);
|
||||
assert.equal(runtimeConfigStatus.state, "running");
|
||||
|
||||
const deniedRuntimeConfigStatus = await fetch(`${endpoint}/__ccr/runtime/config`, {
|
||||
headers: authHeaders("profile-alpha-key")
|
||||
});
|
||||
assert.equal(deniedRuntimeConfigStatus.status, 403);
|
||||
|
||||
const runtimeConfigReload = await fetch(`${endpoint}/__ccr/runtime/config`, {
|
||||
body: JSON.stringify({ configRevision: runtimeConfigRevision, forceRestart: true }),
|
||||
headers: {
|
||||
...authHeaders("gateway-key"),
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
assert.equal(runtimeConfigReload.status, 202, await runtimeConfigReload.text());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(runtimeConfigReloads, [{ expectedRevision: runtimeConfigRevision, forceRestart: true }]);
|
||||
|
||||
const alphaModels = await fetchJson(`${endpoint}/v1/models`, {
|
||||
headers: authHeaders("profile-alpha-key")
|
||||
});
|
||||
assert.deepEqual(alphaModels.data.map((model) => model.id), ["Provider/alpha"]);
|
||||
|
||||
const betaModels = await fetchJson(`${endpoint}/v1/models`, {
|
||||
headers: authHeaders("profile-beta-key")
|
||||
});
|
||||
assert.deepEqual(betaModels.data.map((model) => model.id), ["Provider/beta"]);
|
||||
|
||||
const gatewayModels = await fetchJson(`${endpoint}/v1/models`, {
|
||||
headers: authHeaders("gateway-key")
|
||||
});
|
||||
assert.deepEqual(gatewayModels.data.map((model) => model.id), ["Provider/alpha", "Provider/beta", "Provider/gamma"]);
|
||||
|
||||
const claudeModels = await fetchJson(`${endpoint}/v1/models`, {
|
||||
headers: {
|
||||
...authHeaders("profile-alpha-key"),
|
||||
"user-agent": "claude-cli/1.0"
|
||||
}
|
||||
});
|
||||
assert.deepEqual(claudeModels.data.map((model) => model.display_name), ["Provider/alpha"]);
|
||||
|
||||
const claudeAppModels = await fetchJson(`${endpoint}/v1/models`, {
|
||||
headers: authHeaders("profile-alpha-claude-app-key")
|
||||
});
|
||||
const alphaClaudeAppRouteId = claudeAppModels.data[0]?.id;
|
||||
assert.equal(claudeAppModels.data.length, 1);
|
||||
assert.equal(typeof alphaClaudeAppRouteId, "string");
|
||||
assert.notEqual(alphaClaudeAppRouteId, "Provider/alpha");
|
||||
|
||||
const bootstrap = await fetchJson(`${endpoint}/api/claude_cli/bootstrap`, {
|
||||
headers: authHeaders("profile-alpha-key")
|
||||
});
|
||||
assert.deepEqual(bootstrap.additional_model_options.map((model) => model.display_name), ["Provider/alpha"]);
|
||||
assert.deepEqual(Object.values(bootstrap.auto_compact_windows), []);
|
||||
|
||||
const denied = await fetch(`${endpoint}/v1/messages`, {
|
||||
body: JSON.stringify({
|
||||
max_tokens: 8,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
model: "Provider/beta"
|
||||
}),
|
||||
headers: {
|
||||
...authHeaders("profile-alpha-key"),
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
const deniedBody = await denied.json();
|
||||
assert.equal(denied.status, 403);
|
||||
assert.equal(deniedBody.error.code, "profile_model_not_allowed");
|
||||
|
||||
const limitedDenied = await fetch(`${endpoint}/v1/messages`, {
|
||||
body: JSON.stringify({
|
||||
max_tokens: 8,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
model: "Provider/beta"
|
||||
}),
|
||||
headers: {
|
||||
...authHeaders("profile-alpha-limited-key"),
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
const limitedDeniedBody = await limitedDenied.json();
|
||||
assert.equal(limitedDenied.status, 403);
|
||||
assert.equal(limitedDeniedBody.error.code, "profile_model_not_allowed");
|
||||
|
||||
const deniedCountTokens = await fetch(`${endpoint}/v1/messages/count_tokens`, {
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
model: "Provider/beta"
|
||||
}),
|
||||
headers: {
|
||||
...authHeaders("profile-alpha-key"),
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
const deniedCountTokensBody = await deniedCountTokens.json();
|
||||
assert.equal(deniedCountTokens.status, 403);
|
||||
assert.equal(deniedCountTokensBody.error.code, "profile_model_not_allowed");
|
||||
|
||||
const allowedClaudeAppCountTokens = await fetchJson(`${endpoint}/v1/messages/count_tokens`, {
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
model: alphaClaudeAppRouteId
|
||||
}),
|
||||
headers: {
|
||||
...authHeaders("profile-alpha-claude-app-limited-key"),
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
assert.equal(typeof allowedClaudeAppCountTokens.input_tokens, "number");
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
function createProfileAllowlistHttpTestConfig() {
|
||||
const alphaProfile = {
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
id: "alpha-profile",
|
||||
model: "Provider/alpha",
|
||||
name: "Alpha Profile",
|
||||
scope: "global",
|
||||
availableModels: ["Provider/alpha"]
|
||||
};
|
||||
const betaProfile = {
|
||||
agent: "codex",
|
||||
enabled: true,
|
||||
id: "beta-profile",
|
||||
model: "Provider/beta",
|
||||
name: "Beta Profile",
|
||||
scope: "ccr",
|
||||
availableModels: ["Provider/beta"]
|
||||
};
|
||||
const config = createDefaultAppConfig();
|
||||
config.APIKEY = "gateway-key";
|
||||
config.APIKEYS = [
|
||||
{
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: "gateway",
|
||||
key: "gateway-key",
|
||||
name: "Gateway"
|
||||
},
|
||||
{
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(alphaProfile),
|
||||
key: "profile-alpha-key",
|
||||
name: "Profile: Alpha Profile"
|
||||
},
|
||||
{
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(alphaProfile),
|
||||
key: "profile-alpha-limited-key",
|
||||
limits: { maxRequests: 1, windowMs: 60_000 },
|
||||
name: "Profile: Alpha Profile"
|
||||
},
|
||||
{
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(alphaProfile),
|
||||
key: "profile-alpha-claude-app-key",
|
||||
name: "Claude App"
|
||||
},
|
||||
{
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(alphaProfile),
|
||||
key: "profile-alpha-claude-app-limited-key",
|
||||
limits: { maxRequests: 1, windowMs: 60_000 },
|
||||
name: "Claude App"
|
||||
},
|
||||
{
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(betaProfile),
|
||||
key: "profile-beta-key",
|
||||
name: "Profile: Beta Profile"
|
||||
}
|
||||
];
|
||||
config.Providers = [{
|
||||
api_key: "test-provider-key",
|
||||
base_url: "http://127.0.0.1:1/v1",
|
||||
models: ["alpha", "beta", "gamma"],
|
||||
name: "Provider",
|
||||
type: "openai_responses"
|
||||
}];
|
||||
config.profile = {
|
||||
...config.profile,
|
||||
enabled: true,
|
||||
profiles: [alphaProfile, betaProfile]
|
||||
};
|
||||
config.gateway.enabled = true;
|
||||
return config;
|
||||
}
|
||||
|
||||
function authHeaders(token) {
|
||||
return {
|
||||
authorization: `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url, init) {
|
||||
const response = await fetch(url, init);
|
||||
const text = await response.text();
|
||||
assert.equal(response.status, 200, text);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function serverPort(server) {
|
||||
const address = server.address();
|
||||
assert.equal(typeof address, "object");
|
||||
assert.ok(address);
|
||||
return address.port;
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!server.listening) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
server.close((error) => {
|
||||
error ? reject(error) : resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function unsupportedHandler() {
|
||||
throw new Error("not supported in this test");
|
||||
}
|
||||
|
||||
function isLocalListenUnavailable(error) {
|
||||
return error && typeof error === "object" && (error.code === "EPERM" || error.code === "EACCES");
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -323,13 +323,14 @@ test("RequestLogRuntime persists unmatched raw trace updates across worker resta
|
|||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore retains a bounded prefix for oversized unmatched raw trace bodies", async () => {
|
||||
test("RequestLogStore stores oversized unmatched raw trace bodies in sidecar storage", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-pending-entry-budget-test-"));
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
try {
|
||||
const body = "s".repeat(3 * 1024 * 1024);
|
||||
await store.writeBatch([{
|
||||
input: {
|
||||
requestBodyText: "s".repeat(3 * 1024 * 1024),
|
||||
requestBodyText: body,
|
||||
requestId: "oversized-pending-raw"
|
||||
},
|
||||
kind: "raw-trace-update",
|
||||
|
|
@ -344,25 +345,29 @@ test("RequestLogStore retains a bounded prefix for oversized unmatched raw trace
|
|||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
const detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.match(detail.requestBody.text, /^s+$/);
|
||||
assert.equal(Buffer.byteLength(detail.requestBody.text), 512 * 1024);
|
||||
assert.equal(detail.requestBody.sizeBytes, 3 * 1024 * 1024);
|
||||
assert.equal(detail.requestBody.truncated, true);
|
||||
assert.ok(detail.requestBody.bodyRef);
|
||||
assert.equal(detail.requestBody.preview, true);
|
||||
assert.match(detail.requestBody.text, /bytes omitted from preview/);
|
||||
assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.equal(await readStoredBodyText(store, page.items[0].id, "request"), body);
|
||||
} finally {
|
||||
await store.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore bounds unmatched request and response bodies independently", async () => {
|
||||
test("RequestLogStore stores unmatched request and response bodies independently", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-pending-body-budget-test-"));
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
try {
|
||||
const requestBody = "q".repeat(3 * 1024 * 1024);
|
||||
const responseBody = "p".repeat(3 * 1024 * 1024);
|
||||
await store.writeBatch([{
|
||||
input: {
|
||||
requestBodyText: "q".repeat(3 * 1024 * 1024),
|
||||
requestBodyText: requestBody,
|
||||
requestId: "oversized-pending-pair",
|
||||
responseBodyText: "p".repeat(3 * 1024 * 1024)
|
||||
responseBodyText: responseBody
|
||||
},
|
||||
kind: "raw-trace-update",
|
||||
sequence: 1
|
||||
|
|
@ -376,12 +381,14 @@ test("RequestLogStore bounds unmatched request and response bodies independently
|
|||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
const detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.equal(Buffer.byteLength(detail.requestBody.text), 512 * 1024);
|
||||
assert.equal(Buffer.byteLength(detail.responseBody.text), 512 * 1024);
|
||||
assert.equal(detail.requestBody.sizeBytes, 3 * 1024 * 1024);
|
||||
assert.equal(detail.responseBody.sizeBytes, 3 * 1024 * 1024);
|
||||
assert.equal(detail.requestBody.truncated, true);
|
||||
assert.equal(detail.responseBody.truncated, true);
|
||||
assert.ok(detail.requestBody.bodyRef);
|
||||
assert.ok(detail.responseBody.bodyRef);
|
||||
assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(requestBody));
|
||||
assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(responseBody));
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.equal(detail.responseBody.truncated, false);
|
||||
assert.equal(await readStoredBodyText(store, page.items[0].id, "request"), requestBody);
|
||||
assert.equal(await readStoredBodyText(store, page.items[0].id, "response"), responseBody);
|
||||
} finally {
|
||||
await store.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
|
|
@ -423,7 +430,9 @@ test("RequestLogStore enforces a total byte budget for unmatched raw trace rows"
|
|||
const oldest = await store.getDetail({ id: oldestEntry.id });
|
||||
const newest = await store.getDetail({ id: newestEntry.id });
|
||||
assert.match(oldest.requestBody.text, /worker-model/);
|
||||
assert.match(newest.requestBody.text, /^(?:17)+$/);
|
||||
assert.ok(newest.requestBody.bodyRef);
|
||||
assert.match(newest.requestBody.text, /bytes omitted from preview/);
|
||||
assert.match(await readStoredBodyText(store, newestEntry.id, "request"), /^(?:17)+$/);
|
||||
} finally {
|
||||
await store.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
|
|
@ -460,7 +469,8 @@ test("RequestLogRuntime preserves original response sizes when normal body captu
|
|||
assert.equal(metadataEntry.resolvedModel, "resolved-model");
|
||||
assert.equal(metadataEntry.responseModel, "response-model");
|
||||
assert.equal(truncated.responseBody.sizeBytes, Buffer.byteLength(fullResponse));
|
||||
assert.equal(Buffer.byteLength(truncated.responseBody.text), 512 * 1024);
|
||||
assert.ok(truncated.responseBody.bodyRef);
|
||||
assert.equal(Buffer.byteLength(await readStoredBodyText(runtime, truncatedEntry.id, "response")), 512 * 1024);
|
||||
assert.equal(truncated.responseBody.truncated, true);
|
||||
assert.equal(metadata.responseBody.sizeBytes, Buffer.byteLength("exists"));
|
||||
assert.equal(metadata.responseBody.text, "");
|
||||
|
|
@ -586,7 +596,7 @@ test("RequestLogRuntime keeps the record body-removal policy for later file-back
|
|||
}
|
||||
});
|
||||
|
||||
test("RequestLogRuntime retains inline raw trace bodies below the default safety limit", async () => {
|
||||
test("RequestLogRuntime stores large inline raw trace bodies in sidecar storage", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-raw-body-test-"));
|
||||
const runtime = createRuntime(dir);
|
||||
try {
|
||||
|
|
@ -604,7 +614,9 @@ test("RequestLogRuntime retains inline raw trace bodies below the default safety
|
|||
const page = await runtime.list({ pageSize: 25 });
|
||||
const detail = await runtime.getDetail({ id: page.items[0].id });
|
||||
assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.responseBody.text.length, body.length);
|
||||
assert.ok(detail.responseBody.bodyRef);
|
||||
assert.match(detail.responseBody.text, /bytes omitted from preview/);
|
||||
assert.equal(await readStoredBodyText(runtime, page.items[0].id, "response"), body);
|
||||
assert.equal(detail.responseBody.truncated, false);
|
||||
} finally {
|
||||
await runtime.close({ timeoutMs: 5_000 });
|
||||
|
|
@ -612,7 +624,7 @@ test("RequestLogRuntime retains inline raw trace bodies below the default safety
|
|||
}
|
||||
});
|
||||
|
||||
test("RequestLogRuntime bounds file-backed raw bodies and cleans bundles only after ACK", async () => {
|
||||
test("RequestLogRuntime stores file-backed raw bodies and cleans bundles only after ACK", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-raw-file-test-"));
|
||||
const spoolDir = path.join(dir, "raw-trace-spool");
|
||||
const bundleDir = path.join(spoolDir, "bundle");
|
||||
|
|
@ -643,8 +655,10 @@ test("RequestLogRuntime bounds file-backed raw bodies and cleans bundles only af
|
|||
const page = await runtime.list({ pageSize: 25 });
|
||||
const detail = await runtime.getDetail({ id: page.items[0].id });
|
||||
assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.responseBody.text.length, 256 * 1024);
|
||||
assert.equal(detail.responseBody.truncated, true);
|
||||
assert.ok(detail.responseBody.bodyRef);
|
||||
assert.match(detail.responseBody.text, /bytes omitted from preview/);
|
||||
assert.equal(await readStoredBodyText(runtime, page.items[0].id, "response"), body);
|
||||
assert.equal(detail.responseBody.truncated, false);
|
||||
} finally {
|
||||
await runtime.close({ timeoutMs: 5_000 });
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
|
|
@ -685,8 +699,11 @@ test("RequestLogRuntime compacts Base64 images while reading file-backed raw tra
|
|||
const detail = await runtime.getDetail({ id: page.items[0].id });
|
||||
const captured = JSON.parse(detail.requestBody.text);
|
||||
assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.requestBody.truncated, true);
|
||||
assert.ok(detail.requestBody.bodyRef);
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.match(captured.image_url.url, /^data:image\/jpeg;base64,\[base64 image omitted from log;/);
|
||||
const fullBody = JSON.parse(await readStoredBodyText(runtime, page.items[0].id, "request"));
|
||||
assert.equal(fullBody.image_url.url, `data:image/jpeg;base64,${"A".repeat(512 * 1024)}`);
|
||||
assert.equal(existsSync(bundleDir), false);
|
||||
} finally {
|
||||
await runtime.close({ timeoutMs: 5_000 });
|
||||
|
|
@ -1621,6 +1638,20 @@ function createRuntime(dir, options = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
async function readStoredBodyText(source, id, side) {
|
||||
let offset = 0;
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const chunk = await source.getBodyChunk({ id, length: 512 * 1024, offset, side });
|
||||
assert.ok(chunk, `expected ${side} body chunk at offset ${offset}`);
|
||||
chunks.push(chunk.text);
|
||||
if (chunk.eof) break;
|
||||
assert.ok(chunk.nextOffset > offset);
|
||||
offset = chunk.nextOffset;
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function createRecord(requestId) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -318,6 +318,185 @@ test("RequestLogStore keeps list rows lightweight and detail rows complete", asy
|
|||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore keeps large request bodies in sidecar storage and reads them by chunk", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-sidecar-test-"));
|
||||
let store;
|
||||
try {
|
||||
store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const body = JSON.stringify({
|
||||
messages: [{ content: "hello", role: "user" }],
|
||||
model: "request-model",
|
||||
padding: "中🙂".repeat(40 * 1024),
|
||||
tail: "request-tail"
|
||||
});
|
||||
const response = JSON.stringify({
|
||||
model: "response-model",
|
||||
padding: "y".repeat(220 * 1024),
|
||||
tail: "response-tail",
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 4,
|
||||
total_tokens: 7
|
||||
}
|
||||
});
|
||||
|
||||
await store.record({
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: 42,
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
providerName: "test-provider",
|
||||
requestBody: Buffer.from(body, "utf8"),
|
||||
requestHeaders: { "content-type": "application/json" },
|
||||
requestId: "request-log-sidecar-test",
|
||||
responseBodyText: response,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
startedAt: new Date().toISOString(),
|
||||
statusCode: 200,
|
||||
url: "http://127.0.0.1:3456/v1/messages"
|
||||
});
|
||||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
assert.equal(page.items.length, 1);
|
||||
assert.equal(page.items[0].requestBody.text, "");
|
||||
assert.equal(page.items[0].requestBody.preview, true);
|
||||
assert.ok(page.items[0].requestBody.bodyRef);
|
||||
|
||||
const detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.ok(detail);
|
||||
assert.equal(detail.requestBody.preview, true);
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.match(detail.requestBody.text, /bytes omitted from preview/);
|
||||
assert.match(detail.requestBody.text, /request-tail/);
|
||||
assert.equal(detail.responseBody?.preview, true);
|
||||
assert.equal(detail.responseBody?.truncated, false);
|
||||
assert.match(detail.responseBody?.text ?? "", /response-tail/);
|
||||
|
||||
const requestChunk = await store.getBodyChunk({
|
||||
id: detail.id,
|
||||
length: 512 * 1024,
|
||||
offset: 0,
|
||||
side: "request"
|
||||
});
|
||||
assert.ok(requestChunk);
|
||||
assert.equal(requestChunk.eof, true);
|
||||
assert.equal(requestChunk.truncated, false);
|
||||
assert.equal(requestChunk.text, body);
|
||||
|
||||
let unicodeOffset = 0;
|
||||
const unicodeChunks = [];
|
||||
while (true) {
|
||||
const unicodeChunk = await store.getBodyChunk({
|
||||
id: detail.id,
|
||||
length: 4097,
|
||||
offset: unicodeOffset,
|
||||
side: "request"
|
||||
});
|
||||
assert.ok(unicodeChunk);
|
||||
unicodeChunks.push(unicodeChunk.text);
|
||||
if (unicodeChunk.eof) break;
|
||||
assert.ok(unicodeChunk.nextOffset > unicodeOffset);
|
||||
unicodeOffset = unicodeChunk.nextOffset;
|
||||
}
|
||||
assert.equal(unicodeChunks.join(""), body);
|
||||
|
||||
const responseChunk = await store.getBodyChunk({
|
||||
id: detail.id,
|
||||
length: 512 * 1024,
|
||||
offset: 0,
|
||||
side: "response"
|
||||
});
|
||||
assert.ok(responseChunk);
|
||||
assert.equal(responseChunk.eof, true);
|
||||
assert.equal(responseChunk.truncated, false);
|
||||
assert.equal(responseChunk.text, response);
|
||||
} finally {
|
||||
await store?.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore stores decoded Claude App route models for observability", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-claude-app-model-test-"));
|
||||
let store;
|
||||
try {
|
||||
store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const sessionId = "claude-app-hex-session";
|
||||
const routedModel = "Provider/real-model";
|
||||
const encodedModel = `anthropic/claude-ccr-h${Buffer.from(routedModel, "utf8").toString("hex")}`;
|
||||
const startedAt = new Date().toISOString();
|
||||
const responseBodyText = [
|
||||
"event: message_start",
|
||||
`data: ${JSON.stringify({
|
||||
message: {
|
||||
model: encodedModel,
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 5,
|
||||
total_tokens: 8
|
||||
}
|
||||
},
|
||||
type: "message_start"
|
||||
})}`,
|
||||
"",
|
||||
"event: message_stop",
|
||||
"data: {\"type\":\"message_stop\"}",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
await store.record({
|
||||
completedAt: startedAt,
|
||||
durationMs: 42,
|
||||
fallbackModel: routedModel,
|
||||
method: "POST",
|
||||
model: routedModel,
|
||||
path: "/v1/messages",
|
||||
providerName: "Provider",
|
||||
providerProtocol: "anthropic_messages",
|
||||
requestedModel: encodedModel,
|
||||
requestBody: Buffer.from(JSON.stringify({
|
||||
messages: [{ content: "hello", role: "user" }],
|
||||
model: routedModel
|
||||
}), "utf8"),
|
||||
requestHeaders: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "openai-codex test",
|
||||
"x-codex-session-id": sessionId
|
||||
},
|
||||
requestId: "claude-app-hex-request",
|
||||
resolvedModel: routedModel,
|
||||
responseBodyText,
|
||||
responseHeaders: { "content-type": "text/event-stream" },
|
||||
startedAt,
|
||||
statusCode: 200,
|
||||
url: "http://127.0.0.1:3456/v1/messages"
|
||||
});
|
||||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
assert.equal(page.items.length, 1);
|
||||
assert.equal(page.items[0].provider, "Provider");
|
||||
assert.equal(page.items[0].model, "real-model");
|
||||
assert.equal(page.items[0].requestedModel, encodedModel);
|
||||
assert.equal(page.items[0].resolvedModel, routedModel);
|
||||
assert.equal(page.items[0].responseModel, encodedModel);
|
||||
|
||||
const selected = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId
|
||||
});
|
||||
assert.equal(selected.selectedSession?.requests[0]?.model, "real-model");
|
||||
assert.deepEqual(selected.selectedSession?.session.models, ["real-model"]);
|
||||
assert.equal(
|
||||
selected.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.model,
|
||||
"real-model"
|
||||
);
|
||||
} finally {
|
||||
await store?.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore persists actively reported route hops without synthesizing Core diffs", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-route-trace-test-"));
|
||||
let store;
|
||||
|
|
@ -997,6 +1176,7 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async
|
|||
});
|
||||
assert.equal(selected.selectedSession?.trace.toolRunCount, 1);
|
||||
assert.equal(selected.selectedSession?.trace.llmRunCount, 1);
|
||||
assert.equal(selected.selectedSession?.trace.runs.some((run) => run.kind === "route"), false);
|
||||
assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true);
|
||||
assert.equal(
|
||||
selected.selectedSession?.trace.runs.find((run) => run.id === selected.selectedSession?.trace.rootRunId)?.status,
|
||||
|
|
@ -1323,7 +1503,7 @@ test("RequestLogStore streams more body text than the bounded worker heap", {
|
|||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore keeps analysis bounded by the maximum row count", {
|
||||
test("RequestLogStore reports when analysis is bounded by the maximum row count", {
|
||||
skip: isBoundedHeapWorker,
|
||||
timeout: 30000
|
||||
}, async () => {
|
||||
|
|
@ -1332,7 +1512,7 @@ test("RequestLogStore keeps analysis bounded by the maximum row count", {
|
|||
try {
|
||||
const dbFile = path.join(dir, "request-logs.sqlite");
|
||||
store = new RequestLogStore(dbFile);
|
||||
const requestCount = 5000;
|
||||
const requestCount = 5001;
|
||||
const startedAt = new Date().toISOString();
|
||||
await store.list({ pageSize: 1 });
|
||||
const database = createBetterSqliteDatabase(dbFile);
|
||||
|
|
@ -1371,8 +1551,10 @@ test("RequestLogStore keeps analysis bounded by the maximum row count", {
|
|||
}
|
||||
|
||||
const analysis = await store.analyze({ range: "30d" });
|
||||
assert.equal(analysis.scannedRequestCount, requestCount);
|
||||
assert.equal(analysis.totals.requestCount, requestCount);
|
||||
assert.equal(analysis.requestScanLimit, 5000);
|
||||
assert.equal(analysis.requestScanTruncated, true);
|
||||
assert.equal(analysis.scannedRequestCount, 5000);
|
||||
assert.equal(analysis.totals.requestCount, 5000);
|
||||
assert.equal(analysis.sessions[0]?.id, "max-row-session");
|
||||
} finally {
|
||||
await store?.close();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ test("profile service cleans stale generated bin backups only", () => {
|
|||
mkdirSync(binDir, { recursive: true });
|
||||
const deletedFiles = [
|
||||
"ccr-claude-code-api-key-default.ccr-backup-2026-01-01T00-00-00-000Z",
|
||||
"ccr-claude-code-wif-token-default.ccr-backup-2026-01-01T00-00-00-000Z",
|
||||
"ccr-claude-code-wrapper-default.ccr-original",
|
||||
"ccr-codex-cli-stdio-default.ccr-original-missing",
|
||||
"ccr-codex-cli-middleware.js.ccr-backup-2026-01-01T00-00-00-000Z",
|
||||
|
|
@ -114,7 +115,9 @@ test("profile service preserves user statusLine when the active global Claude ta
|
|||
const profile = {
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
env: {},
|
||||
env: {
|
||||
CCR_CLAUDE_CODE_AUTH_MODE: "wif"
|
||||
},
|
||||
id: "default-claude-code",
|
||||
model: "Provider/model",
|
||||
name: "Claude Code",
|
||||
|
|
@ -156,6 +159,7 @@ test("profile service preserves user statusLine when the active global Claude ta
|
|||
command: "ccstatusline",
|
||||
type: "command"
|
||||
});
|
||||
assert.equal(current.apiKeyHelper, undefined);
|
||||
assert.equal(current.currentOnly, "kept");
|
||||
assert.equal(current.theme, undefined);
|
||||
assert.equal(current.env.USER_VALUE, "kept");
|
||||
|
|
@ -370,7 +374,9 @@ test("profile service does not rewrite Claude settings when only user-managed fi
|
|||
const profile = {
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
env: {},
|
||||
env: {
|
||||
CCR_CLAUDE_CODE_AUTH_MODE: "wif"
|
||||
},
|
||||
id: "user-fields-claude-settings",
|
||||
model: "Provider/model",
|
||||
name: "User Fields Claude Settings",
|
||||
|
|
@ -398,6 +404,7 @@ test("profile service does not rewrite Claude settings when only user-managed fi
|
|||
const initialResult = await applyProfileConfig(config);
|
||||
assert.equal(initialResult.clients.some((client) => client.client === "claude-code" && client.ok), true);
|
||||
const settings = JSON.parse(readFileSync(settingsFile, "utf8"));
|
||||
settings.apiKeyHelper = "/usr/local/bin/custom-claude-helper";
|
||||
settings.disableAllHooks = true;
|
||||
settings.statusLine = {
|
||||
command: "ccstatusline",
|
||||
|
|
@ -463,26 +470,46 @@ test("profile service can exclude ZCode from automatic synchronization", async (
|
|||
test("profile service overwrites generated bin files without creating backups", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||
const profileId = "generated-bin-test";
|
||||
const commandExtension = process.platform === "win32" ? ".cmd" : "";
|
||||
const wifTokenExtension = process.platform === "win32" ? ".txt" : "";
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const legacyApiKeyHelperFile = path.join(binDir, `ccr-claude-code-api-key-${profileId}${commandExtension}`);
|
||||
const generatedFiles = [
|
||||
path.join(binDir, `ccr-claude-code-api-key-${profileId}${commandExtension}`),
|
||||
path.join(binDir, `ccr-claude-code-wif-token-${profileId}${wifTokenExtension}`),
|
||||
path.join(binDir, `ccr-claude-code-wrapper-${profileId}${commandExtension}`),
|
||||
path.join(binDir, "ccr-codex-cli-middleware.js"),
|
||||
path.join(binDir, "toolhub-mcp.js")
|
||||
];
|
||||
const staleClaudeCodeGeneratedFiles = [
|
||||
path.join(binDir, `ccr-claude-code-wif-token-stale-claude-code${wifTokenExtension}`),
|
||||
path.join(binDir, `ccr-claude-code-wrapper-stale-claude-code${commandExtension}`)
|
||||
];
|
||||
writeFileSync(legacyApiKeyHelperFile, "old generated content\n");
|
||||
writeFileSync(`${legacyApiKeyHelperFile}.ccr-backup-2026-01-01T00-00-00-000Z`, "old backup\n");
|
||||
writeFileSync(`${legacyApiKeyHelperFile}.ccr-original`, "old original\n");
|
||||
for (const file of generatedFiles) {
|
||||
writeFileSync(file, "old generated content\n");
|
||||
writeFileSync(`${file}.ccr-backup-2026-01-01T00-00-00-000Z`, "old backup\n");
|
||||
writeFileSync(`${file}.ccr-original`, "old original\n");
|
||||
}
|
||||
for (const file of staleClaudeCodeGeneratedFiles) {
|
||||
writeFileSync(file, "stale generated content\n");
|
||||
}
|
||||
|
||||
const config = createDefaultAppConfig();
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: "https://example.test/v1",
|
||||
api_key: "provider-key",
|
||||
models: ["model"],
|
||||
modelMetadata: {
|
||||
model: {
|
||||
contextWindow: 258400
|
||||
},
|
||||
long: {
|
||||
contextWindow: 1_000_000
|
||||
}
|
||||
},
|
||||
models: ["model", "long"],
|
||||
name: "Provider"
|
||||
}
|
||||
];
|
||||
|
|
@ -521,11 +548,16 @@ test("profile service overwrites generated bin files without creating backups",
|
|||
{
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
env: {},
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "profile-api-key",
|
||||
ANTHROPIC_AUTH_TOKEN: "profile-auth-token",
|
||||
CCR_CLAUDE_CODE_AUTH_MODE: "wif",
|
||||
CLAUDE_CODE_USE_GATEWAY: "1"
|
||||
},
|
||||
fableModel: "Provider/fable",
|
||||
haikuModel: "Provider/haiku",
|
||||
id: profileId,
|
||||
model: "Provider/model",
|
||||
model: "Provider/long",
|
||||
name: "Generated Bin Test",
|
||||
opusModel: "Provider/opus",
|
||||
scope: "ccr",
|
||||
|
|
@ -535,13 +567,35 @@ test("profile service overwrites generated bin files without creating backups",
|
|||
surface: "auto"
|
||||
}
|
||||
];
|
||||
const claudeProfileDir = path.join(CONFIGDIR, "profiles", profileId, "claude");
|
||||
mkdirSync(claudeProfileDir, { recursive: true });
|
||||
writeFileSync(path.join(claudeProfileDir, ".claude.json"), `${JSON.stringify({ firstStartTime: "2026-01-01T00:00:00.000Z" }, null, 2)}\n`);
|
||||
|
||||
const result = await applyProfileConfig(config);
|
||||
assert.equal(result.clients.length, 1);
|
||||
assert.equal(result.clients[0].ok, true);
|
||||
assert.equal(existsSync(legacyApiKeyHelperFile), false);
|
||||
for (const file of generatedFiles) {
|
||||
assert.notEqual(readFileSync(file, "utf8"), "old generated content\n");
|
||||
}
|
||||
for (const file of staleClaudeCodeGeneratedFiles) {
|
||||
assert.equal(existsSync(file), false);
|
||||
}
|
||||
const wifTokenFile = path.join(binDir, `ccr-claude-code-wif-token-${profileId}${wifTokenExtension}`);
|
||||
assert.equal(readFileSync(wifTokenFile, "utf8"), "ccr-profile-test\n");
|
||||
const wrapperContent = readFileSync(path.join(binDir, `ccr-claude-code-wrapper-${profileId}${commandExtension}`), "utf8");
|
||||
assert.equal(wrapperContent.includes("profile-api-key"), false);
|
||||
assert.equal(wrapperContent.includes("profile-auth-token"), false);
|
||||
assert.equal(wrapperContent.includes("CCR_REMOTE_SYNC_API_KEY_HELPER"), false);
|
||||
assert.equal(wrapperContent.includes("CCR_REMOTE_SYNC_API_KEY_FILE"), true);
|
||||
assert.equal(wrapperContent.includes("ANTHROPIC_FEDERATION_RULE_ID"), true);
|
||||
assert.equal(wrapperContent.includes("ANTHROPIC_IDENTITY_TOKEN_FILE"), true);
|
||||
assert.equal(wrapperContent.includes("CLAUDE_CODE_USE_GATEWAY=1"), false);
|
||||
if (process.platform === "win32") {
|
||||
assert.match(wrapperContent, /CLAUDE_CODE_USE_GATEWAY="?/);
|
||||
} else {
|
||||
assert.match(wrapperContent, /unset CLAUDE_CODE_USE_GATEWAY/);
|
||||
}
|
||||
const toolHubMcpConfigFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "toolhub-mcp.json");
|
||||
const toolHubMcpConfig = JSON.parse(readFileSync(toolHubMcpConfigFile, "utf8"));
|
||||
const toolHubMcpServerEnv = toolHubMcpConfig.mcpServers["ccr-toolhub"].env;
|
||||
|
|
@ -550,17 +604,35 @@ test("profile service overwrites generated bin files without creating backups",
|
|||
assert.equal(toolHubMcpServerEnv.TOOLHUB_OPENAI_BASE_URL, `http://127.0.0.1:${config.gateway.port}/v1`);
|
||||
assert.equal(toolHubMcpServerEnv.TOOLHUB_OPENAI_MODEL, "Provider/model");
|
||||
assert.equal(contextArchiveMcpServer, undefined);
|
||||
const settingsFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "settings.json");
|
||||
const settingsFile = path.join(claudeProfileDir, "settings.json");
|
||||
const settings = JSON.parse(readFileSync(settingsFile, "utf8"));
|
||||
assert.equal(settings.env.ANTHROPIC_MODEL, "Provider/model");
|
||||
assert.equal(settings.apiKeyHelper, undefined);
|
||||
assert.equal(settings.env.ANTHROPIC_FEDERATION_RULE_ID, "ccr-local");
|
||||
assert.equal(settings.env.ANTHROPIC_ORGANIZATION_ID, "ccr-local");
|
||||
assert.equal(settings.env.ANTHROPIC_IDENTITY_TOKEN_FILE, wifTokenFile);
|
||||
assert.equal(settings.env.ANTHROPIC_MODEL, "Provider/long[1m]");
|
||||
assert.equal(settings.env.CCR_CLAUDE_CODE_MODEL, "Provider/long[1m]");
|
||||
assert.equal(settings.env.CODEXL_CLAUDE_CODE_MODEL, "Provider/long[1m]");
|
||||
assert.equal(settings.env.ANTHROPIC_DEFAULT_FABLE_MODEL, "Provider/fable");
|
||||
assert.equal(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Provider/opus");
|
||||
assert.equal(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Provider/sonnet");
|
||||
assert.equal(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Provider/haiku");
|
||||
assert.equal(settings.env.ANTHROPIC_SMALL_FAST_MODEL, undefined);
|
||||
assert.equal(settings.env.CLAUDE_CODE_USE_GATEWAY, undefined);
|
||||
assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, undefined);
|
||||
assert.equal(settings.env.ANTHROPIC_API_KEY, undefined);
|
||||
assert.equal(settings.modelOverrides, undefined);
|
||||
const claudeGlobalConfig = JSON.parse(readFileSync(path.join(claudeProfileDir, ".claude.json"), "utf8"));
|
||||
assert.equal(claudeGlobalConfig.firstStartTime, "2026-01-01T00:00:00.000Z");
|
||||
assert.equal(claudeGlobalConfig.autoCompactWindowsCache["Provider/model"], 258400);
|
||||
assert.equal(claudeGlobalConfig.autoCompactWindowsCache["provider/model"], 258400);
|
||||
assert.equal(claudeGlobalConfig.autoCompactWindowsCache["Provider/long"], 1_000_000);
|
||||
assert.equal(claudeGlobalConfig.autoCompactWindowsCache["Provider/long[1m]"], 1_000_000);
|
||||
assert.equal(claudeGlobalConfig.modelOverrides, undefined);
|
||||
const backupEntries = readdirSync(binDir).filter((entry) =>
|
||||
(
|
||||
entry.startsWith(`ccr-claude-code-api-key-${profileId}`) ||
|
||||
entry.startsWith(`ccr-claude-code-wif-token-${profileId}`) ||
|
||||
entry.startsWith(`ccr-claude-code-wrapper-${profileId}`) ||
|
||||
entry.startsWith("ccr-codex-cli-middleware.js") ||
|
||||
entry.startsWith("toolhub-mcp.js")
|
||||
|
|
@ -569,6 +641,72 @@ test("profile service overwrites generated bin files without creating backups",
|
|||
assert.deepEqual(backupEntries, []);
|
||||
});
|
||||
|
||||
test("profile service falls back to apiKeyHelper when Claude Code WIF support is not detected", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-auth-mode-"));
|
||||
const profileId = "old-claude-code";
|
||||
const commandExtension = process.platform === "win32" ? ".cmd" : "";
|
||||
const wifTokenExtension = process.platform === "win32" ? ".txt" : "";
|
||||
try {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
const fakeClaude = path.join(root, process.platform === "win32" ? "claude.cmd" : "claude");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(fakeClaude, process.platform === "win32"
|
||||
? "@echo off\r\necho 2.1.100\r\n"
|
||||
: "#!/bin/sh\nprintf '%s\\n' '2.1.100'\n");
|
||||
chmodSync(fakeClaude, 0o700);
|
||||
|
||||
const config = createDefaultAppConfig();
|
||||
config.APIKEY = "ccr-old-auto-token";
|
||||
config.APIKEYS = [{
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: `profile:${profileId}`,
|
||||
key: "ccr-old-auto-token",
|
||||
name: "Profile: Old Claude Code"
|
||||
}];
|
||||
config.Providers = [{
|
||||
api_base_url: "https://example.test/v1",
|
||||
api_key: "provider-key",
|
||||
models: ["model"],
|
||||
name: "Provider"
|
||||
}];
|
||||
config.profile.profiles = [{
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
env: {
|
||||
CCR_CLAUDE_CODE_BIN: fakeClaude
|
||||
},
|
||||
id: profileId,
|
||||
model: "Provider/model",
|
||||
name: "Old Claude Code",
|
||||
scope: "ccr",
|
||||
settingsFile: "~/.claude/settings.json",
|
||||
smallFastModel: "",
|
||||
surface: "auto"
|
||||
}];
|
||||
|
||||
const result = await applyProfileConfig(config);
|
||||
assert.equal(result.clients.length, 1);
|
||||
assert.equal(result.clients[0].ok, true);
|
||||
|
||||
const helperFile = path.join(binDir, `ccr-claude-code-api-key-${profileId}${commandExtension}`);
|
||||
const tokenFile = path.join(binDir, `ccr-claude-code-wif-token-${profileId}${wifTokenExtension}`);
|
||||
const settingsFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "settings.json");
|
||||
const settings = JSON.parse(readFileSync(settingsFile, "utf8"));
|
||||
assert.equal(settings.apiKeyHelper, process.platform === "win32" ? `"${helperFile}"` : helperFile);
|
||||
assert.equal(settings.env.ANTHROPIC_IDENTITY_TOKEN_FILE, undefined);
|
||||
assert.equal(settings.env.ANTHROPIC_FEDERATION_RULE_ID, undefined);
|
||||
assert.equal(settings.env.CCR_CLAUDE_CODE_AUTH_MODE, undefined);
|
||||
assert.equal(readFileSync(tokenFile, "utf8"), "ccr-old-auto-token\n");
|
||||
assert.match(readFileSync(helperFile, "utf8"), /ccr-old-auto-token/);
|
||||
|
||||
const wrapperContent = readFileSync(path.join(binDir, `ccr-claude-code-wrapper-${profileId}${commandExtension}`), "utf8");
|
||||
assert.equal(wrapperContent.includes("ANTHROPIC_IDENTITY_TOKEN_FILE"), false);
|
||||
assert.equal(wrapperContent.includes("CCR_REMOTE_SYNC_API_KEY_FILE"), true);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex profile launcher bypasses middleware for Browser and Computer Use helpers", { skip: process.platform === "win32" || !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-browser-helper-bypass-"));
|
||||
const profileId = "browser-helper-bypass-test";
|
||||
|
|
|
|||
|
|
@ -289,6 +289,42 @@ test("UsageStore keeps the Fusion logical model while grouping by the upstream m
|
|||
}
|
||||
});
|
||||
|
||||
test("UsageStore attributes Claude App encoded response model IDs to the routed upstream model", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-claude-app-encoded-model-test-"));
|
||||
try {
|
||||
const store = new UsageStore(path.join(dir, "usage.sqlite"));
|
||||
const encodedModel = `anthropic/claude-ccr-h${Buffer.from("Fusion/kimisearch", "utf8").toString("hex")}`;
|
||||
|
||||
await store.recordCapture({
|
||||
bodyText: [
|
||||
"event: message_start",
|
||||
`data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"${encodedModel}","usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
""
|
||||
].join("\n"),
|
||||
client: "Claude Code",
|
||||
config: fusionUsageConfig,
|
||||
durationMs: 100,
|
||||
fallbackModel: "Fusion/kimisearch",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
providerProtocol: "anthropic_messages",
|
||||
requestId: "encoded-claude-app-model",
|
||||
responseHeaders: new Headers({ "content-type": "text/event-stream; charset=utf-8" }),
|
||||
statusCode: 200
|
||||
});
|
||||
|
||||
const stats = await store.getStats("today", { includeProxy: true });
|
||||
assert.equal(stats.models[0]?.model, "kimi-for-coding");
|
||||
assert.equal(stats.models[0]?.provider, "Kimi Code - Coding Plan");
|
||||
assert.equal(stats.recentRequests[0]?.logicalModel, "Fusion/kimisearch");
|
||||
assert.notEqual(stats.models[0]?.model, encodedModel);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("lightweight usage synchronization records and deduplicates Fusion internal upstream calls", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-fusion-internal-test-"));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import {
|
|||
resolveClaudeAppGatewayRouteModel
|
||||
} from "@ccr/core/agents/claude-app/gateway-routes.ts";
|
||||
import {
|
||||
createClaudeCliBootstrapResponse,
|
||||
createGatewayModelsResponse,
|
||||
prepareClaudeAppDiscoveredModelRequest,
|
||||
shouldServeClaudeCliBootstrapResponse,
|
||||
shouldServeGatewayModelsResponse
|
||||
} from "@ccr/core/gateway/features/model-discovery.ts";
|
||||
import { ModelRegistry } from "@ccr/core/routing/model-registry.ts";
|
||||
|
|
@ -63,6 +65,12 @@ test("gateway model discovery supports the /models alias", () => {
|
|||
assert.equal(shouldServeGatewayModelsResponse("POST", "/models"), false);
|
||||
});
|
||||
|
||||
test("Claude CLI bootstrap is exposed at the first-party bootstrap path", () => {
|
||||
assert.equal(shouldServeClaudeCliBootstrapResponse("GET", "/api/claude_cli/bootstrap"), true);
|
||||
assert.equal(shouldServeClaudeCliBootstrapResponse("GET", "/api/claude_cli/bootstrap/"), true);
|
||||
assert.equal(shouldServeClaudeCliBootstrapResponse("POST", "/api/claude_cli/bootstrap"), false);
|
||||
});
|
||||
|
||||
test("issue 1535 Claude App discovery defaults to the first configured provider model", () => {
|
||||
const config = createConfig({
|
||||
providers: [
|
||||
|
|
@ -267,6 +275,19 @@ test("Claude Code exposes a configured 1M context window as a visible model vari
|
|||
assert.equal(discoveredModel.max_input_tokens, 1_000_000);
|
||||
assert.equal(discoveredModel.capabilities.context_window.one_million_context_variant, true);
|
||||
|
||||
const bootstrap = createClaudeCliBootstrapResponse(config);
|
||||
assert.equal(bootstrap.auto_compact_windows[discoveredModel.id], 1_000_000);
|
||||
assert.equal(bootstrap.client_data.rowan_thicket[discoveredModel.id], 1_000_000);
|
||||
assert.equal(bootstrap.additional_model_options[0].description, "1M context window");
|
||||
assert.equal(bootstrap.additional_model_options[0].display_name, "Zhipu AI (China) - Coding Plan/aaa (1M context)");
|
||||
assert.equal(bootstrap.additional_model_options[0].id, discoveredModel.id);
|
||||
assert.equal(bootstrap.additional_model_options[0].max_input_tokens, 1_000_000);
|
||||
assert.equal(bootstrap.additional_model_options[0].model, discoveredModel.id);
|
||||
assert.equal(bootstrap.additional_model_options[0].name, "Zhipu AI (China) - Coding Plan/aaa (1M context)");
|
||||
assert.equal(bootstrap.additional_model_options[0].type, "model");
|
||||
assert.equal(bootstrap.additional_model_options[0].capabilities.context_window.max_input_tokens, 1_000_000);
|
||||
assert.equal(bootstrap.additional_model_options[0].capabilities.context_window.one_million_context_variant, true);
|
||||
|
||||
const rewrite = prepareClaudeAppDiscoveredModelRequest(
|
||||
config,
|
||||
"POST",
|
||||
|
|
@ -276,6 +297,38 @@ test("Claude Code exposes a configured 1M context window as a visible model vari
|
|||
assert.equal(rewrite?.routedModel, "Zhipu AI (China) - Coding Plan/aaa");
|
||||
});
|
||||
|
||||
test("Claude CLI bootstrap returns catalog-derived model configuration from models.json", () => {
|
||||
const config = createConfig({
|
||||
providers: [
|
||||
{
|
||||
models: ["glm-5.2"],
|
||||
name: "Zhipu AI (China) - Coding Plan",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
]
|
||||
});
|
||||
const route = buildClaudeAppGatewayModelRoutes(config)[0];
|
||||
const bootstrap = createClaudeCliBootstrapResponse(config);
|
||||
const option = bootstrap.additional_model_options[0];
|
||||
|
||||
assert.ok(route);
|
||||
assert.equal(option.id, `${route.id}[1m]`);
|
||||
assert.equal(option.model, `${route.id}[1m]`);
|
||||
assert.equal(option.display_name, "Zhipu AI (China) - Coding Plan/GLM-5.2 (1M context)");
|
||||
assert.equal(option.max_input_tokens, 1_049_000);
|
||||
assert.equal(option.max_tokens, 1_048_560);
|
||||
assert.equal(option.capabilities.context_window.max_input_tokens, 1_049_000);
|
||||
assert.equal(option.capabilities.context_management.max_input_tokens, 1_049_000);
|
||||
assert.equal(option.capabilities.context_window.supports_1m_context, true);
|
||||
assert.equal(option.capabilities.context_window.one_million_context_variant, true);
|
||||
assert.equal(option.capabilities.image_input.supported, false);
|
||||
assert.equal(option.capabilities.structured_outputs.supported, true);
|
||||
assert.equal(option.capabilities.tool_use.supported, true);
|
||||
assert.equal(option.capabilities.thinking.supported, true);
|
||||
assert.equal(bootstrap.auto_compact_windows[option.model], 1_000_000);
|
||||
assert.equal(bootstrap.client_data.rowan_thicket[option.model], 1_000_000);
|
||||
});
|
||||
|
||||
test("issue 1632 Claude discovery inherits Fusion fixed model provider context", () => {
|
||||
const config = createConfig({
|
||||
providers: [
|
||||
|
|
@ -356,6 +409,72 @@ test("Claude App discovery publishes the effective provider context for uncatalo
|
|||
assert.equal(model.max_input_tokens, 258400);
|
||||
assert.equal(model.capabilities.context_management.max_input_tokens, 258400);
|
||||
assert.equal(model.capabilities.context_window.max_input_tokens, 258400);
|
||||
|
||||
const bootstrap = createClaudeCliBootstrapResponse(config);
|
||||
assert.equal(bootstrap.auto_compact_windows[model.id], 258400);
|
||||
assert.equal(bootstrap.client_data.rowan_thicket[model.id], 258400);
|
||||
});
|
||||
|
||||
test("Claude CLI bootstrap includes suffixed compact window keys for uncatalogued 1M models", () => {
|
||||
const config = createConfig({
|
||||
providers: [
|
||||
{
|
||||
modelMetadata: {
|
||||
long: {
|
||||
contextWindow: 1_000_000
|
||||
}
|
||||
},
|
||||
models: ["long"],
|
||||
name: "Provider",
|
||||
type: "openai_responses"
|
||||
}
|
||||
]
|
||||
});
|
||||
const bootstrap = createClaudeCliBootstrapResponse(config);
|
||||
|
||||
assert.equal(bootstrap.auto_compact_windows["Provider/long"], 1_000_000);
|
||||
assert.equal(bootstrap.auto_compact_windows["provider/long"], 1_000_000);
|
||||
assert.equal(bootstrap.auto_compact_windows["Provider/long[1m]"], 1_000_000);
|
||||
assert.equal(bootstrap.auto_compact_windows["provider/long[1m]"], 1_000_000);
|
||||
assert.equal(bootstrap.client_data.rowan_thicket["Provider/long[1m]"], 1_000_000);
|
||||
});
|
||||
|
||||
test("Claude CLI bootstrap clamps compact windows to Claude Code's accepted range", () => {
|
||||
const lowConfig = createConfig({
|
||||
providers: [
|
||||
{
|
||||
modelMetadata: {
|
||||
"custom-model": {
|
||||
contextWindow: 64000
|
||||
}
|
||||
},
|
||||
models: ["custom-model"],
|
||||
name: "Custom",
|
||||
type: "openai_responses"
|
||||
}
|
||||
]
|
||||
});
|
||||
const lowModel = createClaudeCodeModelsResponse(lowConfig).data[0];
|
||||
const lowBootstrap = createClaudeCliBootstrapResponse(lowConfig);
|
||||
assert.equal(lowBootstrap.auto_compact_windows[lowModel.id], 100000);
|
||||
|
||||
const highConfig = createConfig({
|
||||
providers: [
|
||||
{
|
||||
modelMetadata: {
|
||||
huge: {
|
||||
contextWindow: 1_500_000
|
||||
}
|
||||
},
|
||||
models: ["huge"],
|
||||
name: "Huge",
|
||||
type: "openai_responses"
|
||||
}
|
||||
]
|
||||
});
|
||||
const highModel = createClaudeCodeModelsResponse(highConfig).data[0];
|
||||
const highBootstrap = createClaudeCliBootstrapResponse(highConfig);
|
||||
assert.equal(highBootstrap.auto_compact_windows[highModel.id], 1_000_000);
|
||||
});
|
||||
|
||||
test("Claude App discovery honors configured reasoning levels for uncatalogued models", () => {
|
||||
|
|
@ -395,6 +514,45 @@ test("Claude App discovery honors configured reasoning levels for uncatalogued m
|
|||
assert.equal(model.capabilities.effort.ultra.supported, true);
|
||||
});
|
||||
|
||||
test("Claude CLI bootstrap honors configured provider metadata over defaults", () => {
|
||||
const config = createConfig({
|
||||
providers: [
|
||||
{
|
||||
modelMetadata: {
|
||||
"custom-model": {
|
||||
capabilities: { imageInput: false },
|
||||
contextWindow: 500000,
|
||||
maxOutputTokens: 12345,
|
||||
supportedReasoningLevels: [
|
||||
{ description: "Low", effort: "low" },
|
||||
{ description: "High", effort: "high" }
|
||||
]
|
||||
}
|
||||
},
|
||||
models: ["custom-model"],
|
||||
name: "Custom",
|
||||
type: "openai_responses"
|
||||
}
|
||||
]
|
||||
});
|
||||
const response = createClaudeModelsResponse(config);
|
||||
const bootstrap = createClaudeCliBootstrapResponse(config);
|
||||
const option = bootstrap.additional_model_options[0];
|
||||
|
||||
assert.equal(response.data[0].max_input_tokens, 500000);
|
||||
assert.equal(response.data[0].max_tokens, 12345);
|
||||
assert.equal(option.max_input_tokens, 500000);
|
||||
assert.equal(option.max_tokens, 12345);
|
||||
assert.equal(option.capabilities.context_window.max_input_tokens, 500000);
|
||||
assert.equal(option.capabilities.context_management.max_input_tokens, 500000);
|
||||
assert.equal(option.capabilities.image_input.supported, false);
|
||||
assert.equal(option.capabilities.thinking.supported, true);
|
||||
assert.equal(option.capabilities.effort.low.supported, true);
|
||||
assert.equal(option.capabilities.effort.medium.supported, false);
|
||||
assert.equal(option.capabilities.effort.high.supported, true);
|
||||
assert.equal(option.capabilities.effort.ultra.supported, false);
|
||||
});
|
||||
|
||||
test("Claude App discovery prefers provider context metadata over the static catalog", () => {
|
||||
const config = createConfig({
|
||||
providers: [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
codexDesktopAppName,
|
||||
codexSharedChatGptAuthEnvForTest,
|
||||
findInstalledCodexAppExecutable,
|
||||
findInstalledWorkbuddyAppExecutable,
|
||||
removeLegacyCodexVirtualAuthMarker,
|
||||
writeCodexCompatibleAppModelCatalog
|
||||
} from "@ccr/core/agents/codex/app-launch.ts";
|
||||
|
|
@ -288,6 +289,177 @@ test("ChatGPT profile appPath overrides process env discovery", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("WorkBuddy AI app path override discovers the Electron executable", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-"));
|
||||
try {
|
||||
let configuredPath;
|
||||
let expectedExecutable;
|
||||
if (process.platform === "darwin") {
|
||||
configuredPath = path.join(root, "WorkBuddy AI.app");
|
||||
const macosDir = path.join(configuredPath, "Contents", "MacOS");
|
||||
mkdirSync(macosDir, { recursive: true });
|
||||
expectedExecutable = path.join(macosDir, "Electron");
|
||||
writeFileSync(expectedExecutable, "");
|
||||
writeFileSync(
|
||||
path.join(configuredPath, "Contents", "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Electron</string></dict></plist>"
|
||||
);
|
||||
} else {
|
||||
expectedExecutable = path.join(root, process.platform === "win32" ? "WorkBuddyAI.exe" : "workbuddy-ai");
|
||||
configuredPath = expectedExecutable;
|
||||
writeFileSync(expectedExecutable, "");
|
||||
}
|
||||
|
||||
const result = findInstalledWorkbuddyAppExecutable(configuredPath);
|
||||
assert.equal(result.executable, expectedExecutable);
|
||||
assert.equal(result.checked[0], configuredPath);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("WorkBuddy AI app profile writes the virtual desktop auth session", () => {
|
||||
const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-auth-"));
|
||||
try {
|
||||
const profile = {
|
||||
agent: "workbuddy",
|
||||
enabled: true,
|
||||
id: "workbuddy-main",
|
||||
model: "Codex API/gpt-5-codex",
|
||||
name: "WorkBuddy Main",
|
||||
providerId: "claude-code-router",
|
||||
scope: "ccr",
|
||||
surface: "app"
|
||||
};
|
||||
|
||||
const workbuddyConfig = {
|
||||
gateway: {
|
||||
enabled: true,
|
||||
host: "0.0.0.0",
|
||||
mode: "process",
|
||||
port: 48765
|
||||
},
|
||||
Providers: [{
|
||||
modelMetadata: {
|
||||
"gpt-5-codex": {
|
||||
contextWindow: 272_000,
|
||||
defaultReasoningLevel: "medium",
|
||||
supportedReasoningLevels: [
|
||||
{ description: "Medium", effort: "medium" },
|
||||
{ description: "High", effort: "high" }
|
||||
],
|
||||
supportsReasoningSummaries: true
|
||||
}
|
||||
},
|
||||
models: ["gpt-5-codex"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}]
|
||||
};
|
||||
const result = writeCodexCompatibleAppModelCatalog(configDir, profile, workbuddyConfig);
|
||||
|
||||
assert.equal(path.basename(result.file), "ccr-workbuddy-model-catalog.json");
|
||||
assert.ok(result.workbuddyModelsConfig);
|
||||
assert.equal(path.basename(result.workbuddyModelsConfig.file), "models.json");
|
||||
assert.equal(result.workbuddyModelsConfig.model, "Codex API/gpt-5-codex");
|
||||
const modelsConfig = JSON.parse(readFileSync(result.workbuddyModelsConfig.file, "utf8"));
|
||||
assert.deepEqual(modelsConfig.availableModels, ["Codex API/gpt-5-codex"]);
|
||||
assert.equal(modelsConfig.models.length, 1);
|
||||
assert.equal(modelsConfig.models[0].id, "Codex API/gpt-5-codex");
|
||||
assert.equal(modelsConfig.models[0].vendor, "Codex API");
|
||||
assert.equal(modelsConfig.models[0].url, "http://127.0.0.1:48765/v1");
|
||||
assert.equal(modelsConfig.models[0].apiKey, "${CCR_PROFILE_API_KEY}");
|
||||
assert.equal(modelsConfig.models[0].supportsToolCall, true);
|
||||
assert.equal(modelsConfig.models[0].supportsReasoning, true);
|
||||
assert.equal(modelsConfig.models[0].maxInputTokens, 272_000);
|
||||
assert.deepEqual(modelsConfig.models[0].reasoning.supportedEfforts, ["medium", "high"]);
|
||||
|
||||
assert.ok(result.workbuddyVirtualAuth);
|
||||
assert.equal(path.basename(result.workbuddyVirtualAuth.authFile), "workbuddy-desktop-ai.info");
|
||||
assert.ok(result.workbuddyVirtualAuth.authFile.includes(path.join("CodeBuddyExtension", "Data", "Public", "auth")));
|
||||
assert.equal(existsSync(result.workbuddyVirtualAuth.authFile), true);
|
||||
|
||||
const session = JSON.parse(readFileSync(result.workbuddyVirtualAuth.authFile, "utf8"));
|
||||
assert.equal(session.auth.accessToken, "ccr-local-profile");
|
||||
assert.equal(session.auth.domain, "www.workbuddy.ai");
|
||||
assert.equal(session.auth.refreshToken, "");
|
||||
assert.ok(Date.now() - session.auth.lastRefreshTime < 5_000);
|
||||
assert.equal(session.account.uid, "ccr-local-profile");
|
||||
assert.equal(session.account.nickname, "WorkBuddy Main");
|
||||
assert.equal(session.account.type, "personal");
|
||||
assert.deepEqual(session.accounts, [session.account]);
|
||||
assert.deepEqual(session.allAccounts, [session.account]);
|
||||
|
||||
const second = writeCodexCompatibleAppModelCatalog(configDir, profile, workbuddyConfig);
|
||||
assert.equal(second.workbuddyModelsConfig.changed, false);
|
||||
} finally {
|
||||
rmSync(configDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("WorkBuddy AI app profile writes every allowed model to models.json", () => {
|
||||
const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-models-"));
|
||||
try {
|
||||
const profile = {
|
||||
agent: "workbuddy",
|
||||
availableModels: ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"],
|
||||
enabled: true,
|
||||
id: "workbuddy-main",
|
||||
model: "Codex API/gpt-5-codex",
|
||||
name: "WorkBuddy Main",
|
||||
providerId: "claude-code-router",
|
||||
scope: "ccr",
|
||||
surface: "app"
|
||||
};
|
||||
|
||||
const result = writeCodexCompatibleAppModelCatalog(configDir, profile, {
|
||||
Providers: [{
|
||||
models: ["gpt-5-codex", "gpt-5.1-codex", "gpt-4.1"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}]
|
||||
});
|
||||
|
||||
const modelsConfig = JSON.parse(readFileSync(result.workbuddyModelsConfig.file, "utf8"));
|
||||
assert.deepEqual(modelsConfig.availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]);
|
||||
assert.deepEqual(modelsConfig.models.map((item) => item.id), ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]);
|
||||
assert.deepEqual(modelsConfig.models.map((item) => item.name), ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]);
|
||||
assert.deepEqual(modelsConfig.models.map((item) => item.isDefault), [true, false]);
|
||||
} finally {
|
||||
rmSync(configDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("WorkBuddy AI app profile writes every catalog model when the allowlist is unrestricted", () => {
|
||||
const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-unrestricted-models-"));
|
||||
try {
|
||||
const profile = {
|
||||
agent: "workbuddy",
|
||||
enabled: true,
|
||||
id: "workbuddy-main",
|
||||
model: "Codex API/gpt-5-codex",
|
||||
name: "WorkBuddy Main",
|
||||
providerId: "claude-code-router",
|
||||
scope: "ccr",
|
||||
surface: "app"
|
||||
};
|
||||
|
||||
const result = writeCodexCompatibleAppModelCatalog(configDir, profile, {
|
||||
Providers: [{
|
||||
models: ["gpt-5-codex", "gpt-5.1-codex", "gpt-4.1"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}]
|
||||
});
|
||||
|
||||
const modelsConfig = JSON.parse(readFileSync(result.workbuddyModelsConfig.file, "utf8"));
|
||||
assert.deepEqual(modelsConfig.availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex", "Codex API/gpt-4.1"]);
|
||||
assert.deepEqual(modelsConfig.models.map((item) => item.isDefault), [true, false, false]);
|
||||
} finally {
|
||||
rmSync(configDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function withPlatform(platform, callback) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,19 @@ test("codex catalog removes duplicate model IDs case-insensitively without reord
|
|||
assert.deepEqual(ids, ["provider/model-a", "Provider/MODEL-B"]);
|
||||
});
|
||||
|
||||
test("codex catalog filters model IDs with a profile allowlist", () => {
|
||||
const ids = buildCodexModelCatalogIds({
|
||||
Providers: [
|
||||
{ name: "Provider", type: "openai_responses", models: ["alpha", "beta"] },
|
||||
{ name: "Other", type: "openai_responses", models: ["gamma"] }
|
||||
]
|
||||
}, "Provider/alpha", {
|
||||
allowedModels: ["Provider/alpha"]
|
||||
});
|
||||
|
||||
assert.deepEqual(ids, ["Provider/alpha"]);
|
||||
});
|
||||
|
||||
test("codex catalog treats unknown models as text-only while enabling apply_patch", () => {
|
||||
const model = catalogModelFor({
|
||||
Providers: [
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ test("compact stores an immutable full request and appends one handoff task", as
|
|||
const config = testConfig();
|
||||
const body = {
|
||||
context_management: { edits: [{ reason: "compact", type: "compact_20260112" }] },
|
||||
max_completion_tokens: 512,
|
||||
max_tokens: 777,
|
||||
messages: [
|
||||
{ content: "Historical decision: use SQLite.", role: "user" },
|
||||
|
|
@ -267,7 +268,8 @@ test("compact stores an immutable full request and appends one handoff task", as
|
|||
assert.deepEqual(forwarded.messages.slice(0, body.messages.length), body.messages);
|
||||
assert.equal(forwarded.messages.length, body.messages.length + 1);
|
||||
assert.equal(forwarded.model, body.model);
|
||||
assert.equal(forwarded.max_tokens, 2048);
|
||||
assert.equal(forwarded.max_completion_tokens, undefined);
|
||||
assert.equal(forwarded.max_tokens, undefined);
|
||||
assert.equal(forwarded.context_management, undefined);
|
||||
assert.equal(forwarded.response_format, undefined);
|
||||
assert.equal(forwarded.tools, undefined);
|
||||
|
|
@ -659,7 +661,9 @@ test("compact handoff strips protocol-specific tool and schema constraints", asy
|
|||
const result = await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
input: [{ content: [{ text: "Responses fact", type: "input_text" }], role: "user", type: "message" }],
|
||||
maxCompletionTokens: 256,
|
||||
max_output_tokens: 128,
|
||||
maxTokens: 1024,
|
||||
metadata: { ccr_context_compact: true, keep: "yes" },
|
||||
model: "gpt-test",
|
||||
parallel_tool_calls: true,
|
||||
|
|
@ -683,7 +687,9 @@ test("compact handoff strips protocol-specific tool and schema constraints", asy
|
|||
assert.equal(forwarded.metadata.ccr_context_compact, undefined);
|
||||
assert.equal(forwarded.metadata.keep, "yes");
|
||||
assert.deepEqual(forwarded.text.format, { type: "text" });
|
||||
assert.equal(forwarded.max_output_tokens, 2048);
|
||||
assert.equal(forwarded.maxCompletionTokens, undefined);
|
||||
assert.equal(forwarded.max_output_tokens, undefined);
|
||||
assert.equal(forwarded.maxTokens, undefined);
|
||||
assert.equal(forwarded.input.length, 2);
|
||||
assert.match(forwarded.input.at(-1).content[0].text, /CCR compact handoff task/);
|
||||
});
|
||||
|
|
@ -970,7 +976,9 @@ test("only explicit or structural compact signals create archives", async () =>
|
|||
const auto = await prepareContextArchiveRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
context_management: { edits: [{ keep: "all", type: "clear_thinking_20251015" }] },
|
||||
maxCompletionTokens: 12000,
|
||||
max_tokens: 32000,
|
||||
maxTokens: 16000,
|
||||
messages: [
|
||||
{ content: "Earlier user request.", role: "user" },
|
||||
{ content: "Earlier assistant answer.", role: "assistant" },
|
||||
|
|
@ -990,9 +998,22 @@ test("only explicit or structural compact signals create archives", async () =>
|
|||
assert.match(auto.diagnostic, /^compact-handoff:claude-auto:/);
|
||||
const forwarded = JSON.parse(auto.body.toString("utf8"));
|
||||
assert.equal(forwarded.tools, undefined);
|
||||
assert.equal(forwarded.maxCompletionTokens, undefined);
|
||||
assert.equal(forwarded.max_tokens, undefined);
|
||||
assert.equal(forwarded.maxTokens, undefined);
|
||||
assert.deepEqual(forwarded.context_management, { edits: [{ keep: "all", type: "clear_thinking_20251015" }] });
|
||||
assert.match(forwarded.messages.at(-1).content, /CCR compact handoff task/);
|
||||
assert.match(forwarded.messages.at(-1).content, /mcp__ccr-context-archive__ccr_history_ask/);
|
||||
assert.equal(forwarded.messages.length, 3);
|
||||
assert.deepEqual(forwarded.messages.slice(0, -1), [
|
||||
{ content: "Earlier user request.", role: "user" },
|
||||
{ content: "Earlier assistant answer.", role: "assistant" }
|
||||
]);
|
||||
const handoffText = forwarded.messages.at(-1).content[0].text;
|
||||
assert.match(handoffText, /CCR compact handoff task/);
|
||||
assert.match(handoffText, /<analysis> block followed by a <summary> block/);
|
||||
assert.match(handoffText, /descriptive, not authoritative/);
|
||||
assert.match(handoffText, /public runtime API shape/);
|
||||
assert.match(handoffText, /mcp__ccr-context-archive__ccr_history_ask/);
|
||||
assert.doesNotMatch(handoffText, /Your task is to create a detailed summary of the conversation so far/);
|
||||
});
|
||||
|
||||
test("compact refuses unresolved tool-call boundaries instead of trimming them", async () => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,31 @@ test("top-level provider protocol becomes a capability when none are configured"
|
|||
]);
|
||||
});
|
||||
|
||||
test("provider auto model refresh flag is parsed from camel and snake case config", async () => {
|
||||
const { parseProvidersForTest } = await import("@ccr/core/config/config.ts");
|
||||
const providers = parseProvidersForTest([
|
||||
{
|
||||
autoFetchKnownModels: ["model-a", "model-hidden"],
|
||||
autoFetchModels: true,
|
||||
baseUrl: "https://api.example.test/v1",
|
||||
models: ["model-a"],
|
||||
name: "Camel"
|
||||
},
|
||||
{
|
||||
auto_fetch_known_models: "model-b,model-hidden",
|
||||
auto_fetch_models: true,
|
||||
baseUrl: "https://api.example.test/v1",
|
||||
models: ["model-b"],
|
||||
name: "Snake"
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(providers[0].autoFetchModels, true);
|
||||
assert.deepEqual(providers[0].autoFetchKnownModels, ["model-a", "model-hidden"]);
|
||||
assert.equal(providers[1].autoFetchModels, true);
|
||||
assert.deepEqual(providers[1].autoFetchKnownModels, ["model-b", "model-hidden"]);
|
||||
});
|
||||
|
||||
test("explicit capabilities win over the top-level protocol", async () => {
|
||||
const { parseProvidersForTest } = await import("@ccr/core/config/config.ts");
|
||||
const providers = parseProvidersForTest([
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
|||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { authorize } from "@ccr/core/gateway/auth/api-key-authorizer.ts";
|
||||
import { authorize, exchangeClaudeCodeWifToken } from "@ccr/core/gateway/auth/api-key-authorizer.ts";
|
||||
|
||||
const authorizerSourceFile = path.join(
|
||||
process.cwd(),
|
||||
|
|
@ -105,6 +105,40 @@ test("gateway authorization separates a missing token from an expired key", asyn
|
|||
assert.equal(expired.response.payload.error.message, "API key is expired.");
|
||||
});
|
||||
|
||||
test("Claude Code WIF token exchange returns a bearer token for a configured profile key", async () => {
|
||||
const config = configWithApiKeys([
|
||||
{ createdAt: new Date(0).toISOString(), id: "profile:claude", key: gatewayApiKey }
|
||||
]);
|
||||
|
||||
const result = await exchangeClaudeCodeWifToken(config, Buffer.from(JSON.stringify({
|
||||
assertion: gatewayApiKey,
|
||||
federation_rule_id: "ccr-local",
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
organization_id: "ccr-local"
|
||||
})));
|
||||
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.deepEqual(result.payload, {
|
||||
access_token: gatewayApiKey,
|
||||
expires_in: 3600,
|
||||
token_type: "Bearer"
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude Code WIF token exchange rejects invalid assertions", async () => {
|
||||
const config = configWithApiKeys([
|
||||
{ createdAt: new Date(0).toISOString(), id: "profile:claude", key: gatewayApiKey }
|
||||
]);
|
||||
|
||||
const result = await exchangeClaudeCodeWifToken(config, Buffer.from(new URLSearchParams({
|
||||
assertion: `${gatewayApiKey}-wrong`,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||
}).toString()));
|
||||
|
||||
assert.equal(result.statusCode, 401);
|
||||
assert.equal(result.payload.error, "invalid_grant");
|
||||
});
|
||||
|
||||
// A constant-time comparison is behaviour-preserving by construction, so the
|
||||
// tests above pass on both sides of the change and only prove there is no
|
||||
// regression. The invariant itself is asserted on the module source, the same
|
||||
|
|
|
|||
|
|
@ -11,8 +11,16 @@ import {
|
|||
replacePersistedRuntimeState
|
||||
} from "@ccr/core/config/config-repository.ts";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor.ts";
|
||||
import { gatewayService } from "@ccr/core/gateway/service.ts";
|
||||
|
||||
test("gateway endpoints normalize wildcard and IPv6 hosts", () => {
|
||||
assert.equal(endpoint("0.0.0.0", 3456), "http://127.0.0.1:3456");
|
||||
assert.equal(endpoint("::", 3456), "http://[::1]:3456");
|
||||
assert.equal(endpoint("::1", 3456), "http://[::1]:3456");
|
||||
assert.equal(endpoint("[::1]", 3456), "http://[::1]:3456");
|
||||
});
|
||||
|
||||
test("gateway start persists preflight validation failures for status polling", async () => {
|
||||
await gatewayService.stop();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Readable } from "node:stream";
|
|||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { rawTraceSyncHeader } from "@ccr/core/gateway/internal/shared.ts";
|
||||
import { rawTraceHardMaxBodyBytes } from "@ccr/core/observability/request-log-limits.ts";
|
||||
import { rawTraceHardMaxBodyBytes, rawTraceMaxPartBytes } from "@ccr/core/observability/request-log-limits.ts";
|
||||
import {
|
||||
applyRawTraceRequestLogPolicy,
|
||||
buildRawTraceConfig,
|
||||
|
|
@ -117,15 +117,16 @@ test("raw trace defers body persistence when the upstream status is unknown", ()
|
|||
assert.equal(policy.update.requestBodyText, "private request body");
|
||||
});
|
||||
|
||||
test("raw trace source defaults to the 50 MB hard body ceiling", () => {
|
||||
test("raw trace source keeps raw body parts unbounded for sidecar storage", () => {
|
||||
const config = createConfig();
|
||||
const previous = process.env.CCR_RAW_TRACE_ENABLED;
|
||||
process.env.CCR_RAW_TRACE_ENABLED = "1";
|
||||
try {
|
||||
const rawTrace = buildRawTraceConfig(config, "sync-token");
|
||||
assert.equal(rawTrace.maxPartBytes, rawTraceHardMaxBodyBytes);
|
||||
assert.equal(rawTrace.maxPartBytes, rawTraceMaxPartBytes);
|
||||
assert.ok(rawTrace.maxPartBytes > rawTraceHardMaxBodyBytes);
|
||||
config.observability.requestLogMaxBodyBytes = Number.MAX_SAFE_INTEGER;
|
||||
assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceHardMaxBodyBytes);
|
||||
assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceMaxPartBytes);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CCR_RAW_TRACE_ENABLED;
|
||||
else process.env.CCR_RAW_TRACE_ENABLED = previous;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { CLAUDE_CODE_AUTH_MODE_ENV } from "@ccr/core/agents/claude-code/auth-mode.ts";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { ensureProfileGateway } from "@ccr/core/profiles/launch-service.ts";
|
||||
|
||||
function claudeProfileConfig() {
|
||||
function claudeProfileConfig(authMode = "api-key-helper") {
|
||||
const profile = {
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
env: {},
|
||||
env: {
|
||||
[CLAUDE_CODE_AUTH_MODE_ENV]: authMode
|
||||
},
|
||||
id: "claude-main",
|
||||
model: "Provider/model",
|
||||
name: "Claude Main",
|
||||
|
|
@ -99,6 +102,47 @@ test("existing profile gateway falls back to the root identity response", async
|
|||
}
|
||||
});
|
||||
|
||||
test("existing profile gateway rejects WIF profiles when the root identity lacks the token endpoint", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const paths = [];
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = new URL(String(input));
|
||||
paths.push(url.pathname);
|
||||
if (url.pathname === "/health") {
|
||||
return Response.json({
|
||||
core: "http://127.0.0.1:3467",
|
||||
status: "running",
|
||||
timestamp: "2026-01-01T00:00:00.000Z"
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/v1/models") {
|
||||
return Response.json({ data: [], object: "list" });
|
||||
}
|
||||
if (url.pathname === "/") {
|
||||
return Response.json({
|
||||
endpoints: ["GET /v1/models"],
|
||||
name: "claude-code-router"
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected gateway probe: ${url.pathname}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const { config, profile } = claudeProfileConfig("wif");
|
||||
await assert.rejects(
|
||||
ensureProfileGateway(config, profile, "Local subscription router", {
|
||||
reuseExisting: true,
|
||||
startIfMissing: false
|
||||
}),
|
||||
/does not advertise the Claude Code WIF token endpoint/
|
||||
);
|
||||
|
||||
assert.deepEqual(paths, ["/health", "/v1/models", "/"]);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("existing profile gateway retries a transient transport failure", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const paths = [];
|
||||
|
|
|
|||
|
|
@ -94,6 +94,17 @@ const kiloProfile = {
|
|||
surface: "cli"
|
||||
};
|
||||
|
||||
const workbuddyProfile = {
|
||||
agent: "workbuddy",
|
||||
enabled: true,
|
||||
id: "workbuddy-main",
|
||||
model: "provider,model",
|
||||
name: "Workbuddy Main",
|
||||
providerId: "claude-code-router",
|
||||
scope: "ccr",
|
||||
surface: "app"
|
||||
};
|
||||
|
||||
const claudeDesignProfile = {
|
||||
agent: "claude-design",
|
||||
enabled: true,
|
||||
|
|
@ -130,6 +141,7 @@ test("profile open surfaces enforce agent capabilities", () => {
|
|||
assert.deepEqual(profileOpenSurfaces(kimiProfile), ["cli"]);
|
||||
assert.deepEqual(profileOpenSurfaces(piProfile), ["cli"]);
|
||||
assert.deepEqual(profileOpenSurfaces(kiloProfile), ["cli"]);
|
||||
assert.deepEqual(profileOpenSurfaces(workbuddyProfile), ["app"]);
|
||||
assert.deepEqual(profileOpenSurfaces(openCodeProfile), ["cli", "app"]);
|
||||
assert.deepEqual(profileOpenSurfaces(claudeDesignProfile), ["app"]);
|
||||
assert.equal(resolveProfileOpenSurface(codexProfile, "app"), "app");
|
||||
|
|
@ -138,6 +150,7 @@ test("profile open surfaces enforce agent capabilities", () => {
|
|||
assert.throws(() => resolveProfileOpenSurface(kimiProfile, "app"), /does not support APP/);
|
||||
assert.throws(() => resolveProfileOpenSurface(piProfile, "app"), /does not support APP/);
|
||||
assert.throws(() => resolveProfileOpenSurface(kiloProfile, "app"), /does not support APP/);
|
||||
assert.throws(() => resolveProfileOpenSurface(workbuddyProfile, "cli"), /does not support CLI/);
|
||||
assert.throws(() => resolveProfileOpenSurface(claudeDesignProfile, "cli"), /does not support CLI/);
|
||||
});
|
||||
|
||||
|
|
@ -145,6 +158,7 @@ test("default profile command surface is CLI unless the agent is app-only", () =
|
|||
assert.equal(defaultProfileOpenSurface(claudeProfile), "cli");
|
||||
assert.equal(defaultProfileOpenSurface(codexProfile), "cli");
|
||||
assert.equal(defaultProfileOpenSurface({ ...codexProfile, surface: "app" }), "cli");
|
||||
assert.equal(defaultProfileOpenSurface(workbuddyProfile), "app");
|
||||
assert.equal(defaultProfileOpenSurface({ ...codexProfile, agent: "zcode" }), "app");
|
||||
assert.equal(defaultProfileOpenSurface(claudeDesignProfile), "app");
|
||||
});
|
||||
|
|
@ -153,6 +167,7 @@ test("Grok and Kimi CLI start a temporary CCR gateway when none is already runni
|
|||
assert.equal(shouldAutoStartProfileGateway(grokProfile, "cli"), true);
|
||||
assert.equal(shouldAutoStartProfileGateway(kimiProfile, "cli"), true);
|
||||
assert.equal(shouldAutoStartProfileGateway(piProfile, "cli"), true);
|
||||
assert.equal(shouldAutoStartProfileGateway(workbuddyProfile, "app"), false);
|
||||
assert.equal(shouldAutoStartProfileGateway(kiloProfile, "cli"), false);
|
||||
assert.equal(shouldAutoStartProfileGateway(codexProfile, "cli"), false);
|
||||
assert.equal(shouldAutoStartProfileGateway(claudeProfile, "app"), false);
|
||||
|
|
@ -168,6 +183,7 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => {
|
|||
const piPlan = buildProfileLaunchPlan(configDir, piProfile, "cli", ["--debug"]);
|
||||
const openCodePlan = buildProfileLaunchPlan(configDir, openCodeProfile, "cli", ["--debug"]);
|
||||
const kiloPlan = buildProfileLaunchPlan(configDir, kiloProfile, "cli", ["--debug"]);
|
||||
const workbuddyPlan = buildProfileLaunchPlan(configDir, workbuddyProfile, "app");
|
||||
|
||||
assert.equal(codexPlan.surface, "app");
|
||||
assert.deepEqual(codexPlan.args, ["app"]);
|
||||
|
|
@ -218,6 +234,12 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => {
|
|||
assert.match(kiloPlan.env.KILO_CONFIG, /kilo[\\/]kilo\.jsonc$/);
|
||||
assert.throws(() => buildProfileLaunchPlan(configDir, kiloProfile, "app"), /does not support APP/);
|
||||
|
||||
assert.equal(workbuddyPlan.surface, "app");
|
||||
assert.deepEqual(workbuddyPlan.args, ["app"]);
|
||||
assert.equal(path.basename(workbuddyPlan.command), process.platform === "win32" ? "ccr-codex-cli-stdio-workbuddy-main.cmd" : "ccr-codex-cli-stdio-workbuddy-main");
|
||||
assert.equal(workbuddyPlan.env.CCR_PROFILE_SURFACE, "app");
|
||||
assert.throws(() => buildProfileLaunchPlan(configDir, workbuddyProfile, "cli"), /does not support CLI/);
|
||||
|
||||
assert.throws(() => buildProfileLaunchPlan(configDir, claudeProfile, "app"), /Claude App opening/);
|
||||
assert.throws(() => buildProfileLaunchPlan(configDir, claudeDesignProfile, "app"), /Claude Design profiles can only be opened from CCR Desktop/);
|
||||
});
|
||||
|
|
@ -239,6 +261,10 @@ test("profile config paths honor CCR, custom, and global scopes", () => {
|
|||
resolveCodexConfigFile(configDir, customProfile),
|
||||
path.join(configDir, "profiles", "custom-profile", "custom", "codex", "config.toml")
|
||||
);
|
||||
assert.equal(
|
||||
resolveCodexConfigFile(configDir, workbuddyProfile),
|
||||
path.join(configDir, "profiles", "workbuddy-main", "workbuddy", "config.toml")
|
||||
);
|
||||
assert.equal(resolveCodexConfigFile(configDir, globalCodex), path.join(process.env.HOME, "codex-home", "config.toml"));
|
||||
assert.equal(
|
||||
resolveOpenCodeConfigFile(configDir, openCodeProfile),
|
||||
|
|
@ -253,9 +279,11 @@ test("profile config paths honor CCR, custom, and global scopes", () => {
|
|||
test("profileOpenCommand quotes profile references for shell usage", () => {
|
||||
const cliCommand = profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main");
|
||||
const appCommand = profileOpenCommand(codexProfile, "app", "ccr", "Codex Main");
|
||||
const workbuddyCommand = profileOpenCommand(workbuddyProfile, undefined, "ccr", "Workbuddy Main");
|
||||
|
||||
assert.match(cliCommand, /Claude/);
|
||||
assert.match(cliCommand, /Main/);
|
||||
assert.equal(cliCommand.endsWith(" cli"), false);
|
||||
assert.match(appCommand, / app$/);
|
||||
assert.match(workbuddyCommand, / app$/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildClaudeAppGatewayModelRoutes } from "@ccr/core/agents/claude-app/gateway-routes.ts";
|
||||
import { createClaudeCliBootstrapResponse, createGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery.ts";
|
||||
import {
|
||||
filterModelIdsForProfile,
|
||||
isModelAllowedForProfile,
|
||||
profileAllowedModels,
|
||||
profileForApiKey
|
||||
} from "@ccr/core/profiles/model-allowlist.ts";
|
||||
import {
|
||||
profileAllowedRouteFallbackForTest,
|
||||
profileDeniedModelForTest
|
||||
} from "@ccr/core/gateway/request/pipeline.ts";
|
||||
import { profileApiKeyId } from "@ccr/core/profiles/api-key.ts";
|
||||
|
||||
function testConfig(profileOverrides = {}) {
|
||||
const profile = {
|
||||
agent: "codex",
|
||||
enabled: true,
|
||||
id: "work",
|
||||
model: "Provider/alpha",
|
||||
name: "Work",
|
||||
scope: "ccr",
|
||||
...profileOverrides
|
||||
};
|
||||
return {
|
||||
Providers: [
|
||||
{ models: ["alpha", "beta"], name: "Provider", type: "openai_responses" },
|
||||
{ models: ["gamma"], name: "Other", type: "openai_responses" }
|
||||
],
|
||||
profile: {
|
||||
enabled: true,
|
||||
profiles: [profile]
|
||||
},
|
||||
Router: {
|
||||
fallback: { mode: "off", models: [], retryCount: 0 },
|
||||
rules: []
|
||||
},
|
||||
virtualModelProfiles: []
|
||||
};
|
||||
}
|
||||
|
||||
test("profile model allowlist resolves profile API keys and filters model IDs", () => {
|
||||
const config = testConfig({ availableModels: ["Provider/alpha"] });
|
||||
const profile = config.profile.profiles[0];
|
||||
const apiKey = {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(profile),
|
||||
key: "profile-key",
|
||||
name: "Profile: Work"
|
||||
};
|
||||
|
||||
assert.equal(profileForApiKey(config, apiKey), profile);
|
||||
assert.deepEqual(profileAllowedModels(profile), ["Provider/alpha"]);
|
||||
assert.deepEqual(
|
||||
filterModelIdsForProfile(config, ["Provider/alpha", "Provider/beta", "Other/gamma"], profile),
|
||||
["Provider/alpha"]
|
||||
);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "Provider/alpha"), true);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), false);
|
||||
});
|
||||
|
||||
test("profile model allowlist keeps existing all-model behavior when unset", () => {
|
||||
const config = testConfig();
|
||||
const profile = config.profile.profiles[0];
|
||||
|
||||
assert.equal(profileAllowedModels(profile), undefined);
|
||||
assert.deepEqual(
|
||||
filterModelIdsForProfile(config, ["Provider/alpha", "Provider/beta", "Other/gamma"], profile),
|
||||
["Provider/alpha", "Provider/beta", "Other/gamma"]
|
||||
);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), true);
|
||||
});
|
||||
|
||||
test("profile model allowlist does not add implicit defaults to explicit lists", () => {
|
||||
const config = testConfig({ availableModels: ["Provider/beta"], model: "" });
|
||||
const profile = config.profile.profiles[0];
|
||||
|
||||
assert.deepEqual(profileAllowedModels(profile), ["Provider/beta"]);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "Provider/alpha"), false);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), true);
|
||||
});
|
||||
|
||||
test("profile model allowlist does not treat configured claude-prefixed models as discovery aliases", () => {
|
||||
const config = testConfig({
|
||||
availableModels: ["Provider/sonnet"],
|
||||
model: "Provider/sonnet"
|
||||
});
|
||||
config.Providers = [{
|
||||
models: ["sonnet", "claude-sonnet"],
|
||||
name: "Provider",
|
||||
type: "openai_responses"
|
||||
}];
|
||||
const profile = config.profile.profiles[0];
|
||||
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "claude-sonnet"), false);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "Provider/claude-sonnet"), false);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "claude-Provider/sonnet"), true);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, "Provider/sonnet[1m]"), true);
|
||||
});
|
||||
|
||||
test("profile model allowlist resolves Claude App encoded route IDs", () => {
|
||||
const config = testConfig({ availableModels: ["Provider/alpha"] });
|
||||
const profile = config.profile.profiles[0];
|
||||
const apiKey = {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(profile),
|
||||
key: "profile-key",
|
||||
name: "Claude App"
|
||||
};
|
||||
const response = createGatewayModelsResponse(config, {}, apiKey);
|
||||
const routeId = response.data[0]?.id;
|
||||
const betaRouteId = buildClaudeAppGatewayModelRoutes(config).find((route) =>
|
||||
route.targetModel === "Provider/beta"
|
||||
)?.id;
|
||||
|
||||
assert.ok(routeId);
|
||||
assert.notEqual(routeId, "Provider/alpha");
|
||||
assert.ok(betaRouteId);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, routeId), true);
|
||||
assert.equal(isModelAllowedForProfile(config, profile, betaRouteId), false);
|
||||
});
|
||||
|
||||
test("profile model access is decided by the final routed model", () => {
|
||||
const config = testConfig({ availableModels: ["Provider/alpha"] });
|
||||
const profile = config.profile.profiles[0];
|
||||
|
||||
assert.equal(
|
||||
profileDeniedModelForTest(config, profile, "Provider/alpha", "Provider/beta"),
|
||||
"Provider/beta"
|
||||
);
|
||||
assert.equal(
|
||||
profileDeniedModelForTest(config, profile, "Provider/beta", "Provider/alpha"),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("profile model allowlist filters model-chain fallback attempts", () => {
|
||||
const config = testConfig({ availableModels: ["Provider/alpha"] });
|
||||
const profile = config.profile.profiles[0];
|
||||
|
||||
assert.deepEqual(
|
||||
profileAllowedRouteFallbackForTest(config, profile, {
|
||||
mode: "model-chain",
|
||||
models: ["Provider/beta", "Provider/alpha", "Other/gamma"],
|
||||
retryCount: 0
|
||||
}),
|
||||
{
|
||||
mode: "model-chain",
|
||||
models: ["Provider/alpha"],
|
||||
retryCount: 0
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("gateway model discovery uses the authenticated profile allowlist", () => {
|
||||
const config = testConfig({ availableModels: ["Provider/alpha"] });
|
||||
const profile = config.profile.profiles[0];
|
||||
const apiKey = {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(profile),
|
||||
key: "profile-key",
|
||||
name: "Profile: Work"
|
||||
};
|
||||
|
||||
const response = createGatewayModelsResponse(config, {}, apiKey);
|
||||
assert.deepEqual(response.data.map((model) => model.id), ["Provider/alpha"]);
|
||||
|
||||
const unrestricted = createGatewayModelsResponse(config, {}, {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: "general",
|
||||
key: "general-key",
|
||||
name: "General"
|
||||
});
|
||||
assert.deepEqual(unrestricted.data.map((model) => model.id), ["Provider/alpha", "Provider/beta", "Other/gamma"]);
|
||||
});
|
||||
|
||||
test("Claude CLI bootstrap options are filtered by profile allowlist", () => {
|
||||
const config = testConfig({
|
||||
agent: "claude-code",
|
||||
availableModels: ["Provider/alpha"],
|
||||
scope: "global"
|
||||
});
|
||||
const profile = config.profile.profiles[0];
|
||||
const apiKey = {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
id: profileApiKeyId(profile),
|
||||
key: "profile-key",
|
||||
name: "Profile: Work"
|
||||
};
|
||||
|
||||
const response = createClaudeCliBootstrapResponse(config, apiKey);
|
||||
assert.equal(response.additional_model_options.length, 1);
|
||||
});
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { codexDefaultBaseUrl } from "@ccr/core/agents/local-providers/codex.ts";
|
||||
import {
|
||||
hasAutoFetchModelProviders,
|
||||
refreshAutoFetchProviderModels,
|
||||
runProviderModelAutoRefreshNow
|
||||
} from "@ccr/core/providers/model-auto-refresh.ts";
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function testConfig(overrides = {}) {
|
||||
return {
|
||||
APIKEY: "",
|
||||
APIKEYS: [],
|
||||
API_TIMEOUT_MS: 600000,
|
||||
CUSTOM_ROUTER_PATH: "",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: 3456,
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://api.provider.test/v1",
|
||||
api_key: "sk-provider",
|
||||
autoFetchModels: true,
|
||||
autoFetchKnownModels: ["alpha"],
|
||||
id: "provider",
|
||||
models: ["alpha"],
|
||||
name: "Provider",
|
||||
type: "openai_chat_completions"
|
||||
},
|
||||
{
|
||||
api_base_url: "https://api.other.test/v1",
|
||||
autoFetchModels: false,
|
||||
id: "other",
|
||||
models: ["omega"],
|
||||
name: "Other",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
],
|
||||
Router: {
|
||||
fallback: { mode: "off", models: [], retryCount: 0 },
|
||||
rules: []
|
||||
},
|
||||
profile: {
|
||||
enabled: true,
|
||||
profiles: [
|
||||
{
|
||||
agent: "codex",
|
||||
availableModels: ["Provider/alpha"],
|
||||
enabled: true,
|
||||
id: "work",
|
||||
model: "Provider/alpha",
|
||||
name: "Work",
|
||||
scope: "ccr",
|
||||
surface: "cli"
|
||||
},
|
||||
{
|
||||
agent: "codex",
|
||||
availableModels: ["Other/omega"],
|
||||
enabled: true,
|
||||
id: "other",
|
||||
model: "Other/omega",
|
||||
name: "Other Work",
|
||||
scope: "ccr",
|
||||
surface: "cli"
|
||||
},
|
||||
{
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
id: "all",
|
||||
model: "Provider/alpha",
|
||||
name: "All Models",
|
||||
scope: "ccr",
|
||||
surface: "cli"
|
||||
}
|
||||
]
|
||||
},
|
||||
providerPlugins: [],
|
||||
virtualModelProfiles: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("auto model refresh adds new provider models and extends matching profile allowlists", async () => {
|
||||
const config = testConfig();
|
||||
const calls = [];
|
||||
|
||||
const result = await refreshAutoFetchProviderModels(config, {
|
||||
probeProvider: async (request) => {
|
||||
calls.push(request);
|
||||
return {
|
||||
capabilities: [],
|
||||
modelDisplayNames: {
|
||||
beta: "Beta"
|
||||
},
|
||||
models: ["alpha", "beta"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].apiKey, "sk-provider");
|
||||
assert.equal(calls[0].mode, "models");
|
||||
assert.equal(calls[0].forceRefresh, true);
|
||||
assert.deepEqual(result.config.Providers[0].models, ["alpha", "beta"]);
|
||||
assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["alpha", "beta"]);
|
||||
assert.deepEqual(result.config.Providers[0].modelDisplayNames, { beta: "Beta" });
|
||||
assert.deepEqual(result.config.Providers[1].models, ["omega"]);
|
||||
assert.deepEqual(result.config.profile.profiles[0].availableModels, ["Provider/alpha", "Provider/beta"]);
|
||||
assert.deepEqual(result.config.profile.profiles[1].availableModels, ["Other/omega"]);
|
||||
assert.equal(result.config.profile.profiles[2].availableModels, undefined);
|
||||
assert.equal(result.profileAllowlistsUpdated, 1);
|
||||
assert.deepEqual(result.providers[0].addedModels, ["beta"]);
|
||||
});
|
||||
|
||||
test("auto model refresh initializes a known model baseline before adding remote-only models", async () => {
|
||||
const config = testConfig({
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://api.provider.test/v1",
|
||||
api_key: "sk-provider",
|
||||
autoFetchModels: true,
|
||||
id: "provider",
|
||||
models: ["alpha"],
|
||||
name: "Provider",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const result = await refreshAutoFetchProviderModels(config, {
|
||||
probeProvider: async (request) => ({
|
||||
capabilities: [],
|
||||
models: ["alpha", "beta"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.config.Providers[0].models, ["alpha"]);
|
||||
assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["alpha", "beta"]);
|
||||
assert.equal(result.profileAllowlistsUpdated, 0);
|
||||
assert.deepEqual(result.providers[0].addedModels, []);
|
||||
});
|
||||
|
||||
test("auto model refresh does not re-add known models removed by the user", async () => {
|
||||
const config = testConfig({
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://api.provider.test/v1",
|
||||
api_key: "sk-provider",
|
||||
autoFetchModels: true,
|
||||
autoFetchKnownModels: ["alpha", "beta"],
|
||||
id: "provider",
|
||||
models: ["alpha"],
|
||||
name: "Provider",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const result = await refreshAutoFetchProviderModels(config, {
|
||||
probeProvider: async (request) => ({
|
||||
capabilities: [],
|
||||
models: ["alpha", "beta", "gamma"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.config.Providers[0].models, ["alpha", "gamma"]);
|
||||
assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["alpha", "beta", "gamma"]);
|
||||
assert.deepEqual(result.config.profile.profiles[0].availableModels, ["Provider/alpha", "Provider/gamma"]);
|
||||
assert.deepEqual(result.providers[0].addedModels, ["gamma"]);
|
||||
});
|
||||
|
||||
test("auto model refresh hot-applies saved agent profiles after the switch is enabled", async () => {
|
||||
const config = testConfig();
|
||||
const savedConfigs = [];
|
||||
const hotAppliedConfigs = [];
|
||||
|
||||
const result = await runProviderModelAutoRefreshNow({
|
||||
loadConfig: async () => config,
|
||||
onConfigChanged: async (nextConfig, refreshResult) => {
|
||||
hotAppliedConfigs.push({ config: nextConfig, result: refreshResult });
|
||||
},
|
||||
probeProvider: async (request) => ({
|
||||
capabilities: [],
|
||||
models: ["alpha", "beta"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
}),
|
||||
saveConfig: async (nextConfig) => {
|
||||
savedConfigs.push(nextConfig);
|
||||
return nextConfig;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(savedConfigs.length, 1);
|
||||
assert.equal(hotAppliedConfigs.length, 1);
|
||||
assert.deepEqual(savedConfigs[0].Providers[0].models, ["alpha", "beta"]);
|
||||
assert.deepEqual(savedConfigs[0].profile.profiles[0].availableModels, ["Provider/alpha", "Provider/beta"]);
|
||||
assert.equal(hotAppliedConfigs[0].config, savedConfigs[0]);
|
||||
assert.equal(hotAppliedConfigs[0].result.config, savedConfigs[0]);
|
||||
assert.equal(hotAppliedConfigs[0].result.profileAllowlistsUpdated, 1);
|
||||
});
|
||||
|
||||
test("auto model refresh merges fetched models into the latest config before saving", async () => {
|
||||
const baseConfig = testConfig();
|
||||
const latestConfig = clone(baseConfig);
|
||||
latestConfig.Router.rules = [{
|
||||
id: "user-added-rule",
|
||||
target: "Other/omega",
|
||||
type: "model"
|
||||
}];
|
||||
latestConfig.Providers[0].models = ["alpha", "manual"];
|
||||
latestConfig.Providers[0].autoFetchKnownModels = ["alpha"];
|
||||
const savedConfigs = [];
|
||||
const loadedConfigs = [baseConfig, latestConfig];
|
||||
|
||||
const result = await runProviderModelAutoRefreshNow({
|
||||
loadConfig: async () => loadedConfigs.shift() ?? latestConfig,
|
||||
probeProvider: async (request) => ({
|
||||
capabilities: [],
|
||||
models: ["alpha", "beta"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
}),
|
||||
saveConfig: async (nextConfig) => {
|
||||
savedConfigs.push(nextConfig);
|
||||
return nextConfig;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(savedConfigs.length, 1);
|
||||
assert.deepEqual(savedConfigs[0].Router.rules, latestConfig.Router.rules);
|
||||
assert.deepEqual(savedConfigs[0].Providers[0].models, ["alpha", "manual", "beta"]);
|
||||
assert.deepEqual(savedConfigs[0].Providers[0].autoFetchKnownModels, ["alpha", "manual", "beta"]);
|
||||
assert.deepEqual(savedConfigs[0].profile.profiles[0].availableModels, ["Provider/alpha", "Provider/beta"]);
|
||||
assert.equal(result.profileAllowlistsUpdated, 1);
|
||||
});
|
||||
|
||||
test("auto model refresh does not save stale results after the switch is disabled", async () => {
|
||||
const baseConfig = testConfig();
|
||||
const latestConfig = clone(baseConfig);
|
||||
latestConfig.Providers[0].autoFetchModels = false;
|
||||
const savedConfigs = [];
|
||||
const loadedConfigs = [baseConfig, latestConfig];
|
||||
|
||||
const result = await runProviderModelAutoRefreshNow({
|
||||
loadConfig: async () => loadedConfigs.shift() ?? latestConfig,
|
||||
probeProvider: async (request) => ({
|
||||
capabilities: [],
|
||||
models: ["alpha", "beta"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
}),
|
||||
saveConfig: async (nextConfig) => {
|
||||
savedConfigs.push(nextConfig);
|
||||
return nextConfig;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(savedConfigs.length, 0);
|
||||
assert.equal(result.config, latestConfig);
|
||||
assert.deepEqual(result.config.Providers[0].models, ["alpha"]);
|
||||
assert.equal(result.config.Providers[0].autoFetchModels, false);
|
||||
});
|
||||
|
||||
test("auto model refresh uses Codex local model catalog during hot apply", async (t) => {
|
||||
const previousCcrHome = process.env.CCR_INTERNAL_HOME_DIR;
|
||||
const previousHome = process.env.HOME;
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-local-catalog-"));
|
||||
mkdirSync(path.join(home, ".codex"), { recursive: true });
|
||||
writeFileSync(path.join(home, ".codex", "models_cache.json"), JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
context_window: 272000,
|
||||
display_name: "GPT-5.1 Codex",
|
||||
max_context_window: 300000,
|
||||
slug: "gpt-5.1-codex",
|
||||
supported_reasoning_levels: [{ description: "High", effort: "high" }],
|
||||
supports_reasoning_summaries: true
|
||||
}
|
||||
]
|
||||
}), "utf8");
|
||||
process.env.CCR_INTERNAL_HOME_DIR = home;
|
||||
process.env.HOME = home;
|
||||
t.after(() => {
|
||||
if (previousCcrHome === undefined) {
|
||||
delete process.env.CCR_INTERNAL_HOME_DIR;
|
||||
} else {
|
||||
process.env.CCR_INTERNAL_HOME_DIR = previousCcrHome;
|
||||
}
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
const config = testConfig({
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: codexDefaultBaseUrl,
|
||||
api_key: "ccr-local-agent-login",
|
||||
autoFetchModels: true,
|
||||
autoFetchKnownModels: ["gpt-5-codex"],
|
||||
id: "codex-api",
|
||||
models: ["gpt-5-codex"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}
|
||||
],
|
||||
profile: {
|
||||
enabled: true,
|
||||
profiles: [
|
||||
{
|
||||
agent: "codex",
|
||||
availableModels: ["Codex API/gpt-5-codex"],
|
||||
enabled: true,
|
||||
id: "codex-work",
|
||||
model: "Codex API/gpt-5-codex",
|
||||
name: "Codex Work",
|
||||
scope: "ccr",
|
||||
surface: "cli"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const hotAppliedConfigs = [];
|
||||
|
||||
const result = await runProviderModelAutoRefreshNow({
|
||||
loadConfig: async () => config,
|
||||
onConfigChanged: async (nextConfig) => {
|
||||
hotAppliedConfigs.push(nextConfig);
|
||||
},
|
||||
probeProvider: async (request) => ({
|
||||
capabilities: [],
|
||||
models: ["gpt-5-codex"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
}),
|
||||
saveConfig: async (nextConfig) => nextConfig
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.config.Providers[0].models, ["gpt-5-codex", "gpt-5.1-codex"]);
|
||||
assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["gpt-5-codex", "gpt-5.1-codex"]);
|
||||
assert.equal(result.config.Providers[0].modelDisplayNames["gpt-5.1-codex"], "GPT-5.1 Codex");
|
||||
assert.equal(result.config.Providers[0].modelMetadata["gpt-5.1-codex"].supportsReasoningSummaries, true);
|
||||
assert.deepEqual(result.config.profile.profiles[0].availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]);
|
||||
assert.equal(hotAppliedConfigs.length, 1);
|
||||
assert.deepEqual(hotAppliedConfigs[0].profile.profiles[0].availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]);
|
||||
assert.deepEqual(result.providers[0].addedModels, ["gpt-5.1-codex"]);
|
||||
});
|
||||
|
||||
test("auto model refresh is unchanged when fetched models are already configured", async () => {
|
||||
const config = testConfig();
|
||||
|
||||
const result = await refreshAutoFetchProviderModels(config, {
|
||||
probeProvider: async (request) => ({
|
||||
capabilities: [],
|
||||
models: ["alpha"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.config, config);
|
||||
assert.equal(result.profileAllowlistsUpdated, 0);
|
||||
});
|
||||
|
||||
test("auto model refresh uses an enabled credential when the provider has no primary key", async () => {
|
||||
const config = testConfig({
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://api.provider.test/v1",
|
||||
autoFetchModels: true,
|
||||
credentials: [
|
||||
{ api_key: "sk-disabled", enabled: false },
|
||||
{ api_key: "sk-enabled", enabled: true }
|
||||
],
|
||||
id: "provider",
|
||||
models: ["alpha"],
|
||||
name: "Provider",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let apiKey = "";
|
||||
await refreshAutoFetchProviderModels(config, {
|
||||
probeProvider: async (request) => {
|
||||
apiKey = request.apiKey;
|
||||
return {
|
||||
capabilities: [],
|
||||
models: ["alpha", "beta"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(apiKey, "sk-enabled");
|
||||
});
|
||||
|
||||
test("auto model refresh uses enabled local provider plugins including protocol-scoped aliases", async () => {
|
||||
const config = testConfig({
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://api.provider.test/v1",
|
||||
api_key: "ccr-local-agent-login",
|
||||
autoFetchModels: true,
|
||||
autoFetchKnownModels: ["alpha"],
|
||||
id: "provider",
|
||||
models: ["alpha"],
|
||||
name: "Provider",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
],
|
||||
providerPlugins: [
|
||||
{
|
||||
enabled: false,
|
||||
key: "ccr-local-agent-provider-disabled",
|
||||
providerName: "Provider"
|
||||
},
|
||||
{
|
||||
key: "ccr-local-agent-provider-internal",
|
||||
providerName: "provider::openai_chat_completions"
|
||||
},
|
||||
{
|
||||
key: "external-provider-plugin",
|
||||
providerName: "Provider"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let pluginKeys = [];
|
||||
await refreshAutoFetchProviderModels(config, {
|
||||
probeProvider: async (request) => {
|
||||
pluginKeys = request.providerPlugins.map((plugin) => plugin.key);
|
||||
return {
|
||||
capabilities: [],
|
||||
models: ["alpha", "beta"],
|
||||
normalizedBaseUrl: request.baseUrl,
|
||||
protocols: []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(pluginKeys, ["ccr-local-agent-provider-internal"]);
|
||||
});
|
||||
|
||||
test("auto model refresh enabled check ignores disabled providers", () => {
|
||||
assert.equal(hasAutoFetchModelProviders(testConfig()), true);
|
||||
assert.equal(hasAutoFetchModelProviders({
|
||||
Providers: [{ autoFetchModels: true, enabled: false, models: [], name: "Disabled" }]
|
||||
}), false);
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import { createServer } from "node:http";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { ROUTER_SCRIPT_MAX_SOURCE_BYTES } from "@ccr/core/contracts/app.ts";
|
||||
import { gatewayService } from "@ccr/core/gateway/application/gateway-service.ts";
|
||||
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin.ts";
|
||||
import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context.ts";
|
||||
|
|
@ -249,13 +250,13 @@ test("route script validation rejects invalid metadata, paths, content, and limi
|
|||
const cases = [
|
||||
[{ ...valid, apiVersion: 2 }, /unsupported route script api or language/i],
|
||||
[{ ...valid, language: "typescript" }, /unsupported route script api or language/i],
|
||||
[{ ...valid, file: undefined, source: " " }, /between 1 and 65536 bytes/i],
|
||||
[{ ...valid, file: undefined, source: " " }, new RegExp(`between 1 and ${ROUTER_SCRIPT_MAX_SOURCE_BYTES} bytes`, "i")],
|
||||
[{ ...valid, file: `${valid.file}.txt` }, /\.js, \.mjs, or \.cjs extension/i],
|
||||
[{ ...valid, timeoutMs: 9 }, /between 10 and 30000 ms/i],
|
||||
[{ ...valid, timeoutMs: 30001 }, /between 10 and 30000 ms/i],
|
||||
[{ ...valid, file: path.join(routeScriptDirectory, "missing.js") }, /unable to read route script file/i],
|
||||
[{ ...valid, file: directoryFile }, /is not a file/i],
|
||||
[routeScript("x".repeat(64 * 1024 + 1)), /exceeds 65536 bytes/i]
|
||||
[routeScript("x".repeat(ROUTER_SCRIPT_MAX_SOURCE_BYTES + 1)), new RegExp(`exceeds ${ROUTER_SCRIPT_MAX_SOURCE_BYTES} bytes`, "i")]
|
||||
];
|
||||
try {
|
||||
for (const [script, expectedMessage] of cases) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,30 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { normalizeExternalHttpTarget, startWebManagementServer } from "@ccr/core/web/management-server.ts";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { after } from "node:test";
|
||||
|
||||
test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", () => {
|
||||
const previousRuntimeEnv = {
|
||||
appData: process.env.CCR_INTERNAL_APP_DATA_DIR,
|
||||
home: process.env.CCR_INTERNAL_HOME_DIR,
|
||||
userData: process.env.CCR_INTERNAL_USER_DATA_DIR
|
||||
};
|
||||
const runtimeRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-web-management-test-"));
|
||||
process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(runtimeRoot, "app-data");
|
||||
process.env.CCR_INTERNAL_HOME_DIR = path.join(runtimeRoot, "home");
|
||||
process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(runtimeRoot, "user-data");
|
||||
|
||||
after(() => {
|
||||
setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previousRuntimeEnv.appData);
|
||||
setOptionalEnv("CCR_INTERNAL_HOME_DIR", previousRuntimeEnv.home);
|
||||
setOptionalEnv("CCR_INTERNAL_USER_DATA_DIR", previousRuntimeEnv.userData);
|
||||
rmSync(runtimeRoot, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", async () => {
|
||||
const { normalizeExternalHttpTarget } = await import("@ccr/core/web/management-server.ts");
|
||||
assert.equal(normalizeExternalHttpTarget(""), undefined);
|
||||
assert.equal(normalizeExternalHttpTarget(undefined), undefined);
|
||||
assert.equal(normalizeExternalHttpTarget("about:blank"), undefined);
|
||||
|
|
@ -14,6 +36,7 @@ test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin U
|
|||
});
|
||||
|
||||
test("web RPC ignores Origin and Referer when the auth token is valid", async () => {
|
||||
const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts");
|
||||
const authToken = "test-web-auth-token";
|
||||
const runtime = await startWebManagementServer({
|
||||
authToken,
|
||||
|
|
@ -42,3 +65,199 @@ test("web RPC ignores Origin and Referer when the auth token is valid", async ()
|
|||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("startGateway reuses an already healthy CCR gateway on the configured port", async () => {
|
||||
const { saveAppConfig } = await import("@ccr/core/config/config.ts");
|
||||
const { createDefaultAppConfig } = await import("@ccr/core/config/default-config.ts");
|
||||
const { gatewayRuntimeConfigRevision } = await import("@ccr/core/gateway/runtime-config-control.ts");
|
||||
const { gatewayService } = await import("@ccr/core/gateway/service.ts");
|
||||
const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts");
|
||||
await gatewayService.stop();
|
||||
const gatewayPort = await findAvailablePort();
|
||||
const webAuthToken = "test-web-auth-token";
|
||||
const config = createDefaultAppConfig();
|
||||
config.gateway.port = gatewayPort;
|
||||
config.gateway.corePort = await findAvailablePort();
|
||||
config.Providers = [{
|
||||
api_base_url: "http://127.0.0.1:9/v1",
|
||||
api_key: "test-provider-key",
|
||||
id: "test-provider",
|
||||
models: ["test-model"],
|
||||
name: "Test Provider",
|
||||
type: "openai_chat_completions"
|
||||
}];
|
||||
const savedConfig = await saveAppConfig(config);
|
||||
const externalGatewayState = {
|
||||
reloadRequests: [],
|
||||
revision: gatewayRuntimeConfigRevision(savedConfig)
|
||||
};
|
||||
const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY, externalGatewayState);
|
||||
await listen(externalGateway, gatewayPort);
|
||||
const runtime = await startWebManagementServer({
|
||||
authToken: webAuthToken,
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
startGateway: false
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await rpc(runtime.url, webAuthToken, "startGateway");
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.value.state, "running", payload.value.lastError);
|
||||
assert.equal(payload.value.endpoint, `http://127.0.0.1:${gatewayPort}`);
|
||||
assert.equal(payload.value.gatewayManagedExternally, true);
|
||||
assert.equal(gatewayService.getStatus().state, "running");
|
||||
assert.equal(gatewayService.getStatus().gatewayManagedExternally, true);
|
||||
|
||||
const secondStart = await rpc(runtime.url, webAuthToken, "startGateway");
|
||||
assert.equal(secondStart.ok, true);
|
||||
assert.equal(secondStart.value.endpoint, `http://127.0.0.1:${gatewayPort}`);
|
||||
assert.equal(secondStart.value.gatewayManagedExternally, true);
|
||||
|
||||
const nextConfig = structuredClone(savedConfig);
|
||||
nextConfig.Providers[0].name = "Updated Test Provider";
|
||||
const reloadCountBeforeSave = externalGatewayState.reloadRequests.length;
|
||||
const saveResult = await rpc(runtime.url, webAuthToken, "saveConfig", [nextConfig, { applyProfile: false }]);
|
||||
assert.equal(saveResult.ok, true);
|
||||
assert.equal(externalGatewayState.reloadRequests.length, reloadCountBeforeSave + 1);
|
||||
assert.equal(externalGatewayState.reloadRequests.at(-1).forceRestart, true);
|
||||
assert.equal(externalGatewayState.revision, gatewayRuntimeConfigRevision(saveResult.value));
|
||||
assert.equal(gatewayService.getStatus().gatewayManagedExternally, true);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
await gatewayService.stop();
|
||||
await closeServer(externalGateway);
|
||||
}
|
||||
});
|
||||
|
||||
async function rpc(baseUrl, authToken, method, args = []) {
|
||||
const response = await fetch(new URL("/api/ccr/rpc", baseUrl), {
|
||||
body: JSON.stringify({ args, method }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ccr-web-auth": authToken
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function createHealthyCcrGateway(apiKey, state) {
|
||||
return createServer((request, response) => {
|
||||
const url = new URL(request.url || "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
sendJson(response, 200, {
|
||||
core: "http://127.0.0.1:3457",
|
||||
status: "running",
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/") {
|
||||
sendJson(response, 200, {
|
||||
core: "next-ai-gateway",
|
||||
endpoints: ["GET /v1/models"],
|
||||
name: "claude-code-router",
|
||||
plugin: "claude-code-router"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/v1/models") {
|
||||
if (request.headers.authorization !== `Bearer ${apiKey}`) {
|
||||
sendJson(response, 401, { error: { message: "Invalid API key." } });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, { data: [{ id: "test-model", object: "model" }], object: "list" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/__ccr/runtime/config") {
|
||||
if (request.headers.authorization !== `Bearer ${apiKey}`) {
|
||||
sendJson(response, 401, { error: { message: "Invalid API key." } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET") {
|
||||
sendJson(response, 200, { revision: state.revision, state: "running" });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
void readJsonRequest(request).then((body) => {
|
||||
state.reloadRequests.push(body);
|
||||
state.revision = body.configRevision;
|
||||
sendJson(response, 202, {
|
||||
accepted: true,
|
||||
configRevision: body.configRevision,
|
||||
restarting: body.forceRestart === true
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendJson(response, 404, { error: { message: "Not found." } });
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonRequest(request) {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function findAvailablePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : undefined;
|
||||
server.close(() => {
|
||||
if (port) {
|
||||
resolve(port);
|
||||
} else {
|
||||
reject(new Error("Failed to allocate a web management test port."));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setOptionalEnv(key, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
async function listen(server, port) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server) {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -991,7 +991,7 @@ async function routeMockRequest(runtime, method, url, request, requestBody) {
|
|||
}
|
||||
|
||||
if (isDesignRestApiRoutePath(path)) {
|
||||
return await handleDesignRestApi(runtime, method, path, request, requestBody);
|
||||
return await handleDesignRestApi(runtime, method, path, url, request, requestBody);
|
||||
}
|
||||
|
||||
if (path.startsWith("/v1/code/")) {
|
||||
|
|
@ -9812,7 +9812,7 @@ function upsertGenericRecord(store, collection, uuid, title, model, data) {
|
|||
store.persist();
|
||||
}
|
||||
|
||||
async function handleDesignRestApi(runtime, method, path, request, requestBody) {
|
||||
async function handleDesignRestApi(runtime, method, path, url, request, requestBody) {
|
||||
const restPath = normalizeDesignRestPath(path);
|
||||
|
||||
if (method === "GET" && restPath === "/v1/design/agents") {
|
||||
|
|
@ -9991,19 +9991,18 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody)
|
|||
});
|
||||
}
|
||||
|
||||
const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download$/);
|
||||
if (method === "GET" && downloadMatch) {
|
||||
const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download(?:\/(.+))?$/);
|
||||
if ((method === "GET" || method === "HEAD") && downloadMatch) {
|
||||
const projectId = decodeURIComponent(downloadMatch[1]);
|
||||
const manifest = listProjectFileRows(runtime, projectId, "").map((row) => ({
|
||||
content_type: row.content_type,
|
||||
path: row.path,
|
||||
size: Buffer.from(row.body_base64 || "", "base64").length,
|
||||
version: row.version
|
||||
}));
|
||||
return textResponse(200, JSON.stringify({ files: manifest, project_id: projectId }, null, 2), {
|
||||
"content-disposition": `attachment; filename="${projectId}.json"`,
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
});
|
||||
const filePath = designDownloadFilePath(downloadMatch[2], url);
|
||||
if (filePath) {
|
||||
const row = getProjectFileRow(runtime, projectId, filePath);
|
||||
if (!row) {
|
||||
return jsonResponse(404, { error: { message: `File not found: ${filePath}` } });
|
||||
}
|
||||
return serveProjectFileDownloadResponse(method, row, filePath);
|
||||
}
|
||||
return serveProjectArchiveDownloadResponse(method, runtime, projectId);
|
||||
}
|
||||
|
||||
if (method === "POST" && restPath === "/v1/design/drop-suggestions") {
|
||||
|
|
@ -10170,6 +10169,56 @@ function renderClaudeDesignPendingPreviewHtml() {
|
|||
</html>`;
|
||||
}
|
||||
|
||||
function designDownloadFilePath(pathSuffix, url) {
|
||||
const suffixPath = safelyDecodeDesignPath(pathSuffix);
|
||||
if (suffixPath) {
|
||||
return suffixPath;
|
||||
}
|
||||
for (const key of ["path", "file", "file_path", "download_path", "asset_path"]) {
|
||||
const queryPath = safelyDecodeDesignPath(url.searchParams.get(key));
|
||||
if (queryPath) {
|
||||
return queryPath;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function safelyDecodeDesignPath(value) {
|
||||
const raw = stringValue(value);
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return sanitizeProjectFilePath(decodeURIComponent(raw));
|
||||
} catch {
|
||||
return sanitizeProjectFilePath(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function serveProjectFileDownloadResponse(method, row, filePath) {
|
||||
const body = method === "HEAD" ? Buffer.alloc(0) : Buffer.from(row.body_base64 || "", "base64");
|
||||
const contentType = row.content_type || guessContentType(filePath);
|
||||
return binaryResponse(200, body, {
|
||||
"cache-control": "no-store",
|
||||
"content-disposition": attachmentContentDisposition(pathModule.posix.basename(filePath) || "download"),
|
||||
"content-type": contentType
|
||||
});
|
||||
}
|
||||
|
||||
function serveProjectArchiveDownloadResponse(method, runtime, projectId) {
|
||||
const rows = listProjectFileRows(runtime, projectId, "");
|
||||
const files = rows.map((row) => ({
|
||||
body: Buffer.from(row.body_base64 || "", "base64"),
|
||||
path: row.path
|
||||
}));
|
||||
const archive = method === "HEAD" ? Buffer.alloc(0) : createZipArchive(files);
|
||||
return binaryResponse(200, archive, {
|
||||
"cache-control": "no-store",
|
||||
"content-disposition": attachmentContentDisposition(`${projectId || "claude-design-project"}.zip`),
|
||||
"content-type": "application/zip"
|
||||
});
|
||||
}
|
||||
|
||||
function serveProjectFileResponse(runtime, projectId, row, filePath) {
|
||||
const body = Buffer.from(row.body_base64 || "", "base64");
|
||||
const contentType = row.content_type || guessContentType(filePath);
|
||||
|
|
@ -16652,6 +16701,21 @@ function escapeHtmlAttribute(value) {
|
|||
return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
}
|
||||
|
||||
function attachmentContentDisposition(fileName) {
|
||||
const basename = pathModule.posix.basename(sanitizeProjectFilePath(fileName) || "download") || "download";
|
||||
const fallback = basename
|
||||
.replace(/[^\x20-\x7e]+/g, "_")
|
||||
.replace(/["\\;\r\n]/g, "_")
|
||||
.trim() || "download";
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987ValueChars(basename)}`;
|
||||
}
|
||||
|
||||
function encodeRfc5987ValueChars(value) {
|
||||
return encodeURIComponent(value).replace(/['()*]/g, (char) =>
|
||||
`%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@claude-code-router/electron",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.21",
|
||||
"private": true,
|
||||
"description": "Claude Code Router Electron desktop shell.",
|
||||
"author": "musistudio",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue