init
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"savedAt": "2026-06-15T07:13:48.206Z",
|
||||
"agents": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"savedAt": "2026-06-15T07:13:48.207Z",
|
||||
"sessions": []
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
@@ -1,7 +1,8 @@
|
||||
node_modules
|
||||
.env
|
||||
log.txt
|
||||
.idea
|
||||
dist
|
||||
.DS_Store
|
||||
.vscode
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
src
|
||||
node_modules
|
||||
.claude
|
||||
CLAUDE.md
|
||||
screenshoots
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.blog
|
||||
docs
|
||||
.log
|
||||
blog
|
||||
config.json
|
||||
ui
|
||||
scripts
|
||||
@@ -1,44 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
- **Build the project**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
- **Start the router server**:
|
||||
```bash
|
||||
ccr start
|
||||
```
|
||||
- **Stop the router server**:
|
||||
```bash
|
||||
ccr stop
|
||||
```
|
||||
- **Check the server status**:
|
||||
```bash
|
||||
ccr status
|
||||
```
|
||||
- **Run Claude Code through the router**:
|
||||
```bash
|
||||
ccr code "<your prompt>"
|
||||
```
|
||||
- **Release a new version**:
|
||||
```bash
|
||||
npm run release
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This project is a TypeScript-based router for Claude Code requests. It allows routing requests to different large language models (LLMs) from various providers based on custom rules.
|
||||
|
||||
- **Entry Point**: The main command-line interface logic is in `src/cli.ts`. It handles parsing commands like `start`, `stop`, and `code`.
|
||||
- **Server**: The `ccr start` command launches a server that listens for requests from Claude Code. The server logic is initiated from `src/index.ts`.
|
||||
- **Configuration**: The router is configured via a JSON file located at `~/.claude-code-router/config.json`. This file defines API providers, routing rules, and custom transformers. An example can be found in `config.example.json`.
|
||||
- **Routing**: The core routing logic determines which LLM provider and model to use for a given request. It supports default routes for different scenarios (`default`, `background`, `think`, `longContext`, `webSearch`) and can be extended with a custom JavaScript router file. The router logic is likely in `src/utils/router.ts`.
|
||||
- **Providers and Transformers**: The application supports multiple LLM providers. Transformers adapt the request and response formats for different provider APIs.
|
||||
- **Claude Code Integration**: When a user runs `ccr code`, the command is forwarded to the running router service. The service then processes the request, applies routing rules, and sends it to the configured LLM. If the service isn't running, `ccr code` will attempt to start it automatically.
|
||||
- **Dependencies**: The project is built with `esbuild`. It has a key local dependency `@musistudio/llms`, which probably contains the core logic for interacting with different LLM APIs.
|
||||
- `@musistudio/llms` is implemented based on `fastify` and exposes `fastify`'s hook and middleware interfaces, allowing direct use of `server.addHook`.
|
||||
- 无论如何你都不能自动提交git
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN npm install -g @musistudio/claude-code-router
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["ccr", "start"]
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 musistudio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,675 +1,279 @@
|
||||
# Claude Code Router
|
||||
# Claude Code Router Desktop
|
||||
|
||||

|
||||
Electron desktop wrapper for Claude Code Router. The core gateway runtime is provided by the local `next-ai/gateway` project and installed as:
|
||||
|
||||
[](README_zh.md)
|
||||
[](https://discord.gg/rdftVMaUcS)
|
||||
[](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
|
||||
|
||||
## 📦 Monorepo Structure
|
||||
|
||||
This project has been restructured as a pnpm monorepo with two main packages:
|
||||
|
||||
### @musistudio/claude-code-router-core
|
||||
Core library package providing the fundamental functionality of Claude Code Router.
|
||||
|
||||
- **Location**: `packages/core`
|
||||
- **Purpose**: Provides core APIs and server functionality for third-party integration
|
||||
- **Exports**: Server creation, configuration management, routing logic, and other core features
|
||||
|
||||
### @musistudio/claude-code-router
|
||||
CLI package built on top of the core package, providing the command-line interface.
|
||||
|
||||
- **Location**: `packages/cli`
|
||||
- **Purpose**: Provides the `ccr` command-line tool
|
||||
- **Dependencies**: Depends on `@musistudio/claude-code-router-core`
|
||||
|
||||
## 🚀 Development
|
||||
|
||||
### Install Dependencies
|
||||
```bash
|
||||
pnpm install
|
||||
```json
|
||||
"gateway": "file:../../next-ai/gateway"
|
||||
```
|
||||
|
||||
### Build All Packages
|
||||
```bash
|
||||
pnpm build
|
||||
At runtime this app starts two local services:
|
||||
|
||||
- CCR wrapper: `http://127.0.0.1:3456`
|
||||
- next-ai core gateway: `http://127.0.0.1:3457`
|
||||
|
||||
The wrapper also owns an internal backend service for local HTTP backend lifecycles and scoped SQLite stores.
|
||||
|
||||
The wrapper reads `~/.claude-code-router/config.json`, preserves the old CCR `Providers` / `Router` format, generates `~/.claude-code-router/gateway.config.json`, and routes Claude Code `POST /v1/messages` requests into the core gateway.
|
||||
|
||||
## Provider Deeplink
|
||||
|
||||
Supplier websites can open CCR and import a model provider with a custom protocol link:
|
||||
|
||||
```text
|
||||
ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions&set_default=1
|
||||
```
|
||||
|
||||
### Build Specific Package
|
||||
```bash
|
||||
# Build core package
|
||||
pnpm --filter @musistudio/claude-code-router-core build
|
||||
Supported query parameters:
|
||||
|
||||
# Build cli package
|
||||
pnpm --filter @musistudio/claude-code-router build
|
||||
```
|
||||
- `name`: display name for the provider.
|
||||
- `base_url`: provider API base URL. Aliases: `baseUrl`, `api_base_url`, `url`, `endpoint`.
|
||||
- `api_key`: optional provider API key. Aliases: `apiKey`, `apikey`, `key`, `token`.
|
||||
- `models`: comma-separated or newline-separated model list. You can also repeat `model=...`.
|
||||
- `protocol`: one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, or `gemini_generate_content`. Aliases such as `openai`, `responses`, `anthropic`, and `gemini` are accepted.
|
||||
- `set_default=1`: make the imported provider the preferred provider.
|
||||
- `replace=1`: replace an existing provider with the same name or normalized base URL.
|
||||
|
||||
### Publish
|
||||
```bash
|
||||
pnpm release
|
||||
```
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### 1. CLI Usage (Unchanged)
|
||||
```bash
|
||||
# Install CLI package
|
||||
npm install -g @musistudio/claude-code-router
|
||||
|
||||
# Use commands
|
||||
ccr start
|
||||
ccr code "Write a Hello World"
|
||||
```
|
||||
|
||||
### 2. Library Usage (New Feature)
|
||||
```bash
|
||||
# Install core package
|
||||
npm install @musistudio/claude-code-router-core
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { getServer, run } from '@musistudio/claude-code-router-core';
|
||||
|
||||
// Create server instance
|
||||
const server = await getServer({ port: 3456 });
|
||||
|
||||
// Start server
|
||||
server.start();
|
||||
|
||||
// Or run directly
|
||||
await run({ port: 3456 });
|
||||
```
|
||||
|
||||
## 🔄 Migration Notes
|
||||
|
||||
This refactoring maintains backward compatibility:
|
||||
- CLI usage remains completely unchanged
|
||||
- Added library usage capability
|
||||
- Core logic separated into independent package for third-party integration
|
||||
|
||||
<hr>
|
||||
|
||||
> I am currently seeking **Agent development related job opportunities**, either **based in Hangzhou** or **remote**. If you are interested in my projects or have suitable opportunities, feel free to reach out! 📧 Email: m@musiiot.top
|
||||
|
||||
> A powerful tool to route Claude Code requests to different models and customize any request.
|
||||
|
||||
> Now you can use models such as `GLM-4.5`, `Kimi-K2`, `Qwen3-Coder-480B-A35B`, and `DeepSeek v3.1` for free through the [iFlow Platform](https://platform.iflow.cn/docs/api-mode).
|
||||
> You can use the `ccr ui` command to directly import the `iflow` template in the UI. It’s worth noting that iFlow limits each user to a concurrency of 1, which means you’ll need to route background requests to other models.
|
||||
> If you’d like a better experience, you can try [iFlow CLI](https://cli.iflow.cn).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Model Routing**: Route requests to different models based on your needs (e.g., background tasks, thinking, long context).
|
||||
- **Multi-Provider Support**: Supports various model providers like OpenRouter, DeepSeek, Ollama, Gemini, Volcengine, and SiliconFlow.
|
||||
- **Request/Response Transformation**: Customize requests and responses for different providers using transformers.
|
||||
- **Dynamic Model Switching**: Switch models on-the-fly within Claude Code using the `/model` command.
|
||||
- **GitHub Actions Integration**: Trigger Claude Code tasks in your GitHub workflows.
|
||||
- **Plugin System**: Extend functionality with custom transformers.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 1. Installation
|
||||
|
||||
First, ensure you have [Claude Code](https://docs.anthropic.com/en/docs/claude-code/quickstart) installed:
|
||||
|
||||
```shell
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
Then, install Claude Code Router:
|
||||
|
||||
```shell
|
||||
npm install -g @musistudio/claude-code-router
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
Create and configure your `~/.claude-code-router/config.json` file. For more details, you can refer to `config.example.json`.
|
||||
|
||||
The `config.json` file has several key sections:
|
||||
|
||||
- **`PROXY_URL`** (optional): You can set a proxy for API requests, for example: `"PROXY_URL": "http://127.0.0.1:7890"`.
|
||||
- **`LOG`** (optional): You can enable logging by setting it to `true`. When set to `false`, no log files will be created. Default is `true`.
|
||||
- **`LOG_LEVEL`** (optional): Set the logging level. Available options are: `"fatal"`, `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`. Default is `"debug"`.
|
||||
- **Logging Systems**: The Claude Code Router uses two separate logging systems:
|
||||
- **Server-level logs**: HTTP requests, API calls, and server events are logged using pino in the `~/.claude-code-router/logs/` directory with filenames like `ccr-*.log`
|
||||
- **Application-level logs**: Routing decisions and business logic events are logged in `~/.claude-code-router/claude-code-router.log`
|
||||
- **`APIKEY`** (optional): You can set a secret key to authenticate requests. When set, clients must provide this key in the `Authorization` header (e.g., `Bearer your-secret-key`) or the `x-api-key` header. Example: `"APIKEY": "your-secret-key"`.
|
||||
- **`HOST`** (optional): You can set the host address for the server. If `APIKEY` is not set, the host will be forced to `127.0.0.1` for security reasons to prevent unauthorized access. Example: `"HOST": "0.0.0.0"`.
|
||||
- **`NON_INTERACTIVE_MODE`** (optional): When set to `true`, enables compatibility with non-interactive environments like GitHub Actions, Docker containers, or other CI/CD systems. This sets appropriate environment variables (`CI=true`, `FORCE_COLOR=0`, etc.) and configures stdin handling to prevent the process from hanging in automated environments. Example: `"NON_INTERACTIVE_MODE": true`.
|
||||
|
||||
- **`Providers`**: Used to configure different model providers.
|
||||
- **`Router`**: Used to set up routing rules. `default` specifies the default model, which will be used for all requests if no other route is configured.
|
||||
- **`API_TIMEOUT_MS`**: Specifies the timeout for API calls in milliseconds.
|
||||
|
||||
#### Environment Variable Interpolation
|
||||
|
||||
Claude Code Router supports environment variable interpolation for secure API key management. You can reference environment variables in your `config.json` using either `$VAR_NAME` or `${VAR_NAME}` syntax:
|
||||
For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"OPENAI_API_KEY": "$OPENAI_API_KEY",
|
||||
"GEMINI_API_KEY": "${GEMINI_API_KEY}",
|
||||
"Providers": [
|
||||
"name": "Example AI",
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"apiKey": "sk-example",
|
||||
"models": ["example-chat", "example-coder"],
|
||||
"protocol": "openai_chat_completions",
|
||||
"setDefault": true
|
||||
}
|
||||
```
|
||||
|
||||
CCR always opens a confirmation dialog before writing a provider imported from an external link.
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
CCR now has two plugin layers:
|
||||
|
||||
- Core gateway plugins: keep using `providerPlugins` and `virtualModelProfiles`. They are passed through to `next-ai/gateway`.
|
||||
- Wrapper plugins: use top-level `plugins` to extend the Electron wrapper, register local HTTP backends, add gateway routes, and route proxy-mode traffic to plugin backends.
|
||||
|
||||
SQLite-backed local backend resources are managed by the wrapper's base backend service. They are not installed as a separate marketplace plugin.
|
||||
|
||||
Declarative proxy route to an existing backend:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "openai",
|
||||
"api_base_url": "https://api.openai.com/v1/chat/completions",
|
||||
"api_key": "$OPENAI_API_KEY",
|
||||
"models": ["gpt-5", "gpt-5-mini"]
|
||||
"id": "local-admin-api",
|
||||
"enabled": true,
|
||||
"proxy": {
|
||||
"routes": [
|
||||
{
|
||||
"id": "admin-api",
|
||||
"host": "api.example.com",
|
||||
"paths": ["/v1/admin"],
|
||||
"upstream": "http://127.0.0.1:4510",
|
||||
"stripPathPrefix": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to keep sensitive API keys in environment variables instead of hardcoding them in configuration files. The interpolation works recursively through nested objects and arrays.
|
||||
Executable plugin module paths are resolved from `~/.claude-code-router`; absolute paths and package names are also supported.
|
||||
|
||||
Here is a comprehensive example:
|
||||
Claude Design plugin routing:
|
||||
|
||||
The marketplace Claude Design plugin adapts Claude Design chat RPCs into CCR `/v1/messages` calls. Configure upstream APIs through CCR `Providers` as usual, then set Claude Design model routing from the Extensions page with the plugin's configure button. Routes created by the plugin are also shown on the Routing page as plugin-owned rows; they are read-only there and must be edited from the extension configuration dialog.
|
||||
|
||||
```json
|
||||
{
|
||||
"Providers": [
|
||||
{
|
||||
"name": "anthropic-main",
|
||||
"type": "anthropic_messages",
|
||||
"baseUrl": "https://api.anthropic.com",
|
||||
"apiKey": "sk-ant-...",
|
||||
"models": ["claude-sonnet-4-20250514"]
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"id": "claude-design",
|
||||
"enabled": true,
|
||||
"module": "./plugins/claude-design-plugin.cjs",
|
||||
"config": {
|
||||
"routing": {
|
||||
"enabled": true,
|
||||
"default": "anthropic-main,claude-sonnet-4-20250514",
|
||||
"rules": [
|
||||
{
|
||||
"id": "design-opus",
|
||||
"name": "Claude Design Opus",
|
||||
"type": "model",
|
||||
"model": "claude-opus-4-8",
|
||||
"target": "anthropic-main,claude-sonnet-4-20250514",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Cursor proxy plugin:
|
||||
|
||||
The marketplace Cursor Proxy plugin registers proxy-mode routes for all paths on `api*.cursor.sh`, forwards OpenAI/Anthropic/Gemini-compatible JSON LLM requests to the local CCR gateway, and uses the configured CCR API key automatically. For Cursor Agent traffic, it bridges the private protobuf `BidiAppend` + `AgentService/RunSSE` flow into CCR's `/v1/chat/completions` gateway and streams the gateway response back as Cursor `AgentServerMessage` events. It also attempts to decode Cursor native Connect JSON/protobuf LLM RPCs under `aiserver.v1.*` and `agent.v1.*`, preserving decoded system prompts, tools, tool choices, tool calls, and tool results when those fields are present in the native payload.
|
||||
|
||||
Cursor often sends Agent requests with `model: "default"` or another Cursor-local model name. Configure Cursor Proxy model routing from the Extensions page with the plugin's configure button, or set `config.routing` manually. Route targets use the same provider/model selector format as Claude Design plugin routing. The plugin rewrites Cursor's source model to the selected CCR target model before forwarding it to the gateway, so the core gateway does not need a model literally named `default`.
|
||||
|
||||
```json
|
||||
{
|
||||
"APIKEY": "your-secret-key",
|
||||
"PROXY_URL": "http://127.0.0.1:7890",
|
||||
"LOG": true,
|
||||
"API_TIMEOUT_MS": 600000,
|
||||
"NON_INTERACTIVE_MODE": false,
|
||||
"Providers": [
|
||||
{
|
||||
"name": "openrouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": [
|
||||
"google/gemini-2.5-pro-preview",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3.7-sonnet:thinking"
|
||||
],
|
||||
"transformer": {
|
||||
"use": ["openrouter"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deepseek",
|
||||
"api_base_url": "https://api.deepseek.com/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"],
|
||||
"deepseek-chat": {
|
||||
"use": ["tooluse"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ollama",
|
||||
"api_base_url": "http://localhost:11434/v1/chat/completions",
|
||||
"api_key": "ollama",
|
||||
"models": ["qwen2.5-coder:latest"]
|
||||
},
|
||||
{
|
||||
"name": "gemini",
|
||||
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
|
||||
"transformer": {
|
||||
"use": ["gemini"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volcengine",
|
||||
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-v3-250324", "deepseek-r1-250528"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "modelscope",
|
||||
"api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"models": ["Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 65536
|
||||
}
|
||||
],
|
||||
"enhancetool"
|
||||
],
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
|
||||
"use": ["reasoning"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dashscope",
|
||||
"api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"models": ["qwen3-coder-plus"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 65536
|
||||
}
|
||||
],
|
||||
"enhancetool"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "aihubmix",
|
||||
"api_base_url": "https://aihubmix.com/v1/chat/completions",
|
||||
"api_key": "sk-",
|
||||
"models": [
|
||||
"Z/glm-4.5",
|
||||
"claude-opus-4-20250514",
|
||||
"gemini-2.5-pro"
|
||||
]
|
||||
"type": "openai_chat_completions",
|
||||
"baseUrl": "https://openrouter.ai/api/v1",
|
||||
"apiKey": "sk-or-...",
|
||||
"models": ["anthropic/claude-sonnet-4.5", "google/gemini-3-pro-preview"]
|
||||
}
|
||||
],
|
||||
"Router": {
|
||||
"default": "deepseek,deepseek-chat",
|
||||
"background": "ollama,qwen2.5-coder:latest",
|
||||
"think": "deepseek,deepseek-reasoner",
|
||||
"longContext": "openrouter,google/gemini-2.5-pro-preview",
|
||||
"longContextThreshold": 60000,
|
||||
"webSearch": "gemini,gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Running Claude Code with the Router
|
||||
|
||||
Start Claude Code using the router:
|
||||
|
||||
```shell
|
||||
ccr code
|
||||
```
|
||||
|
||||
> **Note**: After modifying the configuration file, you need to restart the service for the changes to take effect:
|
||||
>
|
||||
> ```shell
|
||||
> ccr restart
|
||||
> ```
|
||||
|
||||
### 4. UI Mode
|
||||
|
||||
For a more intuitive experience, you can use the UI mode to manage your configuration:
|
||||
|
||||
```shell
|
||||
ccr ui
|
||||
```
|
||||
|
||||
This will open a web-based interface where you can easily view and edit your `config.json` file.
|
||||
|
||||

|
||||
|
||||
#### Providers
|
||||
|
||||
The `Providers` array is where you define the different model providers you want to use. Each provider object requires:
|
||||
|
||||
- `name`: A unique name for the provider.
|
||||
- `api_base_url`: The full API endpoint for chat completions.
|
||||
- `api_key`: Your API key for the provider.
|
||||
- `models`: A list of model names available from this provider.
|
||||
- `transformer` (optional): Specifies transformers to process requests and responses.
|
||||
|
||||
#### Transformers
|
||||
|
||||
Transformers allow you to modify the request and response payloads to ensure compatibility with different provider APIs.
|
||||
|
||||
- **Global Transformer**: Apply a transformer to all models from a provider. In this example, the `openrouter` transformer is applied to all models under the `openrouter` provider.
|
||||
```json
|
||||
{
|
||||
"name": "openrouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": [
|
||||
"google/gemini-2.5-pro-preview",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-3.5-sonnet"
|
||||
],
|
||||
"transformer": { "use": ["openrouter"] }
|
||||
}
|
||||
```
|
||||
- **Model-Specific Transformer**: Apply a transformer to a specific model. In this example, the `deepseek` transformer is applied to all models, and an additional `tooluse` transformer is applied only to the `deepseek-chat` model.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "deepseek",
|
||||
"api_base_url": "https://api.deepseek.com/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"],
|
||||
"deepseek-chat": { "use": ["tooluse"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Passing Options to a Transformer**: Some transformers, like `maxtoken`, accept options. To pass options, use a nested array where the first element is the transformer name and the second is an options object.
|
||||
```json
|
||||
{
|
||||
"name": "siliconflow",
|
||||
"api_base_url": "https://api.siliconflow.cn/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["moonshotai/Kimi-K2-Instruct"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 16384
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available Built-in Transformers:**
|
||||
|
||||
- `Anthropic`:If you use only the `Anthropic` transformer, it will preserve the original request and response parameters(you can use it to connect directly to an Anthropic endpoint).
|
||||
- `deepseek`: Adapts requests/responses for DeepSeek API.
|
||||
- `gemini`: Adapts requests/responses for Gemini API.
|
||||
- `openrouter`: Adapts requests/responses for OpenRouter API. It can also accept a `provider` routing parameter to specify which underlying providers OpenRouter should use. For more details, refer to the [OpenRouter documentation](https://openrouter.ai/docs/features/provider-routing). See an example below:
|
||||
```json
|
||||
"transformer": {
|
||||
"use": ["openrouter"],
|
||||
"moonshotai/kimi-k2": {
|
||||
"use": [
|
||||
[
|
||||
"openrouter",
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"mode": "gateway"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": "cursor-proxy",
|
||||
"enabled": true,
|
||||
"module": "./plugins/cursor-proxy-plugin.cjs",
|
||||
"config": {
|
||||
"routing": {
|
||||
"enabled": true,
|
||||
"default": "openrouter,anthropic/claude-sonnet-4.5",
|
||||
"rules": [
|
||||
{
|
||||
"provider": {
|
||||
"only": ["moonshotai/fp8"]
|
||||
}
|
||||
"id": "cursor-default",
|
||||
"name": "Cursor default",
|
||||
"type": "model",
|
||||
"model": "default",
|
||||
"target": "openrouter,anthropic/claude-sonnet-4.5",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
- `groq`: Adapts requests/responses for groq API.
|
||||
- `maxtoken`: Sets a specific `max_tokens` value.
|
||||
- `tooluse`: Optimizes tool usage for certain models via `tool_choice`.
|
||||
- `gemini-cli` (experimental): Unofficial support for Gemini via Gemini CLI [gemini-cli.js](https://gist.github.com/musistudio/1c13a65f35916a7ab690649d3df8d1cd).
|
||||
- `reasoning`: Used to process the `reasoning_content` field.
|
||||
- `sampling`: Used to process sampling information fields such as `temperature`, `top_p`, `top_k`, and `repetition_penalty`.
|
||||
- `enhancetool`: Adds a layer of error tolerance to the tool call parameters returned by the LLM (this will cause the tool call information to no longer be streamed).
|
||||
- `cleancache`: Clears the `cache_control` field from requests.
|
||||
- `vertex-gemini`: Handles the Gemini API using Vertex authentication.
|
||||
- `chutes-glm` Unofficial support for GLM 4.5 model via Chutes [chutes-glm-transformer.js](https://gist.github.com/vitobotta/2be3f33722e05e8d4f9d2b0138b8c863).
|
||||
- `qwen-cli` (experimental): Unofficial support for qwen3-coder-plus model via Qwen CLI [qwen-cli.js](https://gist.github.com/musistudio/f5a67841ced39912fd99e42200d5ca8b).
|
||||
- `rovo-cli` (experimental): Unofficial support for gpt-5 via Atlassian Rovo Dev CLI [rovo-cli.js](https://gist.github.com/SaseQ/c2a20a38b11276537ec5332d1f7a5e53).
|
||||
|
||||
**Custom Transformers:**
|
||||
|
||||
You can also create your own transformers and load them via the `transformers` field in `config.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"transformers": [
|
||||
{
|
||||
"path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js",
|
||||
"options": {
|
||||
"project": "xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Router
|
||||
Other unsupported native Cursor RPC traffic is passed through to Cursor by default; set `"fallbackToCursor": false` to fail unsupported requests instead. Set `paths` only if you intentionally want to restrict which Cursor paths the plugin captures.
|
||||
|
||||
The `Router` object defines which model to use for different scenarios:
|
||||
`cursorBidiProto`, `cursorConnectJson`, and `cursorNativeProto` are enabled by default. Set them to `false` only when you want Cursor's private Agent protobuf, Connect JSON, or generic native LLM RPC traffic to pass through untouched. `bidiWaitMs`, `bidiSessionTtlMs`, and `gatewayTimeoutMs` can be tuned for slow clients or slow upstream providers.
|
||||
Generic native RPC decoding is intentionally limited to Cursor methods that look like generation or streaming LLM calls. Metadata and status calls such as model pickers, repository sync, analytics, dashboards, and file sync are passed through to Cursor. If a new Cursor LLM method is not detected yet, add its method name or full RPC path to `cursorNativeLlmMethods`.
|
||||
|
||||
- `default`: The default model for general tasks.
|
||||
- `background`: A model for background tasks. This can be a smaller, local model to save costs.
|
||||
- `think`: A model for reasoning-heavy tasks, like Plan Mode.
|
||||
- `longContext`: A model for handling long contexts (e.g., > 60K tokens).
|
||||
- `longContextThreshold` (optional): The token count threshold for triggering the long context model. Defaults to 60000 if not specified.
|
||||
- `webSearch`: Used for handling web search tasks and this requires the model itself to support the feature. If you're using openrouter, you need to add the `:online` suffix after the model name.
|
||||
- `image` (beta): Used for handling image-related tasks (supported by CCR’s built-in agent). If the model does not support tool calling, you need to set the `config.forceUseImageAgent` property to `true`.
|
||||
|
||||
- You can also switch models dynamically in Claude Code with the `/model` command:
|
||||
`/model provider_name,model_name`
|
||||
Example: `/model openrouter,anthropic/claude-3.5-sonnet`
|
||||
|
||||
#### Custom Router
|
||||
|
||||
For more advanced routing logic, you can specify a custom router script via the `CUSTOM_ROUTER_PATH` in your `config.json`. This allows you to implement complex routing rules beyond the default scenarios.
|
||||
|
||||
In your `config.json`:
|
||||
If Cursor sends an OpenAI-compatible `*/chat/completions` request that already includes `system` messages or `tools`, Cursor Proxy and the CCR gateway preserve them. Some Cursor custom-provider flows send only user messages; the proxy cannot recover system/tool context that is not present in the incoming request. For those flows, configure fallback context explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"CUSTOM_ROUTER_PATH": "/User/xxx/.claude-code-router/custom-router.js"
|
||||
"plugins": [
|
||||
{
|
||||
"id": "cursor-proxy",
|
||||
"config": {
|
||||
"systemPrompt": "You are Cursor in agent mode.",
|
||||
"tools": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file from the workspace.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" }
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"toolChoice": "auto"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The custom router file must be a JavaScript module that exports an `async` function. This function receives the request object and the config object as arguments and should return the provider and model name as a string (e.g., `"provider_name,model_name"`), or `null` to fall back to the default router.
|
||||
The plugin does not define a separate provider format. Configure upstream APIs through CCR's existing `Providers`, `Router`, `providerPlugins`, and `virtualModelProfiles`; Cursor Proxy only adapts Cursor-compatible request paths and forwards them to the local CCR gateway. Legacy `targetProvider`, `targetProviders`, and `targetModel` are still accepted and converted into a routing target when `routing.default` is not set, but `config.routing` is preferred:
|
||||
|
||||
Here is an example of a `custom-router.js` based on `custom-router.example.js`:
|
||||
|
||||
```javascript
|
||||
// /User/xxx/.claude-code-router/custom-router.js
|
||||
|
||||
/**
|
||||
* A custom router function to determine which model to use based on the request.
|
||||
*
|
||||
* @param {object} req - The request object from Claude Code, containing the request body.
|
||||
* @param {object} config - The application's config object.
|
||||
* @returns {Promise<string|null>} - A promise that resolves to the "provider,model_name" string, or null to use the default router.
|
||||
*/
|
||||
module.exports = async function router(req, config) {
|
||||
const userMessage = req.body.messages.find((m) => m.role === "user")?.content;
|
||||
|
||||
if (userMessage && userMessage.includes("explain this code")) {
|
||||
// Use a powerful model for code explanation
|
||||
return "openrouter,anthropic/claude-3.5-sonnet";
|
||||
}
|
||||
|
||||
// Fallback to the default router configuration
|
||||
return null;
|
||||
};
|
||||
```json
|
||||
{
|
||||
"Providers": [
|
||||
{
|
||||
"name": "anthropic-main",
|
||||
"type": "anthropic_messages",
|
||||
"baseUrl": "https://api.anthropic.com",
|
||||
"apiKey": "sk-ant-...",
|
||||
"models": ["claude-sonnet-4-20250514"]
|
||||
}
|
||||
],
|
||||
"Router": {
|
||||
"default": "anthropic-main,claude-sonnet-4-20250514"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": "cursor-proxy",
|
||||
"module": "./plugins/cursor-proxy-plugin.cjs",
|
||||
"config": {
|
||||
"routing": {
|
||||
"enabled": true,
|
||||
"default": "anthropic-main,claude-sonnet-4-20250514"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
##### Subagent Routing
|
||||
Local plugin directories can declare dependencies in `plugin.json`, `ccr-plugin.json`, `.ccr-plugin/plugin.json`, `.codex-plugin/plugin.json`, or under `ccr.dependencies` / `ccrPlugin.dependencies` in `package.json`:
|
||||
|
||||
For routing within subagents, you must specify a particular provider and model by including `<CCR-SUBAGENT-MODEL>provider,model</CCR-SUBAGENT-MODEL>` at the **beginning** of the subagent's prompt. This allows you to direct specific subagent tasks to designated models.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
<CCR-SUBAGENT-MODEL>openrouter,anthropic/claude-3.5-sonnet</CCR-SUBAGENT-MODEL>
|
||||
Please help me analyze this code snippet for potential optimizations...
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"module": "./index.cjs",
|
||||
"dependencies": [
|
||||
"claude-design",
|
||||
{ "id": "local-helper", "module": "../local-helper/index.cjs" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Status Line (Beta)
|
||||
To better monitor the status of claude-code-router at runtime, version v1.0.40 includes a built-in statusline tool, which you can enable in the UI.
|
||||

|
||||
Dependencies declared by ID are resolved from the marketplace; dependencies with `module`, `path`, or `modulePath` are installed from that local path.
|
||||
|
||||
The effect is as follows:
|
||||

|
||||
Plugin modules export a function or object with `setup(ctx)`. The context supports:
|
||||
|
||||
## 🤖 GitHub Actions
|
||||
- `ctx.registerGatewayRoute({ method, path, auth, handler })`
|
||||
- `ctx.registerHttpBackend({ id, host, port, handler })`
|
||||
- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
|
||||
- `ctx.openSqliteStore({ filename, migrate })`
|
||||
- `ctx.registerCoreGatewayProviderPlugin(plugin)`
|
||||
- `ctx.registerCoreGatewayVirtualModelProfile(profile)`
|
||||
|
||||
Integrate Claude Code Router into your CI/CD pipeline. After setting up [Claude Code Actions](https://docs.anthropic.com/en/docs/claude-code/github-actions), modify your `.github/workflows/claude.yaml` to use the router:
|
||||
`ctx.registerHttpBackend` and `ctx.openSqliteStore` are backed by the wrapper's base backend service, so plugin modules do not need to ship or install a SQLite backend plugin.
|
||||
|
||||
```yaml
|
||||
name: Claude Code
|
||||
## Scripts
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# ... other triggers
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
# ... other conditions
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Prepare Environment
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
mkdir -p $HOME/.claude-code-router
|
||||
cat << 'EOF' > $HOME/.claude-code-router/config.json
|
||||
{
|
||||
"log": true,
|
||||
"NON_INTERACTIVE_MODE": true,
|
||||
"OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}",
|
||||
"OPENAI_BASE_URL": "https://api.deepseek.com",
|
||||
"OPENAI_MODEL": "deepseek-chat"
|
||||
}
|
||||
EOF
|
||||
shell: bash
|
||||
|
||||
- name: Start Claude Code Router
|
||||
run: |
|
||||
nohup ~/.bun/bin/bunx @musistudio/claude-code-router@1.0.8 start &
|
||||
shell: bash
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: http://localhost:3456
|
||||
with:
|
||||
anthropic_api_key: "any-string-is-ok"
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run typecheck
|
||||
npm run build:assets
|
||||
npm run build:app
|
||||
```
|
||||
|
||||
> **Note**: When running in GitHub Actions or other automation environments, make sure to set `"NON_INTERACTIVE_MODE": true` in your configuration to prevent the process from hanging due to stdin handling issues.
|
||||
`npm run build:assets` compiles the Electron main process and renderer assets into `dist/`.
|
||||
|
||||
This setup allows for interesting automations, like running tasks during off-peak hours to reduce API costs.
|
||||
`npm run build` packages the app for the current platform and writes installer artifacts to `release/`.
|
||||
|
||||
## 📝 Further Reading
|
||||
|
||||
- [Project Motivation and How It Works](blog/en/project-motivation-and-how-it-works.md)
|
||||
- [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md)
|
||||
|
||||
## ❤️ Support & Sponsoring
|
||||
|
||||
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated!
|
||||
|
||||
[](https://ko-fi.com/F1F31GN2GM)
|
||||
|
||||
[Paypal](https://paypal.me/musistudio1999)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
|
||||
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Our Sponsors
|
||||
|
||||
A huge thank you to all our sponsors for their generous support!
|
||||
|
||||
|
||||
- [AIHubmix](https://aihubmix.com/)
|
||||
- [BurnCloud](https://ai.burncloud.com)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
- @\*o
|
||||
- [@ceilwoo](https://github.com/ceilwoo)
|
||||
- @\*说
|
||||
- @\*更
|
||||
- @K\*g
|
||||
- @R\*R
|
||||
- [@bobleer](https://github.com/bobleer)
|
||||
- @\*苗
|
||||
- @\*划
|
||||
- [@Clarence-pan](https://github.com/Clarence-pan)
|
||||
- [@carter003](https://github.com/carter003)
|
||||
- @S\*r
|
||||
- @\*晖
|
||||
- @\*敏
|
||||
- @Z\*z
|
||||
- @\*然
|
||||
- [@cluic](https://github.com/cluic)
|
||||
- @\*苗
|
||||
- [@PromptExpert](https://github.com/PromptExpert)
|
||||
- @\*应
|
||||
- [@yusnake](https://github.com/yusnake)
|
||||
- @\*飞
|
||||
- @董\*
|
||||
- @\*汀
|
||||
- @\*涯
|
||||
- @\*:-)
|
||||
- @\*\*磊
|
||||
- @\*琢
|
||||
- @\*成
|
||||
- @Z\*o
|
||||
- @\*琨
|
||||
- [@congzhangzh](https://github.com/congzhangzh)
|
||||
- @\*\_
|
||||
- @Z\*m
|
||||
- @*鑫
|
||||
- @c\*y
|
||||
- @\*昕
|
||||
- [@witsice](https://github.com/witsice)
|
||||
- @b\*g
|
||||
- @\*亿
|
||||
- @\*辉
|
||||
- @JACK
|
||||
- @\*光
|
||||
- @W\*l
|
||||
- [@kesku](https://github.com/kesku)
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
- @\*咸
|
||||
- @\*明
|
||||
- @S\*y
|
||||
- @f\*o
|
||||
- @\*智
|
||||
- @F\*t
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
- @\*政
|
||||
- @\*铭
|
||||
- @\*叶
|
||||
- @七\*o
|
||||
- @\*青
|
||||
- @\*\*晨
|
||||
- @\*远
|
||||
- @\*霄
|
||||
- @\*\*吉
|
||||
- @\*\*飞
|
||||
|
||||
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
|
||||
`npm run build:app` packages both macOS and Windows artifacts with `electron-builder --mac --win`. You can also run `npm run build:app:mac` or `npm run build:app:win` for a single platform. Cross-building Windows installers from macOS may require Wine; otherwise run the Windows build command on Windows.
|
||||
|
||||
@@ -1,568 +0,0 @@
|
||||

|
||||
|
||||
[](README.md)
|
||||
[](https://discord.gg/rdftVMaUcS)
|
||||
[](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
|
||||
|
||||
<hr>
|
||||
|
||||
> 我目前正在寻找 **Agent 开发相关的工作机会**,可 base 在 **杭州**,也接受 **远程** 合作。如果你对我的项目感兴趣,或有合适的岗位/合作机会,欢迎联系我! 📧 Email: m@musiiot.top
|
||||
|
||||
> 一款强大的工具,可将 Claude Code 请求路由到不同的模型,并自定义任何请求。
|
||||
|
||||
> 现在你可以通过[心流平台](https://platform.iflow.cn/docs/api-mode)免费使用`GLM-4.5`、`Kimi-K2`、`Qwen3-Coder-480B-A35B`、`DeepSeek v3.1`等模型。
|
||||
> 你可以使用`ccr ui`命令在UI中直接导入`iflow`模板,值得注意的是心流限制每位用户的并发数为1,意味着你需要将`background`路由到其他模型。
|
||||
> 如果你想获得更好的体验,可以尝试[iFlow CLI](https://cli.iflow.cn)。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
## ✨ 功能
|
||||
|
||||
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
|
||||
- **多提供商支持**: 支持 OpenRouter、DeepSeek、Ollama、Gemini、Volcengine 和 SiliconFlow 等各种模型提供商。
|
||||
- **请求/响应转换**: 使用转换器为不同的提供商自定义请求和响应。
|
||||
- **动态模型切换**: 在 Claude Code 中使用 `/model` 命令动态切换模型。
|
||||
- **GitHub Actions 集成**: 在您的 GitHub 工作流程中触发 Claude Code 任务。
|
||||
- **插件系统**: 使用自定义转换器扩展功能。
|
||||
|
||||
## 🚀 快速入门
|
||||
|
||||
### 1. 安装
|
||||
|
||||
首先,请确保您已安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code/quickstart):
|
||||
|
||||
```shell
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
然后,安装 Claude Code Router:
|
||||
|
||||
```shell
|
||||
npm install -g @musistudio/claude-code-router
|
||||
```
|
||||
|
||||
### 2. 配置
|
||||
|
||||
创建并配置您的 `~/.claude-code-router/config.json` 文件。有关更多详细信息,您可以参考 `config.example.json`。
|
||||
|
||||
`config.json` 文件有几个关键部分:
|
||||
- **`PROXY_URL`** (可选): 您可以为 API 请求设置代理,例如:`"PROXY_URL": "http://127.0.0.1:7890"`。
|
||||
- **`LOG`** (可选): 您可以通过将其设置为 `true` 来启用日志记录。当设置为 `false` 时,将不会创建日志文件。默认值为 `true`。
|
||||
- **`LOG_LEVEL`** (可选): 设置日志级别。可用选项包括:`"fatal"`、`"error"`、`"warn"`、`"info"`、`"debug"`、`"trace"`。默认值为 `"debug"`。
|
||||
- **日志系统**: Claude Code Router 使用两个独立的日志系统:
|
||||
- **服务器级别日志**: HTTP 请求、API 调用和服务器事件使用 pino 记录在 `~/.claude-code-router/logs/` 目录中,文件名类似于 `ccr-*.log`
|
||||
- **应用程序级别日志**: 路由决策和业务逻辑事件记录在 `~/.claude-code-router/claude-code-router.log` 文件中
|
||||
- **`APIKEY`** (可选): 您可以设置一个密钥来进行身份验证。设置后,客户端请求必须在 `Authorization` 请求头 (例如, `Bearer your-secret-key`) 或 `x-api-key` 请求头中提供此密钥。例如:`"APIKEY": "your-secret-key"`。
|
||||
- **`HOST`** (可选): 您可以设置服务的主机地址。如果未设置 `APIKEY`,出于安全考虑,主机地址将强制设置为 `127.0.0.1`,以防止未经授权的访问。例如:`"HOST": "0.0.0.0"`。
|
||||
- **`NON_INTERACTIVE_MODE`** (可选): 当设置为 `true` 时,启用与非交互式环境(如 GitHub Actions、Docker 容器或其他 CI/CD 系统)的兼容性。这会设置适当的环境变量(`CI=true`、`FORCE_COLOR=0` 等)并配置 stdin 处理,以防止进程在自动化环境中挂起。例如:`"NON_INTERACTIVE_MODE": true`。
|
||||
- **`Providers`**: 用于配置不同的模型提供商。
|
||||
- **`Router`**: 用于设置路由规则。`default` 指定默认模型,如果未配置其他路由,则该模型将用于所有请求。
|
||||
- **`API_TIMEOUT_MS`**: API 请求超时时间,单位为毫秒。
|
||||
|
||||
这是一个综合示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"APIKEY": "your-secret-key",
|
||||
"PROXY_URL": "http://127.0.0.1:7890",
|
||||
"LOG": true,
|
||||
"API_TIMEOUT_MS": 600000,
|
||||
"NON_INTERACTIVE_MODE": false,
|
||||
"Providers": [
|
||||
{
|
||||
"name": "openrouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": [
|
||||
"google/gemini-2.5-pro-preview",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3.7-sonnet:thinking"
|
||||
],
|
||||
"transformer": {
|
||||
"use": ["openrouter"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deepseek",
|
||||
"api_base_url": "https://api.deepseek.com/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"],
|
||||
"deepseek-chat": {
|
||||
"use": ["tooluse"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ollama",
|
||||
"api_base_url": "http://localhost:11434/v1/chat/completions",
|
||||
"api_key": "ollama",
|
||||
"models": ["qwen2.5-coder:latest"]
|
||||
},
|
||||
{
|
||||
"name": "gemini",
|
||||
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
|
||||
"transformer": {
|
||||
"use": ["gemini"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volcengine",
|
||||
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-v3-250324", "deepseek-r1-250528"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "modelscope",
|
||||
"api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"models": ["Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 65536
|
||||
}
|
||||
],
|
||||
"enhancetool"
|
||||
],
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
|
||||
"use": ["reasoning"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dashscope",
|
||||
"api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"models": ["qwen3-coder-plus"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 65536
|
||||
}
|
||||
],
|
||||
"enhancetool"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "aihubmix",
|
||||
"api_base_url": "https://aihubmix.com/v1/chat/completions",
|
||||
"api_key": "sk-",
|
||||
"models": [
|
||||
"Z/glm-4.5",
|
||||
"claude-opus-4-20250514",
|
||||
"gemini-2.5-pro"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Router": {
|
||||
"default": "deepseek,deepseek-chat",
|
||||
"background": "ollama,qwen2.5-coder:latest",
|
||||
"think": "deepseek,deepseek-reasoner",
|
||||
"longContext": "openrouter,google/gemini-2.5-pro-preview",
|
||||
"longContextThreshold": 60000,
|
||||
"webSearch": "gemini,gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 3. 使用 Router 运行 Claude Code
|
||||
|
||||
使用 router 启动 Claude Code:
|
||||
|
||||
```shell
|
||||
ccr code
|
||||
```
|
||||
|
||||
> **注意**: 修改配置文件后,需要重启服务使配置生效:
|
||||
> ```shell
|
||||
> ccr restart
|
||||
> ```
|
||||
|
||||
### 4. UI 模式
|
||||
|
||||
为了获得更直观的体验,您可以使用 UI 模式来管理您的配置:
|
||||
|
||||
```shell
|
||||
ccr ui
|
||||
```
|
||||
|
||||
这将打开一个基于 Web 的界面,您可以在其中轻松查看和编辑您的 `config.json` 文件。
|
||||
|
||||

|
||||
|
||||
#### Providers
|
||||
|
||||
`Providers` 数组是您定义要使用的不同模型提供商的地方。每个提供商对象都需要:
|
||||
|
||||
- `name`: 提供商的唯一名称。
|
||||
- `api_base_url`: 聊天补全的完整 API 端点。
|
||||
- `api_key`: 您提供商的 API 密钥。
|
||||
- `models`: 此提供商可用的模型名称列表。
|
||||
- `transformer` (可选): 指定用于处理请求和响应的转换器。
|
||||
|
||||
#### Transformers
|
||||
|
||||
Transformers 允许您修改请求和响应负载,以确保与不同提供商 API 的兼容性。
|
||||
|
||||
- **全局 Transformer**: 将转换器应用于提供商的所有模型。在此示例中,`openrouter` 转换器将应用于 `openrouter` 提供商下的所有模型。
|
||||
```json
|
||||
{
|
||||
"name": "openrouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": [
|
||||
"google/gemini-2.5-pro-preview",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-3.5-sonnet"
|
||||
],
|
||||
"transformer": { "use": ["openrouter"] }
|
||||
}
|
||||
```
|
||||
- **特定于模型的 Transformer**: 将转换器应用于特定模型。在此示例中,`deepseek` 转换器应用于所有模型,而额外的 `tooluse` 转换器仅应用于 `deepseek-chat` 模型。
|
||||
```json
|
||||
{
|
||||
"name": "deepseek",
|
||||
"api_base_url": "https://api.deepseek.com/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"],
|
||||
"deepseek-chat": { "use": ["tooluse"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **向 Transformer 传递选项**: 某些转换器(如 `maxtoken`)接受选项。要传递选项,请使用嵌套数组,其中第一个元素是转换器名称,第二个元素是选项对象。
|
||||
```json
|
||||
{
|
||||
"name": "siliconflow",
|
||||
"api_base_url": "https://api.siliconflow.cn/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["moonshotai/Kimi-K2-Instruct"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 16384
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用的内置 Transformer:**
|
||||
|
||||
- `Anthropic`: 如果你只使用这一个转换器,则会直接透传请求和响应(你可以用它来接入其他支持Anthropic端点的服务商)。
|
||||
- `deepseek`: 适配 DeepSeek API 的请求/响应。
|
||||
- `gemini`: 适配 Gemini API 的请求/响应。
|
||||
- `openrouter`: 适配 OpenRouter API 的请求/响应。它还可以接受一个 `provider` 路由参数,以指定 OpenRouter 应使用哪些底层提供商。有关更多详细信息,请参阅 [OpenRouter 文档](https://openrouter.ai/docs/features/provider-routing)。请参阅下面的示例:
|
||||
```json
|
||||
"transformer": {
|
||||
"use": ["openrouter"],
|
||||
"moonshotai/kimi-k2": {
|
||||
"use": [
|
||||
[
|
||||
"openrouter",
|
||||
{
|
||||
"provider": {
|
||||
"only": ["moonshotai/fp8"]
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
- `groq`: 适配 groq API 的请求/响应
|
||||
- `maxtoken`: 设置特定的 `max_tokens` 值。
|
||||
- `tooluse`: 优化某些模型的工具使用(通过`tool_choice`参数)。
|
||||
- `gemini-cli` (实验性): 通过 Gemini CLI [gemini-cli.js](https://gist.github.com/musistudio/1c13a65f35916a7ab690649d3df8d1cd) 对 Gemini 的非官方支持。
|
||||
- `reasoning`: 用于处理 `reasoning_content` 字段。
|
||||
- `sampling`: 用于处理采样信息字段,如 `temperature`、`top_p`、`top_k` 和 `repetition_penalty`。
|
||||
- `enhancetool`: 对 LLM 返回的工具调用参数增加一层容错处理(这会导致不再流式返回工具调用信息)。
|
||||
- `cleancache`: 清除请求中的 `cache_control` 字段。
|
||||
- `vertex-gemini`: 处理使用 vertex 鉴权的 gemini api。
|
||||
- `qwen-cli` (实验性): 通过 Qwen CLI [qwen-cli.js](https://gist.github.com/musistudio/f5a67841ced39912fd99e42200d5ca8b) 对 qwen3-coder-plus 的非官方支持。
|
||||
- `rovo-cli` (experimental): 通过 Atlassian Rovo Dev CLI [rovo-cli.js](https://gist.github.com/SaseQ/c2a20a38b11276537ec5332d1f7a5e53) 对 GPT-5 的非官方支持。
|
||||
|
||||
**自定义 Transformer:**
|
||||
|
||||
您还可以创建自己的转换器,并通过 `config.json` 中的 `transformers` 字段加载它们。
|
||||
|
||||
```json
|
||||
{
|
||||
"transformers": [
|
||||
{
|
||||
"path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js",
|
||||
"options": {
|
||||
"project": "xxx"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Router
|
||||
|
||||
`Router` 对象定义了在不同场景下使用哪个模型:
|
||||
|
||||
- `default`: 用于常规任务的默认模型。
|
||||
- `background`: 用于后台任务的模型。这可以是一个较小的本地模型以节省成本。
|
||||
- `think`: 用于推理密集型任务(如计划模式)的模型。
|
||||
- `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。
|
||||
- `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。
|
||||
- `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。
|
||||
- `image`(测试版): 用于处理图片类任务(采用CCR内置的agent支持),如果该模型不支持工具调用,需要将`config.forceUseImageAgent`属性设置为`true`。
|
||||
|
||||
您还可以使用 `/model` 命令在 Claude Code 中动态切换模型:
|
||||
`/model provider_name,model_name`
|
||||
示例: `/model openrouter,anthropic/claude-3.5-sonnet`
|
||||
|
||||
#### 自定义路由器
|
||||
|
||||
对于更高级的路由逻辑,您可以在 `config.json` 中通过 `CUSTOM_ROUTER_PATH` 字段指定一个自定义路由器脚本。这允许您实现超出默认场景的复杂路由规则。
|
||||
|
||||
在您的 `config.json` 中配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"CUSTOM_ROUTER_PATH": "/User/xxx/.claude-code-router/custom-router.js"
|
||||
}
|
||||
```
|
||||
|
||||
自定义路由器文件必须是一个导出 `async` 函数的 JavaScript 模块。该函数接收请求对象和配置对象作为参数,并应返回提供商和模型名称的字符串(例如 `"provider_name,model_name"`),如果返回 `null` 则回退到默认路由。
|
||||
|
||||
这是一个基于 `custom-router.example.js` 的 `custom-router.js` 示例:
|
||||
|
||||
```javascript
|
||||
// /User/xxx/.claude-code-router/custom-router.js
|
||||
|
||||
/**
|
||||
* 一个自定义路由函数,用于根据请求确定使用哪个模型。
|
||||
*
|
||||
* @param {object} req - 来自 Claude Code 的请求对象,包含请求体。
|
||||
* @param {object} config - 应用程序的配置对象。
|
||||
* @returns {Promise<string|null>} - 一个解析为 "provider,model_name" 字符串的 Promise,如果返回 null,则使用默认路由。
|
||||
*/
|
||||
module.exports = async function router(req, config) {
|
||||
const userMessage = req.body.messages.find(m => m.role === 'user')?.content;
|
||||
|
||||
if (userMessage && userMessage.includes('解释这段代码')) {
|
||||
// 为代码解释任务使用更强大的模型
|
||||
return 'openrouter,anthropic/claude-3.5-sonnet';
|
||||
}
|
||||
|
||||
// 回退到默认的路由配置
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
##### 子代理路由
|
||||
|
||||
对于子代理内的路由,您必须在子代理提示词的**开头**包含 `<CCR-SUBAGENT-MODEL>provider,model</CCR-SUBAGENT-MODEL>` 来指定特定的提供商和模型。这样可以将特定的子代理任务定向到指定的模型。
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
<CCR-SUBAGENT-MODEL>openrouter,anthropic/claude-3.5-sonnet</CCR-SUBAGENT-MODEL>
|
||||
请帮我分析这段代码是否存在潜在的优化空间...
|
||||
```
|
||||
|
||||
## Status Line (Beta)
|
||||
为了在运行时更好的查看claude-code-router的状态,claude-code-router在v1.0.40内置了一个statusline工具,你可以在UI中启用它,
|
||||

|
||||
|
||||
效果如下:
|
||||

|
||||
|
||||
## 🤖 GitHub Actions
|
||||
|
||||
将 Claude Code Router 集成到您的 CI/CD 管道中。在设置 [Claude Code Actions](https://docs.anthropic.com/en/docs/claude-code/github-actions) 后,修改您的 `.github/workflows/claude.yaml` 以使用路由器:
|
||||
|
||||
```yaml
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# ... other triggers
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
# ... other conditions
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Prepare Environment
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
mkdir -p $HOME/.claude-code-router
|
||||
cat << 'EOF' > $HOME/.claude-code-router/config.json
|
||||
{
|
||||
"log": true,
|
||||
"NON_INTERACTIVE_MODE": true,
|
||||
"OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}",
|
||||
"OPENAI_BASE_URL": "https://api.deepseek.com",
|
||||
"OPENAI_MODEL": "deepseek-chat"
|
||||
}
|
||||
EOF
|
||||
shell: bash
|
||||
|
||||
- name: Start Claude Code Router
|
||||
run: |
|
||||
nohup ~/.bun/bin/bunx @musistudio/claude-code-router@1.0.8 start &
|
||||
shell: bash
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: http://localhost:3456
|
||||
with:
|
||||
anthropic_api_key: "any-string-is-ok"
|
||||
```
|
||||
|
||||
这种设置可以实现有趣的自动化,例如在非高峰时段运行任务以降低 API 成本。
|
||||
|
||||
## 📝 深入阅读
|
||||
|
||||
- [项目动机和工作原理](blog/zh/项目初衷及原理.md)
|
||||
- [也许我们可以用路由器做更多事情](blog/zh/或许我们能在Router中做更多事情.md)
|
||||
|
||||
## ❤️ 支持与赞助
|
||||
|
||||
如果您觉得这个项目有帮助,请考虑赞助它的开发。非常感谢您的支持!
|
||||
|
||||
[](https://ko-fi.com/F1F31GN2GM)
|
||||
|
||||
[Paypal](https://paypal.me/musistudio1999)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
|
||||
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 我们的赞助商
|
||||
|
||||
非常感谢所有赞助商的慷慨支持!
|
||||
|
||||
- [AIHubmix](https://aihubmix.com/)
|
||||
- [BurnCloud](https://ai.burncloud.com)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
- @*o
|
||||
- [@ceilwoo](https://github.com/ceilwoo)
|
||||
- @*说
|
||||
- @*更
|
||||
- @K*g
|
||||
- @R*R
|
||||
- [@bobleer](https://github.com/bobleer)
|
||||
- @*苗
|
||||
- @*划
|
||||
- [@Clarence-pan](https://github.com/Clarence-pan)
|
||||
- [@carter003](https://github.com/carter003)
|
||||
- @S*r
|
||||
- @*晖
|
||||
- @*敏
|
||||
- @Z*z
|
||||
- @*然
|
||||
- [@cluic](https://github.com/cluic)
|
||||
- @*苗
|
||||
- [@PromptExpert](https://github.com/PromptExpert)
|
||||
- @*应
|
||||
- [@yusnake](https://github.com/yusnake)
|
||||
- @*飞
|
||||
- @董*
|
||||
- @*汀
|
||||
- @*涯
|
||||
- @*:-)
|
||||
- @**磊
|
||||
- @*琢
|
||||
- @*成
|
||||
- @Z*o
|
||||
- @\*琨
|
||||
- [@congzhangzh](https://github.com/congzhangzh)
|
||||
- @*_
|
||||
- @Z\*m
|
||||
- @*鑫
|
||||
- @c\*y
|
||||
- @\*昕
|
||||
- [@witsice](https://github.com/witsice)
|
||||
- @b\*g
|
||||
- @\*亿
|
||||
- @\*辉
|
||||
- @JACK
|
||||
- @\*光
|
||||
- @W\*l
|
||||
- [@kesku](https://github.com/kesku)
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
- @\*咸
|
||||
- @\*明
|
||||
- @S\*y
|
||||
- @f\*o
|
||||
- @\*智
|
||||
- @F\*t
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
- @\*政
|
||||
- @\*铭
|
||||
- @\*叶
|
||||
- @七\*o
|
||||
- @\*青
|
||||
- @\*\*晨
|
||||
- @\*远
|
||||
- @\*霄
|
||||
- @\*\*吉
|
||||
- @\*\*飞
|
||||
|
||||
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)
|
||||
|
||||
|
||||
## 交流群
|
||||
<img src="/blog/images/wechat_group.jpg" width="200" alt="wechat_group" />
|
||||
|
After Width: | Height: | Size: 992 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -1,105 +0,0 @@
|
||||
# Maybe We Can Do More with the Router
|
||||
|
||||
Since the release of `claude-code-router`, I’ve received a lot of user feedback, and quite a few issues are still open. Most of them are related to support for different providers and the lack of tool usage from the deepseek model.
|
||||
|
||||
Originally, I created this project for personal use, mainly to access claude code at a lower cost. So, multi-provider support wasn’t part of the initial design. But during troubleshooting, I discovered that even though most providers claim to be compatible with the OpenAI-style `/chat/completions` interface, there are many subtle differences. For example:
|
||||
|
||||
1. When Gemini's tool parameter type is string, the `format` field only supports `date` and `date-time`, and there’s no tool call ID.
|
||||
|
||||
2. OpenRouter requires `cache_control` for caching.
|
||||
|
||||
3. The official DeepSeek API has a `max_output` of 8192, but Volcano Engine’s limit is even higher.
|
||||
|
||||
Aside from these, smaller providers often have quirks in their parameter handling. So I decided to create a new project, [musistudio/llms](https://github.com/musistudio/llms), to deal with these compatibility issues. It uses the OpenAI format as a base and introduces a generic Transformer interface for transforming both requests and responses.
|
||||
|
||||
Once a `Transformer` is implemented for each provider, it becomes possible to mix-and-match requests between them. For example, I implemented bidirectional conversion between Anthropic and OpenAI formats in `AnthropicTransformer`, which listens to the `/v1/messages` endpoint. Similarly, `GeminiTransformer` handles Gemini <-> OpenAI format conversions and listens to `/v1beta/models/:modelAndAction`.
|
||||
|
||||
When both requests and responses are transformed into a common format, they can interoperate seamlessly:
|
||||
|
||||
```
|
||||
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
|
||||
```
|
||||
|
||||
```
|
||||
GeminiResponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
|
||||
```
|
||||
|
||||
Using a middleware layer to smooth out differences may introduce some performance overhead, but the main goal here is to enable `claude-code-router` to support multiple providers.
|
||||
|
||||
As for the issue of DeepSeek’s lackluster tool usage — I found that it stems from poor instruction adherence in long conversations. Initially, the model actively calls tools, but after several rounds, it starts responding with plain text instead. My first workaround was injecting a system prompt to remind the model to use tools proactively. But in long contexts, the model tends to forget this instruction.
|
||||
|
||||
After reading the DeepSeek documentation, I noticed it supports the `tool_choice` parameter, which can be set to `"required"` to force the model to use at least one tool. I tested this by enabling the parameter, and it significantly improved the model’s tool usage. We can remove the setting when it's no longer necessary. With the help of the `Transformer` interface in [musistudio/llms](https://github.com/musistudio/llms), we can modify the request before it’s sent and adjust the response after it’s received.
|
||||
|
||||
Inspired by the Plan Mode in `claude code`, I implemented a similar Tool Mode for DeepSeek:
|
||||
|
||||
```typescript
|
||||
export class TooluseTransformer implements Transformer {
|
||||
name = "tooluse";
|
||||
|
||||
transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
|
||||
if (request.tools?.length) {
|
||||
request.messages.push({
|
||||
role: "system",
|
||||
content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task.
|
||||
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
|
||||
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
|
||||
});
|
||||
request.tool_choice = "required";
|
||||
request.tools.unshift({
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ExitTool",
|
||||
description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
|
||||
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
|
||||
Examples:
|
||||
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
|
||||
2. Task: "What’s the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
response: {
|
||||
type: "string",
|
||||
description:
|
||||
"Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
|
||||
},
|
||||
},
|
||||
required: ["response"],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
if (
|
||||
jsonResponse?.choices[0]?.message.tool_calls?.length &&
|
||||
jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
|
||||
"ExitTool"
|
||||
) {
|
||||
const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
|
||||
jsonResponse.choices[0].message.content = toolArguments.response || "";
|
||||
delete jsonResponse.choices[0].message.tool_calls;
|
||||
}
|
||||
|
||||
// Handle non-streaming response if needed
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
// ...
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This transformer ensures the model calls at least one tool. If no tools are appropriate or the task is finished, it can exit using `ExitTool`. Since this relies on the `tool_choice` parameter, it only works with models that support it.
|
||||
|
||||
In practice, this approach noticeably improves tool usage for DeepSeek. The tradeoff is that sometimes the model may invoke irrelevant or unnecessary tools, which could increase latency and token usage.
|
||||
|
||||
This update is just a small experiment — adding an `“agent”` to the router. Maybe there are more interesting things we can explore from here.
|
||||
@@ -1,103 +0,0 @@
|
||||
# Project Motivation and Principles
|
||||
|
||||
As early as the day after Claude Code was released (2025-02-25), I began and completed a reverse engineering attempt of the project. At that time, using Claude Code required registering for an Anthropic account, applying for a waitlist, and waiting for approval. However, due to well-known reasons, Anthropic blocks users from mainland China, making it impossible for me to use the service through normal means. Based on known information, I discovered the following:
|
||||
|
||||
1. Claude Code is installed via npm, so it's very likely developed with Node.js.
|
||||
2. Node.js offers various debugging methods: simple `console.log` usage, launching with `--inspect` to hook into Chrome DevTools, or even debugging obfuscated code using `d8`.
|
||||
|
||||
My goal was to use Claude Code without an Anthropic account. I didn’t need the full source code—just a way to intercept and reroute requests made by Claude Code to Anthropic’s models to my own custom endpoint. So I started the reverse engineering process:
|
||||
|
||||
1. First, install Claude Code:
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
2. After installation, the project is located at `~/.nvm/versions/node/v20.10.0/lib/node_modules/@anthropic-ai/claude-code`(this may vary depending on your Node version manager and version).
|
||||
|
||||
3. Open the package.json to analyze the entry point:
|
||||
```package.json
|
||||
{
|
||||
"name": "@anthropic-ai/claude-code",
|
||||
"version": "1.0.24",
|
||||
"main": "sdk.mjs",
|
||||
"types": "sdk.d.ts",
|
||||
"bin": {
|
||||
"claude": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"author": "Boris Cherny <boris@anthropic.com>",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"description": "Use Claude, Anthropic's AI assistant, right from your terminal. Claude can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
|
||||
"homepage": "https://github.com/anthropics/claude-code",
|
||||
"bugs": {
|
||||
"url": "https://github.com/anthropics/claude-code/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "node -e \"if (!process.env.AUTHORIZED) { console.error('ERROR: Direct publishing is not allowed.\\nPlease use the publish-external.sh script to publish this package.'); process.exit(1); }\"",
|
||||
"preinstall": "node scripts/preinstall.js"
|
||||
},
|
||||
"dependencies": {},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "^0.33.5",
|
||||
"@img/sharp-darwin-x64": "^0.33.5",
|
||||
"@img/sharp-linux-arm": "^0.33.5",
|
||||
"@img/sharp-linux-arm64": "^0.33.5",
|
||||
"@img/sharp-linux-x64": "^0.33.5",
|
||||
"@img/sharp-win32-x64": "^0.33.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key entry is `"claude": "cli.js"`. Opening cli.js, you'll see the code is minified and obfuscated. But using WebStorm’s `Format File` feature, you can reformat it for better readability:
|
||||

|
||||
|
||||
Now you can begin understanding Claude Code’s internal logic and prompt structure by reading the code. To dig deeper, you can insert console.log statements or launch in debug mode with Chrome DevTools using:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS="--inspect-brk=9229" claude
|
||||
```
|
||||
|
||||
This command starts Claude Code in debug mode and opens port 9229. Visit chrome://inspect/ in Chrome and click inspect to begin debugging:
|
||||

|
||||

|
||||
|
||||
By searching for the keyword api.anthropic.com, you can easily locate where Claude Code makes its API calls. From the surrounding code, it's clear that baseURL can be overridden with the `ANTHROPIC_BASE_URL` environment variable, and `apiKey` and `authToken` can be configured similarly:
|
||||

|
||||
|
||||
So far, we’ve discovered some key information:
|
||||
|
||||
1. Environment variables can override Claude Code's `baseURL` and `apiKey`.
|
||||
|
||||
2. Claude Code adheres to the Anthropic API specification.
|
||||
|
||||
Therefore, we need:
|
||||
1. A service to convert OpenAI API–compatible requests into Anthropic API format.
|
||||
|
||||
2. Set the environment variables before launching Claude Code to redirect requests to this service.
|
||||
|
||||
Thus, `claude-code-router` was born. This project uses `Express.js` to implement the `/v1/messages` endpoint. It leverages middlewares to transform request/response formats and supports request rewriting (useful for prompt tuning per model).
|
||||
|
||||
Back in February, the full DeepSeek model series had poor support for Function Calling, so I initially used `qwen-max`. It worked well—but without KV cache support, it consumed a large number of tokens and couldn’t provide the native `Claude Code` experience.
|
||||
|
||||
So I experimented with a Router-based mode using a lightweight model to dispatch tasks. The architecture included four roles: `router`, `tool`, `think`, and `coder`. Each request passed through a free lightweight model that would decide whether the task involved reasoning, coding, or tool usage. Reasoning and coding tasks looped until a tool was invoked to apply changes. However, the lightweight model lacked the capability to route tasks accurately, and architectural issues prevented it from effectively driving Claude Code.
|
||||
|
||||
Everything changed at the end of May when the official Claude Code was launched, and `DeepSeek-R1` model (released 2025-05-28) added Function Call support. I redesigned the system. With the help of AI pair programming, I fixed earlier request/response transformation issues—especially the handling of models that return JSON instead of Function Call outputs.
|
||||
|
||||
This time, I used the `DeepSeek-V3` model. It performed better than expected: supporting most tool calls, handling task decomposition and stepwise planning, and—most importantly—costing less than one-tenth the price of Claude 3.5 Sonnet.
|
||||
|
||||
The official Claude Code organizes agents differently from the beta version, so I restructured my Router mode to include four roles: the default model, `background`, `think`, and `longContext`.
|
||||
|
||||
- The default model handles general tasks and acts as a fallback.
|
||||
|
||||
- The `background` model manages lightweight background tasks. According to Anthropic, Claude Haiku 3.5 is often used here, so I routed this to a local `ollama` service.
|
||||
|
||||
- The `think` model is responsible for reasoning and planning mode tasks. I use `DeepSeek-R1` here, though it doesn’t support cost control, so `Think` and `UltraThink` behave identically.
|
||||
|
||||
- The `longContext` model handles long-context scenarios. The router uses `tiktoken` to calculate token lengths in real time, and if the context exceeds 32K, it switches to this model to compensate for DeepSeek's long-context limitations.
|
||||
|
||||
This describes the evolution and reasoning behind the project. By cleverly overriding environment variables, we can forward and modify requests without altering Claude Code’s source—allowing us to benefit from official updates while using our own models and custom prompts.
|
||||
|
||||
This project offers a practical approach to running Claude Code under Anthropic’s regional restrictions, balancing `cost`, `performance`, and `customizability`. That said, the official `Max Plan` still offers the best experience if available.
|
||||
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 915 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 353 KiB |
@@ -1,67 +0,0 @@
|
||||
<svg viewBox="0 0 1200 420" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style>
|
||||
.road { stroke: #7aa2ff; stroke-width: 6; fill: none; filter: drop-shadow(0 6px 18px rgba(122,162,255,0.25)); }
|
||||
.dash { stroke: rgba(122,162,255,0.25); stroke-width: 6; fill: none; stroke-dasharray: 2 18; }
|
||||
.node { filter: drop-shadow(0 3px 10px rgba(126,240,193,0.35)); }
|
||||
.node-circle { fill: #7ef0c1; }
|
||||
.node-core { fill: #181b22; stroke: white; stroke-width: 1.5; }
|
||||
.label-bg { fill: rgba(24,27,34,0.8); stroke: rgba(255,255,255,0.12); rx: 12; }
|
||||
.label-text { fill: #e8ecf1; font-weight: 700; font-size: 14px; font-family: Arial, sans-serif; }
|
||||
.label-sub { fill: #9aa6b2; font-weight: 500; font-size: 12px; font-family: Arial, sans-serif; }
|
||||
.spark { fill: none; stroke: #ffd36e; stroke-width: 1.6; stroke-linecap: round; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Background road with dash -->
|
||||
<path class="dash" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
|
||||
|
||||
<!-- Main road -->
|
||||
<path class="road" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
|
||||
|
||||
<!-- New Documentation Node -->
|
||||
<g class="node" transform="translate(200,280)">
|
||||
<circle class="node-circle" r="10"/>
|
||||
<circle class="node-core" r="6"/>
|
||||
</g>
|
||||
|
||||
<!-- New Documentation Label -->
|
||||
<g transform="translate(80,120)">
|
||||
<rect class="label-bg" width="260" height="92"/>
|
||||
<text class="label-text" x="16" y="34">New Documentation</text>
|
||||
<text class="label-sub" x="16" y="58">Clear structure, examples & best practices</text>
|
||||
</g>
|
||||
|
||||
<!-- Plugin Marketplace Node -->
|
||||
<g class="node" transform="translate(640,150)">
|
||||
<circle class="node-circle" r="10"/>
|
||||
<circle class="node-core" r="6"/>
|
||||
</g>
|
||||
|
||||
<!-- Plugin Marketplace Label -->
|
||||
<g transform="translate(560,20)">
|
||||
<rect class="label-bg" width="320" height="100"/>
|
||||
<text class="label-text" x="16" y="34">Plugin Marketplace</text>
|
||||
<text class="label-sub" x="16" y="58">Community submissions, ratings & version constraints</text>
|
||||
</g>
|
||||
|
||||
<!-- One More Thing Node -->
|
||||
<g class="node" transform="translate(1080,255)">
|
||||
<circle class="node-circle" r="10"/>
|
||||
<circle class="node-core" r="6"/>
|
||||
</g>
|
||||
|
||||
<!-- One More Thing Label -->
|
||||
<g transform="translate(940,300)">
|
||||
<rect class="label-bg" width="250" height="86"/>
|
||||
<text class="label-text" x="16" y="34">One More Thing</text>
|
||||
<text class="label-sub" x="16" y="58">🚀 Confidential project · Revealing soon</text>
|
||||
</g>
|
||||
|
||||
<!-- Spark decorations -->
|
||||
<g transform="translate(1125,290)">
|
||||
<path class="spark" d="M0 0 L8 0 M4 -4 L4 4"/>
|
||||
<path class="spark" d="M14 -2 L22 -2 M18 -6 L18 2"/>
|
||||
<path class="spark" d="M-10 6 L-2 6 M-6 2 L-6 10"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 984 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 518 KiB |
|
Before Width: | Height: | Size: 1012 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 237 KiB |
@@ -1,95 +0,0 @@
|
||||
# 或许我们能在 Router 中做更多事情
|
||||
|
||||
自从`claude-code-router`发布以来,我收到了很多用户的反馈,至今还有不少的 issues 未处理。其中大多都是关于不同的供应商的支持和`deepseek`模型调用工具不积极的问题。
|
||||
之前开发这个项目主要是为了我自己能以较低成本使用上`claude code`,所以一开始的设计并没有考虑到多供应商的情况。在实际的排查问题中,我发现尽管市面上所有的供应商几乎都宣称兼容`OpenAI`格式调用,即通过`/chat/compeletions`接口调用,但是其中的细节差异非常多。例如:
|
||||
|
||||
1. Gemini 的工具参数类型是 string 时,`format`参数只支持`date`和`date-time`,并且没有工具调用 ID。
|
||||
|
||||
2. OpenRouter 需要使用`cache_control`进行缓存。
|
||||
|
||||
3. DeepSeek 官方 API 的 `max_output` 为 8192,而火山引擎的会更大。
|
||||
|
||||
除了这些问题之外,还有一些其他的小的供应商,他们或多或少参数都有点问题。于是,我打算开发一个新的项目[musistudio/llms](https://github.com/musistudio/llms)来处理这种不同服务商的兼容问题。该项目使用 OpenAI 格式为基础的通用格式,提供了一个`Transformer`接口,该接口用于处理转换请求和响应。当我们给不同的服务商都实现了`Transformer`后,我们可以实现不同服务商的混合调用。比如我在`AnthropicTransformer`中实现了`Anthropic`<->`OpenAI`格式的互相转换,并监听了`/v1/messages`端点,在`GeminiTransformer`中实现了`Gemini`<->`OpenAI`格式的互相转换,并监听了`/v1beta/models/:modelAndAction`端点,当他们的请求和响应都被转换成一个通用格式的时候,就可以实现他们的互相调用。
|
||||
|
||||
```
|
||||
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
|
||||
```
|
||||
|
||||
```
|
||||
GeminiReseponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
|
||||
```
|
||||
|
||||
虽然使用中间层抹平差异可能会带来一些性能问题,但是该项目最初的目的是为了让`claude-code-router`支持不同的供应商。
|
||||
|
||||
至于`deepseek`模型调用工具不积极的问题,我发现这是由于`deepseek`在长上下文中的指令遵循不佳导致的。现象就是刚开始模型会主动调用工具,但是在经过几轮对话后模型只会返回文本。一开始的解决方案是通过注入一个系统提示词告知模型需要积极去使用工具以解决用户的问题,但是后面测试发现在长上下文中模型会遗忘该指令。
|
||||
查看`deepseek`文档后发现模型支持`tool_choice`参数,可以强制让模型最少调用 1 个工具,我尝试将该值设置为`required`,发现模型调用工具的积极性大大增加,现在我们只需要在合适的时候取消这个参数即可。借助[musistudio/llms](https://github.com/musistudio/llms)的`Transformer`可以让我们在发送请求前和收到响应后做点什么,所以我参考`claude code`的`Plan Mode`,实现了一个使用与`deepseek`的`Tool Mode`
|
||||
|
||||
```typescript
|
||||
export class TooluseTransformer implements Transformer {
|
||||
name = "tooluse";
|
||||
|
||||
transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
|
||||
if (request.tools?.length) {
|
||||
request.messages.push({
|
||||
role: "system",
|
||||
content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task.
|
||||
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
|
||||
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
|
||||
});
|
||||
request.tool_choice = "required";
|
||||
request.tools.unshift({
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ExitTool",
|
||||
description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
|
||||
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
|
||||
Examples:
|
||||
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
|
||||
2. Task: "What’s the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
response: {
|
||||
type: "string",
|
||||
description:
|
||||
"Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
|
||||
},
|
||||
},
|
||||
required: ["response"],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
if (
|
||||
jsonResponse?.choices[0]?.message.tool_calls?.length &&
|
||||
jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
|
||||
"ExitTool"
|
||||
) {
|
||||
const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
|
||||
jsonResponse.choices[0].message.content = toolArguments.response || "";
|
||||
delete jsonResponse.choices[0].message.tool_calls;
|
||||
}
|
||||
|
||||
// Handle non-streaming response if needed
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
// ...
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
该工具将始终让模型至少调用一个工具,如果没有合适的工具或者任务已完成可以调用`ExitTool`来退出工具模式,因为是依靠`tool_choice`参数实现的,所以仅适用于支持该参数的模型。经过测试,该工具能显著增加`deepseek`的工具调用次数,弊端是可能会有跟任务无关或者没有必要的工具调用导致增加任务执行事件和消耗的 `token` 数。
|
||||
|
||||
这次更新仅仅是在 Router 中实现一个`agent`的一次小探索,或许还能做更多其他有趣的事也说不定...
|
||||
@@ -1,96 +0,0 @@
|
||||
# 项目初衷及原理
|
||||
|
||||
早在 Claude Code 发布的第二天(2025-02-25),我就尝试并完成了对该项目的逆向。当时要使用 Claude Code 你需要注册一个 Anthropic 账号,然后申请 waitlist,等待通过后才能使用。但是因为众所周知的原因,Anthropic 屏蔽了中国区的用户,所以通过正常手段我无法使用,通过已知的信息,我发现:
|
||||
|
||||
1. Claude Code 使用 npm 进行安装,所以很大可能其使用 Node.js 进行开发。
|
||||
2. Node.js 调试手段众多,可以简单使用`console.log`获取想要的信息,也可以使用`--inspect`将其接入`Chrome Devtools`,甚至你可以使用`d8`去调试某些加密混淆的代码。
|
||||
|
||||
由于我的目标是让我在没有 Anthropic 账号的情况下使用`Claude Code`,我并不需要获得完整的源代码,只需要将`Claude Code`请求 Anthropic 模型时将其转发到我自定义的接口即可。接下来我就开启了我的逆向过程:
|
||||
|
||||
1. 首先安装`Claude Code`
|
||||
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
2. 安装后该项目被放在了`~/.nvm/versions/node/v20.10.0/lib/node_modules/@anthropic-ai/claude-code`中,因为我使用了`nvm`作为我的 node 版本控制器,当前使用`node-v20.10.0`,所以该路径会因人而异。
|
||||
3. 找到项目路径之后可通过 package.json 分析包入口,内容如下:
|
||||
|
||||
```package.json
|
||||
{
|
||||
"name": "@anthropic-ai/claude-code",
|
||||
"version": "1.0.24",
|
||||
"main": "sdk.mjs",
|
||||
"types": "sdk.d.ts",
|
||||
"bin": {
|
||||
"claude": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"author": "Boris Cherny <boris@anthropic.com>",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"description": "Use Claude, Anthropic's AI assistant, right from your terminal. Claude can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
|
||||
"homepage": "https://github.com/anthropics/claude-code",
|
||||
"bugs": {
|
||||
"url": "https://github.com/anthropics/claude-code/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "node -e \"if (!process.env.AUTHORIZED) { console.error('ERROR: Direct publishing is not allowed.\\nPlease use the publish-external.sh script to publish this package.'); process.exit(1); }\"",
|
||||
"preinstall": "node scripts/preinstall.js"
|
||||
},
|
||||
"dependencies": {},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "^0.33.5",
|
||||
"@img/sharp-darwin-x64": "^0.33.5",
|
||||
"@img/sharp-linux-arm": "^0.33.5",
|
||||
"@img/sharp-linux-arm64": "^0.33.5",
|
||||
"@img/sharp-linux-x64": "^0.33.5",
|
||||
"@img/sharp-win32-x64": "^0.33.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中`"claude": "cli.js"`就是我们要找的入口,打开 cli.js,发现代码被压缩混淆过了。没关系,借助`webstorm`的`Formate File`功能可以重新格式化,让代码变得稍微好看一点。就像这样:
|
||||

|
||||
|
||||
现在,你可以通过阅读部分代码来了解`Claude Code`的内容工具原理与提示词。你也可以在关键地方使用`console.log`来获得更多信息,当然,也可以使用`Chrome Devtools`来进行断点调试,使用以下命令启动`Claude Code`:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS="--inspect-brk=9229" claude
|
||||
```
|
||||
|
||||
该命令会以调试模式启动`Claude Code`,并将调试的端口设置为`9229`。这时候通过 Chrome 访问`chrome://inspect/`即可看到当前的`Claude Code`进程,点击`inspect`即可进行调试。
|
||||

|
||||

|
||||
|
||||
通过搜索关键字符`api.anthropic.com`很容易能找到`Claude Code`用来发请求的地方,根据上下文的查看,很容易发现这里的`baseURL`可以通过环境变量`ANTHROPIC_BASE_URL`进行覆盖,`apiKey`和`authToken`也同理。
|
||||

|
||||
|
||||
到目前为止,我们获得关键信息:
|
||||
|
||||
1. 可以使用环境变量覆盖`Claude Code`的`BaseURL`和`apiKey`的配置
|
||||
|
||||
2. `Claude Code`使用[Anthropic API](https://docs.anthropic.com/en/api/overview)的规范
|
||||
|
||||
所以我们需要:
|
||||
|
||||
1. 实现一个服务用来将`OpenAI API`的规范转换成`Anthropic API`格式。
|
||||
|
||||
2. 启动`Claude Code`之前写入环境变量将`baseURL`指向到该服务。
|
||||
|
||||
于是,`claude-code-router`就诞生了,该项目使用`Express.js`作为 HTTP 服务,实现`/v1/messages`端点,使用`middlewares`处理请求/响应的格式转换以及请求重写功能(可以用来重写 Claude Code 的提示词以针对单个模型进行调优)。
|
||||
在 2 月份由于`DeepSeek`全系列模型对`Function Call`的支持不佳导致无法直接使用`DeepSeek`模型,所以在当时我选择了`qwen-max`模型,一切表现的都很好,但是`qwen-max`不支持`KV Cache`,意味着我要消耗大量的 token,但是却无法获取`Claude Code`原生的体验。
|
||||
所以我又尝试了`Router`模式,即使用一个小模型对任务进行分发,一共分为四个模型:`router`、`tool`、`think`和`coder`,所有的请求先经过一个免费的小模型,由小模型去判断应该是进行思考还是编码还是调用工具,再进行任务的分发,如果是思考和编码任务将会进行循环调用,直到最终使用工具写入或修改文件。但是实践下来发现免费的小模型不足以很好的完成任务的分发,再加上整个 Agnet 的设计存在缺陷,导致并不能很好的驱动`Claude Code`。
|
||||
直到 5 月底,`Claude Code`被正式推出,这时`DeepSeek`全系列模型(R1 于 05-28)均支持`Function Call`,我开始重新设计该项目。在与 AI 的结对编程中我修复了之前的请求和响应转换问题,在某些场景下模型输出 JSON 响应而不是`Function Call`。这次直接使用`DeepSeek-v3`模型,它工作的比我想象中要好:能完成绝大多数工具调用,还支持用步骤规划解决任务,最关键的是`DeepSeek`的价格不到`claude Sonnet 3.5`的十分之一。正式发布的`Claude Code`对 Agent 的组织也不同于测试版,于是在分析了`Claude Code`的请求调用之后,我重新组织了`Router`模式:现在它还是四个模型:默认模型、`background`、`think`和`longContext`。
|
||||
|
||||
- 默认模型作为最终的兜底和日常处理
|
||||
|
||||
- `background`是用来处理一些后台任务,据 Anthropic 官方说主要用`Claude Haiku 3.5`模型去处理一些小任务,如俳句生成和对话摘要,于是我将其路由到了本地的`ollama`服务。
|
||||
|
||||
- `think`模型用于让`Claude Code`进行思考或者在`Plan Mode`下使用,这里我使用的是`DeepSeek-R1`,由于其不支持推理成本控制,所以`Think`和`UltraThink`是一样的逻辑。
|
||||
|
||||
- `longContext`是用于处理长下上文的场景,该项目会对每次请求使用tiktoken实时计算上下文长度,如果上下文大于32K则使用该模型,旨在弥补`DeepSeek`在长上下文处理不佳的情况。
|
||||
|
||||
以上就是该项目的发展历程以及我的一些思考,通过巧妙的使用环境变量覆盖的手段在不修改`Claude Code`源码的情况下完成请求的转发和修改,这就使得在可以得到 Anthropic 更新的同时使用自己的模型,自定义自己的提示词。该项目只是在 Anthropic 封禁中国区用户的情况下使用`Claude Code`并且达到成本和性能平衡的一种手段。如果可以的话,还是官方的Max Plan体验最好。
|
||||
@@ -0,0 +1,20 @@
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyMarketplacePlugins();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
copyTrayRendererHtml();
|
||||
|
||||
await Promise.all([
|
||||
buildMain({ mode }),
|
||||
buildBrowserRenderer({ mode }),
|
||||
buildRenderer({ mode }),
|
||||
buildTrayRenderer({ mode }),
|
||||
buildStyles({ minify: mode === "production" })
|
||||
]);
|
||||
|
||||
console.log(`Built Electron app assets in ${mode} mode.`);
|
||||
@@ -0,0 +1,169 @@
|
||||
import electron from "electron";
|
||||
import esbuild from "esbuild";
|
||||
import { spawn } from "node:child_process";
|
||||
import { watch } from "node:fs";
|
||||
import {
|
||||
binPath,
|
||||
buildStyles,
|
||||
cleanDist,
|
||||
browserRendererHtmlInput,
|
||||
copyAppAssets,
|
||||
copyBrowserRendererHtml,
|
||||
copyMarketplacePlugins,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
createBrowserRendererBuildOptions,
|
||||
createMainBuildOptions,
|
||||
createRendererBuildOptions,
|
||||
createTrayRendererBuildOptions,
|
||||
cssInput,
|
||||
cssOutput,
|
||||
appAssetsInput,
|
||||
projectRoot,
|
||||
rendererHtmlInput,
|
||||
trayRendererHtmlInput,
|
||||
watchPlugin
|
||||
} from "./esbuild.config.mjs";
|
||||
|
||||
let electronProcess = null;
|
||||
let restartTimer = null;
|
||||
let shuttingDown = false;
|
||||
const ready = {
|
||||
browser: false,
|
||||
main: false,
|
||||
renderer: false,
|
||||
tray: false
|
||||
};
|
||||
|
||||
function markReady(name) {
|
||||
if (name === "browser" || name === "main" || name === "renderer" || name === "tray") {
|
||||
ready[name] = true;
|
||||
}
|
||||
if (ready.browser && ready.main && ready.renderer && ready.tray) {
|
||||
scheduleRestart();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRestart() {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer);
|
||||
}
|
||||
restartTimer = setTimeout(restartElectron, 160);
|
||||
}
|
||||
|
||||
function restartElectron() {
|
||||
if (electronProcess) {
|
||||
electronProcess.kill();
|
||||
electronProcess = null;
|
||||
}
|
||||
|
||||
electronProcess = spawn(electron, ["."], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "development"
|
||||
},
|
||||
stdio: "inherit"
|
||||
});
|
||||
}
|
||||
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyMarketplacePlugins();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
copyTrayRendererHtml();
|
||||
await buildStyles({ minify: false });
|
||||
|
||||
const tailwindProcess = spawn(binPath("tailwindcss"), ["-i", cssInput, "-o", cssOutput, "--watch"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, () => {
|
||||
copyRendererHtml();
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, () => {
|
||||
copyBrowserRendererHtml();
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, () => {
|
||||
copyTrayRendererHtml();
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, () => {
|
||||
copyAppAssets();
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
const mainContext = await esbuild.context(
|
||||
createMainBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [watchPlugin("main", markReady)]
|
||||
})
|
||||
);
|
||||
|
||||
const rendererContext = await esbuild.context(
|
||||
createRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("renderer", () => {
|
||||
copyRendererHtml();
|
||||
markReady("renderer");
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const trayRendererContext = await esbuild.context(
|
||||
createTrayRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("tray", () => {
|
||||
copyTrayRendererHtml();
|
||||
markReady("tray");
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const browserRendererContext = await esbuild.context(
|
||||
createBrowserRendererBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("browser", () => {
|
||||
copyBrowserRendererHtml();
|
||||
markReady("browser");
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([mainContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch()]);
|
||||
|
||||
async function shutdown() {
|
||||
shuttingDown = true;
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer);
|
||||
}
|
||||
if (electronProcess) {
|
||||
electronProcess.kill();
|
||||
}
|
||||
tailwindProcess.kill();
|
||||
htmlWatcher.close();
|
||||
browserHtmlWatcher.close();
|
||||
trayHtmlWatcher.close();
|
||||
appAssetsWatcher.close();
|
||||
await Promise.all([mainContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose()]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
@@ -0,0 +1,261 @@
|
||||
import esbuild from "esbuild";
|
||||
import { spawn } from "node:child_process";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { builtinModules } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const projectRoot = path.resolve(__dirname, "..");
|
||||
export const distDir = path.join(projectRoot, "dist");
|
||||
export const mainOutDir = path.join(distDir, "main");
|
||||
export const rendererOutDir = path.join(distDir, "renderer");
|
||||
export const appAssetsDir = path.join(distDir, "assets");
|
||||
export const rendererAssetsDir = path.join(rendererOutDir, "assets");
|
||||
export const marketplacePluginsDir = path.join(distDir, "marketplace", "plugins");
|
||||
export const appAssetsInput = path.join(projectRoot, "assets");
|
||||
export const rendererRoot = path.join(projectRoot, "src", "renderer");
|
||||
export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html");
|
||||
export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html");
|
||||
export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html");
|
||||
export const browserRendererHtmlOutput = path.join(rendererOutDir, "pages", "browser", "index.html");
|
||||
export const trayRendererHtmlInput = path.join(rendererRoot, "pages", "tray", "index.html");
|
||||
export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", "index.html");
|
||||
export const cssInput = path.join(rendererRoot, "styles", "globals.css");
|
||||
export const cssOutput = path.join(rendererAssetsDir, "main.css");
|
||||
|
||||
const nodeExternals = [
|
||||
"electron",
|
||||
...builtinModules,
|
||||
...builtinModules.map((moduleName) => `node:${moduleName}`)
|
||||
];
|
||||
|
||||
export function cleanDist() {
|
||||
rmSync(distDir, { force: true, recursive: true });
|
||||
ensureDist();
|
||||
}
|
||||
|
||||
export function ensureDist() {
|
||||
mkdirSync(mainOutDir, { recursive: true });
|
||||
mkdirSync(appAssetsDir, { recursive: true });
|
||||
mkdirSync(marketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(rendererAssetsDir, { recursive: true });
|
||||
mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true });
|
||||
}
|
||||
|
||||
export function copyAppAssets() {
|
||||
ensureDist();
|
||||
if (existsSync(appAssetsInput)) {
|
||||
cpSync(appAssetsInput, appAssetsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function copyRendererHtml() {
|
||||
copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js");
|
||||
}
|
||||
|
||||
export function copyTrayRendererHtml() {
|
||||
copyRendererPageHtml(trayRendererHtmlInput, trayRendererHtmlOutput, "tray.js");
|
||||
}
|
||||
|
||||
export function copyBrowserRendererHtml() {
|
||||
copyRendererPageHtml(browserRendererHtmlInput, browserRendererHtmlOutput, "browser.js");
|
||||
}
|
||||
|
||||
export function copyMarketplacePlugins() {
|
||||
ensureDist();
|
||||
for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) {
|
||||
const source = path.join(projectRoot, "examples", "plugins", filename);
|
||||
const target = path.join(marketplacePluginsDir, filename);
|
||||
if (existsSync(source)) {
|
||||
cpSync(source, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyRendererPageHtml(input, output, scriptName) {
|
||||
ensureDist();
|
||||
const source = readFileSync(input, "utf8");
|
||||
const styleTag = ' <link rel="stylesheet" href="../../assets/main.css" />';
|
||||
const scriptTag = ` <script type="module" src="../../assets/${scriptName}"></script>`;
|
||||
let html = source.includes('<script type="module" src="./main.tsx"></script>')
|
||||
? source.replace(' <script type="module" src="./main.tsx"></script>', scriptTag)
|
||||
: source.replace("</body>", `${scriptTag}\n </body>`);
|
||||
|
||||
if (!html.includes('href="../../assets/main.css"')) {
|
||||
html = html.replace("</head>", `${styleTag}\n </head>`);
|
||||
}
|
||||
|
||||
writeFileSync(output, html, "utf8");
|
||||
}
|
||||
|
||||
export function createMainBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: [
|
||||
path.join(projectRoot, "src", "main", "main.ts"),
|
||||
path.join(projectRoot, "src", "main", "browser-preload.ts"),
|
||||
path.join(projectRoot, "src", "main", "preload.ts")
|
||||
],
|
||||
external: nodeExternals,
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outdir: mainOutDir,
|
||||
platform: "node",
|
||||
plugins,
|
||||
sourcemap: mode !== "production",
|
||||
target: "node22"
|
||||
};
|
||||
}
|
||||
|
||||
export function createRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
assetNames: "assets/[name]-[hash]",
|
||||
bundle: true,
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify(mode)
|
||||
},
|
||||
entryPoints: [path.join(rendererRoot, "pages", "home", "main.tsx")],
|
||||
format: "esm",
|
||||
jsx: "automatic",
|
||||
legalComments: "none",
|
||||
loader: {
|
||||
".gif": "file",
|
||||
".jpg": "file",
|
||||
".jpeg": "file",
|
||||
".png": "file",
|
||||
".svg": "file",
|
||||
".webp": "file"
|
||||
},
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outfile: path.join(rendererAssetsDir, "main.js"),
|
||||
platform: "browser",
|
||||
plugins: [rendererAliasPlugin(), ...plugins],
|
||||
publicPath: "../../assets",
|
||||
sourcemap: mode !== "production",
|
||||
target: "chrome120"
|
||||
};
|
||||
}
|
||||
|
||||
export function createTrayRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
...createRendererBuildOptions({ mode, plugins }),
|
||||
entryPoints: [path.join(rendererRoot, "pages", "tray", "main.tsx")],
|
||||
outfile: path.join(rendererAssetsDir, "tray.js")
|
||||
};
|
||||
}
|
||||
|
||||
export function createBrowserRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
...createRendererBuildOptions({ mode, plugins }),
|
||||
entryPoints: [path.join(rendererRoot, "pages", "browser", "main.tsx")],
|
||||
outfile: path.join(rendererAssetsDir, "browser.js")
|
||||
};
|
||||
}
|
||||
|
||||
export function watchPlugin(name, onEnd) {
|
||||
return {
|
||||
name: `${name}-watch`,
|
||||
setup(build) {
|
||||
build.onEnd((result) => {
|
||||
if (result.errors.length === 0) {
|
||||
onEnd(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildMain(options = {}) {
|
||||
await esbuild.build(createMainBuildOptions(options));
|
||||
}
|
||||
|
||||
export async function buildRenderer(options = {}) {
|
||||
await esbuild.build(createRendererBuildOptions(options));
|
||||
}
|
||||
|
||||
export async function buildTrayRenderer(options = {}) {
|
||||
await esbuild.build(createTrayRendererBuildOptions(options));
|
||||
}
|
||||
|
||||
export async function buildBrowserRenderer(options = {}) {
|
||||
await esbuild.build(createBrowserRendererBuildOptions(options));
|
||||
}
|
||||
|
||||
export async function buildStyles({ minify = false } = {}) {
|
||||
ensureDist();
|
||||
const args = ["-i", cssInput, "-o", cssOutput];
|
||||
if (minify) {
|
||||
args.push("--minify");
|
||||
}
|
||||
await runCommand(binPath("tailwindcss"), args);
|
||||
}
|
||||
|
||||
export function binPath(name) {
|
||||
const extension = process.platform === "win32" ? ".cmd" : "";
|
||||
return path.join(projectRoot, "node_modules", ".bin", `${name}${extension}`);
|
||||
}
|
||||
|
||||
export function runCommand(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
...options
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${path.basename(command)} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rendererAliasPlugin() {
|
||||
return {
|
||||
name: "renderer-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, (args) => {
|
||||
return { path: resolveRendererImport(args.path.slice(2)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRendererImport(importPath) {
|
||||
const basePath = path.resolve(rendererRoot, importPath);
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.jsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.json`,
|
||||
`${basePath}.css`,
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
path.join(basePath, "index.js")
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return basePath;
|
||||
}
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 761 KiB |
@@ -5,7 +5,7 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"css": "src/renderer/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
@@ -14,8 +14,7 @@
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
{
|
||||
"LOG": true,
|
||||
"LOG_LEVEL": "debug",
|
||||
"CLAUDE_PATH": "",
|
||||
"HOST": "0.0.0.0",
|
||||
"PORT": 3456,
|
||||
"APIKEY": "sk-123",
|
||||
"API_TIMEOUT_MS": "600000",
|
||||
"PROXY_URL": "",
|
||||
"transformers": [
|
||||
{
|
||||
"path": "/Users/jinhuilee/.claude-code-router/plugins/gemini-cli.js",
|
||||
"options": {
|
||||
"project": "fuji-tools"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/Users/jinhuilee/.claude-code-router/plugins/qwen-cli.js"
|
||||
}
|
||||
],
|
||||
"Providers": [
|
||||
{
|
||||
"name": "kimi",
|
||||
"api_base_url": "https://api.moonshot.cn/v1/chat/completions",
|
||||
"api_key": "sk-123",
|
||||
"models": [
|
||||
"kimi-k2-0711-preview"
|
||||
],
|
||||
"transformer": {
|
||||
"use": [
|
||||
"openrouter"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "siliconflow",
|
||||
"api_base_url": "https://api.siliconflow.cn/v1/chat/completions",
|
||||
"api_key": "sk-12",
|
||||
"models": [
|
||||
"moonshotai/Kimi-K2-Instruct"
|
||||
],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 16384
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "modelscope",
|
||||
"api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
|
||||
"api_key": "sk-123",
|
||||
"models": [
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
"moonshotai/Kimi-K2-Instruct-0905"
|
||||
],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 65536
|
||||
}
|
||||
],
|
||||
"enhancetool",
|
||||
"streamoptions"
|
||||
],
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
|
||||
"use": [
|
||||
"reasoning"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"StatusLine": {
|
||||
"enabled": true,
|
||||
"currentStyle": "default",
|
||||
"default": {
|
||||
"modules": [
|
||||
{
|
||||
"type": "workDir",
|
||||
"icon": "",
|
||||
"text": "{{workDirName}}",
|
||||
"color": "#00d6e7"
|
||||
},
|
||||
{
|
||||
"type": "gitBranch",
|
||||
"icon": "",
|
||||
"text": "{{gitBranch}}",
|
||||
"color": "#00d6e7"
|
||||
},
|
||||
{
|
||||
"type": "model",
|
||||
"icon": "",
|
||||
"text": "{{model}}",
|
||||
"color": "#00d6e7"
|
||||
},
|
||||
{
|
||||
"type": "usage",
|
||||
"icon": "",
|
||||
"text": "↑{{inputTokens}} ↓{{outputTokens}}",
|
||||
"color": "#00d6e7"
|
||||
}
|
||||
]
|
||||
},
|
||||
"powerline": {
|
||||
"modules": [
|
||||
{
|
||||
"type": "workDir",
|
||||
"icon": "",
|
||||
"text": "{{workDirName}}({{gitBranch}})",
|
||||
"color": "#e7e5f2",
|
||||
"background": "#1f00f4"
|
||||
},
|
||||
{
|
||||
"type": "model",
|
||||
"icon": "",
|
||||
"text": "{{model}}",
|
||||
"color": "#024f0f",
|
||||
"background": "#0abe27"
|
||||
},
|
||||
{
|
||||
"type": "usage",
|
||||
"icon": "",
|
||||
"text": "↑{{inputTokens}} ↓{{outputTokens}}",
|
||||
"color": "#74a003",
|
||||
"background": "#a3e203"
|
||||
},
|
||||
{
|
||||
"type": "script",
|
||||
"icon": "",
|
||||
"text": "",
|
||||
"color": "#000000",
|
||||
"scriptPath": "/Users/jinhuilee/.claude-code-router/statusline/modelscope.js",
|
||||
"background": "#00d6e7"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Router": {
|
||||
"default": "iflow,qwen3-coder-plus",
|
||||
"background": "bigmodelanthropic,glm-4.5-air",
|
||||
"think": "iflow,qwen3-235B-A22B-Thinking-2507",
|
||||
"longContext": "qwen-cli,qwen3-coder-plus",
|
||||
"longContextThreshold": 200000,
|
||||
"webSearch": "gemini-cli,gemini-2.5-flash",
|
||||
"image": "iflow,qwen3-vl-plus"
|
||||
},
|
||||
"CUSTOM_ROUTER_PATH": ""
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = async function router(req, config) {
|
||||
return "deepseek,deepseek-chat";
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
claude-code-router:
|
||||
build: .
|
||||
ports:
|
||||
- "3456:3456"
|
||||
volumes:
|
||||
- ~/.claude-code-router:/root/.claude-code-router
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"appId": "com.claudecoderouter.desktop",
|
||||
"productName": "Claude Code Router",
|
||||
"asar": true,
|
||||
"npmRebuild": false,
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Claude Code Router Provider Import",
|
||||
"schemes": ["ccr"]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"icon": "build/icon.icns",
|
||||
"target": ["dmg"],
|
||||
"artifactName": "${productName}_${version}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}_${version}.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage"],
|
||||
"artifactName": "${productName}_${version}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowElevation": true,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"runAfterFinish": true,
|
||||
"shortcutName": "Claude Code Router"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
"mode": "transparent",
|
||||
"port": 7890,
|
||||
"targets": []
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": "claude-design",
|
||||
"enabled": true,
|
||||
"module": "./plugins/claude-design-plugin.cjs",
|
||||
"config": {
|
||||
"host": "claude.ai",
|
||||
"fallbackHosts": ["claude.com", "www.anthropic.com", "anthropic.com"],
|
||||
"paths": [
|
||||
"/",
|
||||
"/_bootstrap",
|
||||
"/api/bootstrap",
|
||||
"/design/anthropic.omelette.api.v1alpha.OmeletteService",
|
||||
"/design",
|
||||
"/v1/design",
|
||||
"/api",
|
||||
"/organizations",
|
||||
"/cdn-cgi",
|
||||
"/login",
|
||||
"/auth",
|
||||
"/oauth"
|
||||
],
|
||||
"assetProxy": true,
|
||||
"assetAutoUpdate": true,
|
||||
"autoAnswerQuestions": true,
|
||||
"assetDir": "/absolute/path/to/claude-design-assets",
|
||||
"scriptPath": "/design/assets/index-DWa5J5J9.js",
|
||||
"stylePath": "/design/assets/index-DZOB93ZB.css",
|
||||
"upstreamOrigin": "https://claude.ai",
|
||||
// Optional. Missing lazy-loaded assets are fetched from these origins in order.
|
||||
"upstreamOrigins": ["https://claude.ai"],
|
||||
"gatewayUrl": "http://127.0.0.1:3456",
|
||||
// Optional. Required only when CCR has API keys configured.
|
||||
"gatewayApiKey": "",
|
||||
// Optional. Also configurable from the Extensions page. Targets use CCR provider/model selectors.
|
||||
"routing": {
|
||||
"enabled": true,
|
||||
"default": "anthropic-main,claude-sonnet-4-20250514",
|
||||
"rules": [
|
||||
{
|
||||
"id": "design-opus",
|
||||
"name": "Claude Design Opus",
|
||||
"type": "model",
|
||||
"model": "claude-opus-4-8",
|
||||
"target": "openrouter,anthropic/claude-opus-4.1",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "design-long-context",
|
||||
"name": "Long design context",
|
||||
"type": "long-context",
|
||||
"threshold": 180000,
|
||||
"target": "anthropic-main,claude-sonnet-4-20250514",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"me": {
|
||||
"accountUuid": "12345678",
|
||||
"organizationUuid": "87654321",
|
||||
"email": "aa@example.com",
|
||||
"displayName": "aa",
|
||||
"orgName": "aa's Organization"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +1,49 @@
|
||||
{
|
||||
"name": "claude-code-router",
|
||||
"version": "1.0.54",
|
||||
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "3.0.0",
|
||||
"description": "Desktop scaffold for Claude Code Router.",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm -r dev",
|
||||
"clean": "pnpm -r clean",
|
||||
"test": "pnpm -r test",
|
||||
"release": "pnpm build && pnpm -r publish"
|
||||
"dev": "node build/dev.mjs",
|
||||
"build": "npm run build:assets && electron-builder",
|
||||
"build:assets": "node build/build.mjs",
|
||||
"build:app": "npm run build:assets && electron-builder --mac --win",
|
||||
"build:app:mac": "npm run build:assets && electron-builder --mac",
|
||||
"build:app:win": "npm run build:assets && electron-builder --win",
|
||||
"preview": "npm run build:assets && electron .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "file:../../next-ai/gateway",
|
||||
"node-forge": "^1.4.0",
|
||||
"sql.js": "^1.14.1",
|
||||
"undici": "^7.27.2"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"code",
|
||||
"router",
|
||||
"llm",
|
||||
"anthropic"
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"fastify": "^5.4.0",
|
||||
"shx": "^0.4.0",
|
||||
"typescript": "^5.8.2"
|
||||
"styletron-engine-atomic": "^1.6.2",
|
||||
"baseui": "^16.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"styletron-react": "^6.1.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"motion": "^12.40.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^3.8.1",
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"electron": "^42.3.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"esbuild": "^0.27.7",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "$@types/react"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "1.0.54",
|
||||
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
|
||||
"bin": {
|
||||
"ccr": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
"dev": "node scripts/build.js --watch",
|
||||
"clean": "shx rm -rf dist",
|
||||
"test": "echo \"No tests specified\""
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"code",
|
||||
"router",
|
||||
"llm",
|
||||
"anthropic",
|
||||
"cli"
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@claude-code-router/core": "workspace:*",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"commander": "^14.0.1",
|
||||
"find-process": "^2.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"minimist": "^1.2.8",
|
||||
"rotating-file-stream": "^3.2.7",
|
||||
"shell-quote": "^1.8.3",
|
||||
"tiktoken": "^1.0.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"shx": "^0.4.0",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
console.log('Building Claude Code Router CLI...');
|
||||
|
||||
try {
|
||||
// Build the CLI application
|
||||
console.log('Building CLI application...');
|
||||
execSync('esbuild src/cli.ts --bundle --platform=node --outfile=dist/cli.js --minify', { stdio: 'inherit' });
|
||||
|
||||
// Copy the tiktoken WASM file
|
||||
console.log('Copying tiktoken WASM file...');
|
||||
try {
|
||||
execSync('shx cp node_modules/tiktoken/tiktoken_bg.wasm dist/tiktoken_bg.wasm', { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
console.log('Warning: tiktoken WASM file not found, skipping...');
|
||||
}
|
||||
|
||||
// Build the UI
|
||||
console.log('Building UI...');
|
||||
// Check if node_modules exists in ui directory, if not install dependencies
|
||||
if (!fs.existsSync('../../ui/node_modules')) {
|
||||
console.log('Installing UI dependencies...');
|
||||
execSync('cd ../../ui && npm install', { stdio: 'inherit' });
|
||||
}
|
||||
execSync('cd ../../ui && npm run build', { stdio: 'inherit' });
|
||||
|
||||
// Copy the built UI index.html to dist
|
||||
console.log('Copying UI build artifacts...');
|
||||
execSync('shx cp ../../ui/dist/index.html dist/index.html', { stdio: 'inherit' });
|
||||
|
||||
console.log('CLI build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('CLI build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { spawn, exec } from "child_process";
|
||||
import fs, { existsSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import {cleanupPidFile, getServiceInfo, isServiceRunning} from "./utils/processCheck";
|
||||
import {PID_FILE, REFERENCE_COUNT_FILE} from "./constants";
|
||||
import {parseStatusLineData, StatusLineInput} from "./utils/statusline";
|
||||
import {executeCodeCommand} from "./utils/codeCommand";
|
||||
import {backupConfigFile, initDir, writeConfigFile} from "./utils";
|
||||
import {showStatus} from "./utils/status";
|
||||
import {run} from "./index";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
const packageJson = require("../package.json");
|
||||
const version = packageJson.version;
|
||||
|
||||
async function waitForService(
|
||||
timeout = 10000,
|
||||
initialDelay = 1000
|
||||
): Promise<boolean> {
|
||||
// Wait for an initial period to let the service initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, initialDelay));
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const isRunning = await isServiceRunning()
|
||||
if (isRunning) {
|
||||
// Wait for an additional short period to ensure service is fully ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Define commands
|
||||
program
|
||||
.name('ccr')
|
||||
.description('Claude Code Router - Route Claude Code requests to different LLM providers')
|
||||
.version(version);
|
||||
|
||||
program
|
||||
.command('start')
|
||||
.description('Start server')
|
||||
.action(() => {
|
||||
run();
|
||||
});
|
||||
|
||||
program
|
||||
.command('stop')
|
||||
.description('Stop server')
|
||||
.action(async () => {
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, "utf-8"));
|
||||
process.kill(pid);
|
||||
cleanupPidFile();
|
||||
if (existsSync(REFERENCE_COUNT_FILE)) {
|
||||
try {
|
||||
fs.unlinkSync(REFERENCE_COUNT_FILE);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
"claude code router service has been successfully stopped."
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Failed to stop the service. It may have already been stopped."
|
||||
);
|
||||
cleanupPidFile();
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('restart')
|
||||
.description('Restart server')
|
||||
.action(async () => {
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, "utf-8"));
|
||||
process.kill(pid);
|
||||
cleanupPidFile();
|
||||
if (existsSync(REFERENCE_COUNT_FILE)) {
|
||||
try {
|
||||
fs.unlinkSync(REFERENCE_COUNT_FILE);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
console.log("claude code router service has been stopped.");
|
||||
} catch (e) {
|
||||
console.log("Service was not running or failed to stop.");
|
||||
cleanupPidFile();
|
||||
}
|
||||
|
||||
// Start the service again in the background
|
||||
console.log("Starting claude code router service...");
|
||||
const cliPath = join(__dirname, "cli.js");
|
||||
const startProcess = spawn("node", [cliPath, "start"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
startProcess.on("error", (error) => {
|
||||
console.error("Failed to start service:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
startProcess.unref();
|
||||
console.log("✅ Service started successfully in the background.");
|
||||
});
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show server status')
|
||||
.action(async () => {
|
||||
await showStatus();
|
||||
});
|
||||
|
||||
program
|
||||
.command('statusline')
|
||||
.description('Integrated statusline')
|
||||
.action(() => {
|
||||
// 从stdin读取JSON输入
|
||||
let inputData = "";
|
||||
process.stdin.setEncoding("utf-8");
|
||||
process.stdin.on("readable", () => {
|
||||
let chunk;
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
inputData += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on("end", async () => {
|
||||
try {
|
||||
const input: StatusLineInput = JSON.parse(inputData);
|
||||
const statusLine = await parseStatusLineData(input);
|
||||
console.log(statusLine);
|
||||
} catch (error) {
|
||||
console.error("Error parsing status line data:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('code')
|
||||
.description('Execute claude command')
|
||||
.argument('[prompt...]', 'command to execute')
|
||||
.action(async (prompt) => {
|
||||
const isRunning = await isServiceRunning();
|
||||
if (!isRunning) {
|
||||
console.log("Service not running, starting service...");
|
||||
const cliPath = join(__dirname, "cli.js");
|
||||
const startProcess = spawn("node", [cliPath, "start"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
startProcess.on("error", (error) => {
|
||||
console.error("Failed to start service:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
startProcess.unref();
|
||||
|
||||
if (await waitForService()) {
|
||||
// Join all code arguments into a single string to preserve spaces within quotes
|
||||
const codeArgs = prompt ? prompt.join(' ') : '';
|
||||
executeCodeCommand([codeArgs]);
|
||||
} else {
|
||||
console.error(
|
||||
"Service startup timeout, please manually run `ccr start` to start the service"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Join all code arguments into a single string to preserve spaces within quotes
|
||||
const codeArgs = prompt ? prompt.join(' ') : '';
|
||||
executeCodeCommand([codeArgs]);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('ui')
|
||||
.description('Open the web UI in browser')
|
||||
.action(async () => {
|
||||
const isRunning = await isServiceRunning();
|
||||
|
||||
// Check if service is running
|
||||
if (!isRunning) {
|
||||
console.log("Service not running, starting service...");
|
||||
const cliPath = join(__dirname, "cli.js");
|
||||
const startProcess = spawn("node", [cliPath, "start"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
startProcess.on("error", (error) => {
|
||||
console.error("Failed to start service:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
startProcess.unref();
|
||||
|
||||
if (!(await waitForService())) {
|
||||
// If service startup fails, try to start with default config
|
||||
console.log(
|
||||
"Service startup timeout, trying to start with default configuration..."
|
||||
);
|
||||
// 使用已导入的函数
|
||||
|
||||
try {
|
||||
// Initialize directories
|
||||
await initDir();
|
||||
|
||||
// Backup existing config file if it exists
|
||||
const backupPath = await backupConfigFile();
|
||||
if (backupPath) {
|
||||
console.log(
|
||||
`Backed up existing configuration file to ${backupPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create a minimal default config file
|
||||
await writeConfigFile({
|
||||
PORT: 3456,
|
||||
Providers: [],
|
||||
Router: {},
|
||||
});
|
||||
console.log(
|
||||
"Created minimal default configuration file at ~/.claude-code-router/config.json"
|
||||
);
|
||||
console.log(
|
||||
"Please edit this file with your actual configuration."
|
||||
);
|
||||
|
||||
// Try starting the service again
|
||||
const restartProcess = spawn("node", [cliPath, "start"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
restartProcess.on("error", (error) => {
|
||||
console.error(
|
||||
"Failed to start service with default config:",
|
||||
error.message
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
restartProcess.unref();
|
||||
|
||||
if (!(await waitForService(15000))) {
|
||||
// Wait a bit longer for the first start
|
||||
console.error(
|
||||
"Service startup still failing. Please manually run `ccr start` to start the service and check the logs."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Failed to create default configuration:",
|
||||
error.message
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get service info and open UI
|
||||
const serviceInfo = await getServiceInfo();
|
||||
|
||||
// Add temporary API key as URL parameter if successfully generated
|
||||
const uiUrl = `${serviceInfo.endpoint}/ui/`;
|
||||
|
||||
console.log(`Opening UI at ${uiUrl}`);
|
||||
|
||||
// Open URL in browser based on platform
|
||||
const platform = process.platform;
|
||||
let openCommand = "";
|
||||
|
||||
if (platform === "win32") {
|
||||
// Windows
|
||||
openCommand = `start ${uiUrl}`;
|
||||
} else if (platform === "darwin") {
|
||||
// macOS
|
||||
openCommand = `open ${uiUrl}`;
|
||||
} else if (platform === "linux") {
|
||||
// Linux
|
||||
openCommand = `xdg-open ${uiUrl}`;
|
||||
} else {
|
||||
console.error("Unsupported platform for opening browser");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
exec(openCommand, (error) => {
|
||||
if (error) {
|
||||
console.error("Failed to open browser:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
program.parse();
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Configuration schema for Claude Code Router
|
||||
*/
|
||||
export interface ConfigSchema {
|
||||
/**
|
||||
* Enable logging
|
||||
* @default true
|
||||
*/
|
||||
LOG?: boolean;
|
||||
|
||||
/**
|
||||
* Log level for debugging
|
||||
* @default "debug"
|
||||
*/
|
||||
LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
/**
|
||||
* Path to Claude Code executable
|
||||
* @default ""
|
||||
*/
|
||||
CLAUDE_PATH?: string;
|
||||
|
||||
/**
|
||||
* Host address for the server
|
||||
* @default "0.0.0.0"
|
||||
*/
|
||||
HOST?: string;
|
||||
|
||||
/**
|
||||
* Port number for the server
|
||||
* @default 3456
|
||||
*/
|
||||
PORT?: number;
|
||||
|
||||
/**
|
||||
* API key for authentication
|
||||
* @default "sk-123"
|
||||
*/
|
||||
APIKEY?: string;
|
||||
|
||||
/**
|
||||
* API timeout in milliseconds
|
||||
* @default "600000"
|
||||
*/
|
||||
API_TIMEOUT_MS?: string | number;
|
||||
|
||||
/**
|
||||
* Proxy URL if needed
|
||||
* @default ""
|
||||
*/
|
||||
PROXY_URL?: string;
|
||||
|
||||
/**
|
||||
* Transformers configuration
|
||||
*/
|
||||
transformers?: Array<{
|
||||
/**
|
||||
* Path to the transformer file
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* Options for the transformer
|
||||
*/
|
||||
options?: Record<string, any>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Providers configuration
|
||||
*/
|
||||
Providers?: Array<{
|
||||
/**
|
||||
* Name of the provider
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* API base URL for the provider
|
||||
*/
|
||||
api_base_url: string;
|
||||
|
||||
/**
|
||||
* API key for the provider
|
||||
*/
|
||||
api_key: string;
|
||||
|
||||
/**
|
||||
* List of supported models
|
||||
*/
|
||||
models: string[];
|
||||
|
||||
/**
|
||||
* Transformer configuration for the provider
|
||||
*/
|
||||
transformer?: {
|
||||
/**
|
||||
* List of transformers to use
|
||||
*/
|
||||
use?: Array<string | [string, Record<string, any>]>;
|
||||
|
||||
/**
|
||||
* Model-specific transformer configuration
|
||||
*/
|
||||
[model: string]: {
|
||||
use?: Array<string | [string, Record<string, any>]>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Status line configuration
|
||||
*/
|
||||
StatusLine?: {
|
||||
/**
|
||||
* Whether status line is enabled
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* Current style of the status line
|
||||
* @default "default"
|
||||
*/
|
||||
currentStyle?: string;
|
||||
|
||||
/**
|
||||
* Style definitions
|
||||
*/
|
||||
[styleName: string]: {
|
||||
modules: Array<{
|
||||
type: 'workDir' | 'gitBranch' | 'model' | 'usage' | 'script';
|
||||
icon: string;
|
||||
text: string;
|
||||
color: string;
|
||||
background?: string;
|
||||
scriptPath?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Router configuration
|
||||
*/
|
||||
Router?: {
|
||||
/**
|
||||
* Default routing rule
|
||||
* @default "iflow,qwen3-coder-plus"
|
||||
*/
|
||||
default?: string;
|
||||
|
||||
/**
|
||||
* Background task routing rule
|
||||
* @default "bigmodelanthropic,glm-4.5-air"
|
||||
*/
|
||||
background?: string;
|
||||
|
||||
/**
|
||||
* Thinking/analysis task routing rule
|
||||
* @default "iflow,qwen3-235B-A22B-Thinking-2507"
|
||||
*/
|
||||
think?: string;
|
||||
|
||||
/**
|
||||
* Long context task routing rule
|
||||
* @default "qwen-cli,qwen3-coder-plus"
|
||||
*/
|
||||
longContext?: string;
|
||||
|
||||
/**
|
||||
* Threshold for long context in characters
|
||||
* @default 200000
|
||||
*/
|
||||
longContextThreshold?: number;
|
||||
|
||||
/**
|
||||
* Web search task routing rule
|
||||
* @default "gemini-cli,gemini-2.5-flash"
|
||||
*/
|
||||
webSearch?: string;
|
||||
|
||||
/**
|
||||
* Image processing task routing rule
|
||||
* @default "iflow,qwen3-vl-plus"
|
||||
*/
|
||||
image?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom router file path
|
||||
* @default ""
|
||||
*/
|
||||
CUSTOM_ROUTER_PATH?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
export const DEFAULT_CONFIG: ConfigSchema = {
|
||||
LOG: true,
|
||||
LOG_LEVEL: 'debug',
|
||||
CLAUDE_PATH: '',
|
||||
HOST: '0.0.0.0',
|
||||
PORT: 3456,
|
||||
APIKEY: 'sk-123',
|
||||
API_TIMEOUT_MS: '600000',
|
||||
PROXY_URL: '',
|
||||
transformers: [],
|
||||
Providers: [],
|
||||
StatusLine: {
|
||||
enabled: true,
|
||||
currentStyle: 'default'
|
||||
},
|
||||
Router: {
|
||||
longContextThreshold: 200000
|
||||
},
|
||||
CUSTOM_ROUTER_PATH: ''
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
export const HOME_DIR = path.join(os.homedir(), ".claude-code-router");
|
||||
|
||||
export const CONFIG_FILE = path.join(HOME_DIR, "config.json");
|
||||
|
||||
export const PLUGINS_DIR = path.join(HOME_DIR, "plugins");
|
||||
|
||||
export const PID_FILE = path.join(HOME_DIR, '.claude-code-router.pid');
|
||||
|
||||
export const REFERENCE_COUNT_FILE = path.join(os.tmpdir(), "claude-code-reference-count.txt");
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
LOG: false,
|
||||
OPENAI_API_KEY: "",
|
||||
OPENAI_BASE_URL: "",
|
||||
OPENAI_MODEL: "",
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import { createCliServer } from "./server";
|
||||
import {homedir} from "os";
|
||||
import {join} from "path";
|
||||
import {existsSync} from "fs";
|
||||
import {writeFile} from "fs/promises";
|
||||
import {cleanupPidFile, isServiceRunning, savePid} from "./utils/processCheck";
|
||||
import {HOME_DIR} from "./constants";
|
||||
import {initConfig, initDir} from "./utils";
|
||||
import {cleanupLogFiles} from "./utils/logCleanup";
|
||||
import { createStream } from 'rotating-file-stream';
|
||||
|
||||
async function initializeClaudeConfig() {
|
||||
const homeDir = homedir();
|
||||
const configPath = join(homeDir, ".claude.json");
|
||||
if (!existsSync(configPath)) {
|
||||
const userID = Array.from(
|
||||
{ length: 64 },
|
||||
() => Math.random().toString(16)[2]
|
||||
).join("");
|
||||
const configContent = {
|
||||
autoUpdaterStatus: "enabled",
|
||||
userID,
|
||||
hasCompletedOnboarding: true,
|
||||
projects: {},
|
||||
};
|
||||
await writeFile(configPath, JSON.stringify(configContent, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export async function getServer(options: RunOptions = {}) {
|
||||
await initializeClaudeConfig();
|
||||
await initDir();
|
||||
// Clean up old log files, keeping only the 10 most recent ones
|
||||
await cleanupLogFiles(4);
|
||||
const config = await initConfig();
|
||||
|
||||
let HOST = config.HOST || "127.0.0.1";
|
||||
|
||||
if (config.HOST && !config.APIKEY) {
|
||||
HOST = "127.0.0.1";
|
||||
console.warn("⚠️ API key is not set. HOST is forced to 127.0.0.1.");
|
||||
}
|
||||
|
||||
const port = options.port || config.PORT || 3456;
|
||||
|
||||
// Save the PID of the background process
|
||||
savePid(process.pid);
|
||||
|
||||
// Handle SIGINT (Ctrl+C) to clean up PID file
|
||||
process.on("SIGINT", () => {
|
||||
console.log("Received SIGINT, cleaning up...");
|
||||
cleanupPidFile();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle SIGTERM to clean up PID file
|
||||
process.on("SIGTERM", () => {
|
||||
cleanupPidFile();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Use port from environment variable if set (for background process)
|
||||
const servicePort = process.env.SERVICE_PORT
|
||||
? parseInt(process.env.SERVICE_PORT)
|
||||
: port;
|
||||
|
||||
// Configure logger based on config settings
|
||||
const pad = num => (num > 9 ? "" : "0") + num;
|
||||
const generator = (time, index) => {
|
||||
if (!time) {
|
||||
time = new Date()
|
||||
}
|
||||
|
||||
var month = time.getFullYear() + "" + pad(time.getMonth() + 1);
|
||||
var day = pad(time.getDate());
|
||||
var hour = pad(time.getHours());
|
||||
var minute = pad(time.getMinutes());
|
||||
|
||||
return `./logs/ccr-${month}${day}${hour}${minute}${pad(time.getSeconds())}${index ? `_${index}` : ''}.log`;
|
||||
};
|
||||
const loggerConfig =
|
||||
config.LOG !== false
|
||||
? {
|
||||
level: config.LOG_LEVEL || "debug",
|
||||
stream: createStream(generator, {
|
||||
path: HOME_DIR,
|
||||
maxFiles: 3,
|
||||
interval: "1d",
|
||||
compress: false,
|
||||
maxSize: "50M"
|
||||
}),
|
||||
}
|
||||
: false;
|
||||
|
||||
const server = createCliServer({
|
||||
...config,
|
||||
logger: loggerConfig,
|
||||
providers: config.Providers || config.providers,
|
||||
HOST: HOST,
|
||||
PORT: servicePort,
|
||||
LOG_FILE: join(
|
||||
homedir(),
|
||||
".claude-code-router",
|
||||
"claude-code-router.log"
|
||||
)
|
||||
});
|
||||
|
||||
// Add global error handlers to prevent the service from crashing
|
||||
process.on("uncaughtException", (err) => {
|
||||
server.logger.error("Uncaught exception:", err);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
server.logger.error("Unhandled rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function run(options: RunOptions = {}) {
|
||||
const isRunning = await isServiceRunning()
|
||||
if (isRunning) {
|
||||
console.log("✅ Service is already running in the background.");
|
||||
return;
|
||||
}
|
||||
const server = await getServer(options);
|
||||
server.start();
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
|
||||
export const apiKeyAuth = async (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
config: any
|
||||
) => {
|
||||
// Public endpoints that don't require authentication
|
||||
const whiteList = ["/", "/health", "/ui", "/ui/"];
|
||||
if (whiteList.includes(req.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = config.APIKEY;
|
||||
if (!apiKey) {
|
||||
// If no API key is set, enable CORS for local
|
||||
const allowedOrigins = [
|
||||
`http://127.0.0.1:${config.PORT || 3456}`,
|
||||
`http://localhost:${config.PORT || 3456}`,
|
||||
];
|
||||
if (req.headers.origin && !allowedOrigins.includes(req.headers.origin)) {
|
||||
return reply.status(403).send("CORS not allowed for this origin");
|
||||
} else {
|
||||
reply.header(
|
||||
"Access-Control-Allow-Origin",
|
||||
`http://127.0.0.1:${config.PORT || 3456}`
|
||||
);
|
||||
reply.header(
|
||||
"Access-Control-Allow-Origin",
|
||||
`http://localhost:${config.PORT || 3456}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const authHeaderValue = req.headers.authorization || req.headers["x-api-key"];
|
||||
const authKey: string = Array.isArray(authHeaderValue)
|
||||
? authHeaderValue[0]
|
||||
: authHeaderValue || "";
|
||||
if (!authKey) {
|
||||
reply.status(401).send("APIKEY is missing");
|
||||
return;
|
||||
}
|
||||
let token = "";
|
||||
if (authKey.startsWith("Bearer")) {
|
||||
token = authKey.split(" ")[1];
|
||||
} else {
|
||||
token = authKey;
|
||||
}
|
||||
|
||||
if (token !== apiKey) {
|
||||
return reply.status(401).send("Invalid API key");
|
||||
}
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
import { createServer } from "@claude-code-router/core";
|
||||
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
|
||||
import { checkForUpdates, performUpdate } from "./utils/update";
|
||||
import { join } from "path";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { apiKeyAuth } from "./middleware/auth";
|
||||
|
||||
|
||||
export const createCliServer = (config: any) => {
|
||||
const server = createServer(config);
|
||||
|
||||
// Add endpoint to read config.json with access control
|
||||
server.app.get("/api/config", async (req, reply) => {
|
||||
return await readConfigFile();
|
||||
});
|
||||
|
||||
// Add endpoint to save config.json with access control
|
||||
server.app.post("/api/config", async (req, reply) => {
|
||||
const newConfig = req.body;
|
||||
|
||||
// Backup existing config file if it exists
|
||||
const backupPath = await backupConfigFile();
|
||||
if (backupPath) {
|
||||
console.log(`Backed up existing configuration file to ${backupPath}`);
|
||||
}
|
||||
|
||||
await writeConfigFile(newConfig);
|
||||
return { success: true, message: "Config saved successfully" };
|
||||
});
|
||||
|
||||
// Add endpoint to restart the service with access control
|
||||
server.app.post("/api/restart", async (req, reply) => {
|
||||
reply.send({ success: true, message: "Service restart initiated" });
|
||||
|
||||
// Restart the service after a short delay to allow response to be sent
|
||||
setTimeout(() => {
|
||||
const { spawn } = require("child_process");
|
||||
spawn(process.execPath, [process.argv[1], "restart"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
server.app.get("/api/transformers", async () => {
|
||||
const transformers =
|
||||
server.app._server!.transformerService.getAllTransformers();
|
||||
const transformerList = Array.from(transformers.entries()).map(
|
||||
([name, transformer]: any) => ({
|
||||
name,
|
||||
endpoint: transformer.endPoint || null,
|
||||
})
|
||||
);
|
||||
return { transformers: transformerList };
|
||||
});
|
||||
// 获取日志文件列表端点
|
||||
server.app.get("/api/logs/files", async (req, reply) => {
|
||||
try {
|
||||
const logDir = join(homedir(), ".claude-code-router", "logs");
|
||||
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
|
||||
|
||||
if (existsSync(logDir)) {
|
||||
const files = readdirSync(logDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.log')) {
|
||||
const filePath = join(logDir, file);
|
||||
const stats = statSync(filePath);
|
||||
|
||||
logFiles.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime.toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按修改时间倒序排列
|
||||
logFiles.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
}
|
||||
|
||||
return logFiles;
|
||||
} catch (error) {
|
||||
console.error("Failed to get log files:", error);
|
||||
reply.status(500).send({ error: "Failed to get log files" });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取日志内容端点
|
||||
server.app.get("/api/logs", async (req, reply) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
|
||||
if (filePath) {
|
||||
// 如果指定了文件路径,使用指定的路径
|
||||
logFilePath = filePath;
|
||||
} else {
|
||||
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||
}
|
||||
|
||||
if (!existsSync(logFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const logContent = readFileSync(logFilePath, 'utf8');
|
||||
const logLines = logContent.split('\n').filter(line => line.trim())
|
||||
|
||||
return logLines;
|
||||
} catch (error) {
|
||||
console.error("Failed to get logs:", error);
|
||||
reply.status(500).send({ error: "Failed to get logs" });
|
||||
}
|
||||
});
|
||||
|
||||
// 清除日志内容端点
|
||||
server.app.delete("/api/logs", async (req, reply) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
|
||||
if (filePath) {
|
||||
// 如果指定了文件路径,使用指定的路径
|
||||
logFilePath = filePath;
|
||||
} else {
|
||||
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||
}
|
||||
|
||||
if (existsSync(logFilePath)) {
|
||||
writeFileSync(logFilePath, '', 'utf8');
|
||||
}
|
||||
|
||||
return { success: true, message: "Logs cleared successfully" };
|
||||
} catch (error) {
|
||||
console.error("Failed to clear logs:", error);
|
||||
reply.status(500).send({ error: "Failed to clear logs" });
|
||||
}
|
||||
});
|
||||
|
||||
// Add async preHandler hook for authentication
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
await apiKeyAuth(req, reply, config)
|
||||
});
|
||||
|
||||
// 版本检查端点
|
||||
server.app.get("/api/update/check", async (req, reply) => {
|
||||
try {
|
||||
// 获取当前版本
|
||||
const currentVersion = require("../package.json").version;
|
||||
const { hasUpdate, latestVersion, changelog } = await checkForUpdates(currentVersion);
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
latestVersion: hasUpdate ? latestVersion : undefined,
|
||||
changelog: hasUpdate ? changelog : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
reply.status(500).send({ error: "Failed to check for updates" });
|
||||
}
|
||||
});
|
||||
|
||||
// 执行更新端点
|
||||
server.app.post("/api/update/perform", async (req, reply) => {
|
||||
try {
|
||||
// 只允许完全访问权限的用户执行更新
|
||||
const accessLevel = (req as any).accessLevel || "restricted";
|
||||
if (accessLevel !== "full") {
|
||||
reply.status(403).send("Full access required to perform updates");
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行更新逻辑
|
||||
const result = await performUpdate();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to perform update:", error);
|
||||
reply.status(500).send({ error: "Failed to perform update" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Register static file serving with caching
|
||||
server.app.register(fastifyStatic, {
|
||||
root: join(__dirname, "..", "dist"),
|
||||
prefix: "/ui/",
|
||||
maxAge: "1h",
|
||||
});
|
||||
|
||||
// Redirect /ui to /ui/ for proper static file serving
|
||||
server.app.get("/ui", async (_, reply) => {
|
||||
return reply.redirect("/ui/");
|
||||
});
|
||||
|
||||
|
||||
return server;
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { isServiceRunning, cleanupPidFile, getReferenceCount } from './processCheck';
|
||||
import { readFileSync } from 'fs';
|
||||
import { HOME_DIR } from '../constants';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function closeService() {
|
||||
const PID_FILE = join(HOME_DIR, '.claude-code-router.pid');
|
||||
const isRunning = await isServiceRunning()
|
||||
|
||||
if (!isRunning) {
|
||||
console.log("No service is currently running.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (getReferenceCount() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
|
||||
process.kill(pid);
|
||||
cleanupPidFile();
|
||||
console.log("claude code router service has been successfully stopped.");
|
||||
} catch (e) {
|
||||
console.log("Failed to stop the service. It may have already been stopped.");
|
||||
cleanupPidFile();
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { spawn, type StdioOptions } from "child_process";
|
||||
import { readConfigFile } from "./index";
|
||||
import { closeService } from "./close";
|
||||
import {
|
||||
decrementReferenceCount,
|
||||
incrementReferenceCount,
|
||||
} from "./processCheck";
|
||||
import { quote } from 'shell-quote';
|
||||
import minimist from "minimist";
|
||||
|
||||
|
||||
export async function executeCodeCommand(args: string[] = []) {
|
||||
// Set environment variables
|
||||
const config = await readConfigFile();
|
||||
const port = config.PORT || 3456;
|
||||
const env: Record<string, string> = {
|
||||
ANTHROPIC_AUTH_TOKEN: config?.APIKEY || "test",
|
||||
ANTHROPIC_API_KEY: '',
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
NO_PROXY: `127.0.0.1`,
|
||||
DISABLE_TELEMETRY: 'true',
|
||||
DISABLE_COST_WARNINGS: 'true',
|
||||
API_TIMEOUT_MS: String(config.API_TIMEOUT_MS ?? 600000), // Default to 10 minutes if not set
|
||||
};
|
||||
const settingsFlag = {
|
||||
env
|
||||
};
|
||||
if (config?.StatusLine?.enabled) {
|
||||
settingsFlag.statusLine = {
|
||||
type: "command",
|
||||
command: "ccr statusline",
|
||||
padding: 0,
|
||||
}
|
||||
}
|
||||
args.push('--settings', `${JSON.stringify(settingsFlag)}`);
|
||||
|
||||
// Non-interactive mode for automation environments
|
||||
if (config.NON_INTERACTIVE_MODE) {
|
||||
env.CI = "true";
|
||||
env.FORCE_COLOR = "0";
|
||||
env.NODE_NO_READLINE = "1";
|
||||
env.TERM = "dumb";
|
||||
}
|
||||
|
||||
// Set ANTHROPIC_SMALL_FAST_MODEL if it exists in config
|
||||
if (config?.ANTHROPIC_SMALL_FAST_MODEL) {
|
||||
env.ANTHROPIC_SMALL_FAST_MODEL = config.ANTHROPIC_SMALL_FAST_MODEL;
|
||||
}
|
||||
|
||||
// Increment reference count when command starts
|
||||
incrementReferenceCount();
|
||||
|
||||
// Execute claude command
|
||||
const claudePath = config?.CLAUDE_PATH || process.env.CLAUDE_PATH || "claude";
|
||||
|
||||
const joinedArgs = args.length > 0 ? quote(args) : "";
|
||||
|
||||
const stdioConfig: StdioOptions = config.NON_INTERACTIVE_MODE
|
||||
? ["pipe", "inherit", "inherit"] // Pipe stdin for non-interactive
|
||||
: "inherit"; // Default inherited behavior
|
||||
|
||||
const argsObj = minimist(args)
|
||||
const argsArr = []
|
||||
for (const [argsObjKey, argsObjValue] of Object.entries(argsObj)) {
|
||||
if (argsObjKey !== '_' && argsObj[argsObjKey]) {
|
||||
argsArr.push(`${argsObjKey.length === 1 ? '-' : '--'}${argsObjKey} ${JSON.stringify(argsObjValue)}`);
|
||||
}
|
||||
}
|
||||
const claudeProcess = spawn(
|
||||
claudePath,
|
||||
argsArr,
|
||||
{
|
||||
env: process.env,
|
||||
stdio: stdioConfig,
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Close stdin for non-interactive mode
|
||||
if (config.NON_INTERACTIVE_MODE) {
|
||||
claudeProcess.stdin?.end();
|
||||
}
|
||||
|
||||
claudeProcess.on("error", (error) => {
|
||||
console.error("Failed to start claude command:", error.message);
|
||||
console.log(
|
||||
"Make sure Claude Code is installed: npm install -g @anthropic-ai/claude-code"
|
||||
);
|
||||
decrementReferenceCount();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
claudeProcess.on("close", (code) => {
|
||||
decrementReferenceCount();
|
||||
closeService();
|
||||
process.exit(code || 0);
|
||||
});
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import readline from "node:readline";
|
||||
import JSON5 from "json5";
|
||||
import path from "node:path";
|
||||
import {
|
||||
CONFIG_FILE,
|
||||
DEFAULT_CONFIG,
|
||||
HOME_DIR,
|
||||
PLUGINS_DIR,
|
||||
} from "../constants";
|
||||
|
||||
// Function to interpolate environment variables in config values
|
||||
const interpolateEnvVars = (obj: any): any => {
|
||||
if (typeof obj === "string") {
|
||||
// Replace $VAR_NAME or ${VAR_NAME} with environment variable values
|
||||
return obj.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => {
|
||||
const varName = braced || unbraced;
|
||||
return process.env[varName] || match; // Keep original if env var doesn't exist
|
||||
});
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map(interpolateEnvVars);
|
||||
} else if (obj !== null && typeof obj === "object") {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = interpolateEnvVars(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
const ensureDir = async (dir_path: string) => {
|
||||
try {
|
||||
await fs.access(dir_path);
|
||||
} catch {
|
||||
await fs.mkdir(dir_path, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
export const initDir = async () => {
|
||||
await ensureDir(HOME_DIR);
|
||||
await ensureDir(PLUGINS_DIR);
|
||||
await ensureDir(path.join(HOME_DIR, "logs"));
|
||||
};
|
||||
|
||||
const createReadline = () => {
|
||||
return readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
};
|
||||
|
||||
const question = (query: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const rl = createReadline();
|
||||
rl.question(query, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = async (query: string): Promise<boolean> => {
|
||||
const answer = await question(query);
|
||||
return answer.toLowerCase() !== "n";
|
||||
};
|
||||
|
||||
export const readConfigFile = async () => {
|
||||
try {
|
||||
const config = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
try {
|
||||
// Try to parse with JSON5 first (which also supports standard JSON)
|
||||
const parsedConfig = JSON5.parse(config);
|
||||
// Interpolate environment variables in the parsed config
|
||||
return interpolateEnvVars(parsedConfig);
|
||||
} catch (parseError) {
|
||||
console.error(`Failed to parse config file at ${CONFIG_FILE}`);
|
||||
console.error("Error details:", (parseError as Error).message);
|
||||
console.error("Please check your config file syntax.");
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (readError: any) {
|
||||
if (readError.code === "ENOENT") {
|
||||
// Config file doesn't exist, prompt user for initial setup
|
||||
try {
|
||||
// Initialize directories
|
||||
await initDir();
|
||||
|
||||
// Backup existing config file if it exists
|
||||
const backupPath = await backupConfigFile();
|
||||
if (backupPath) {
|
||||
console.log(
|
||||
`Backed up existing configuration file to ${backupPath}`
|
||||
);
|
||||
}
|
||||
const config = {
|
||||
PORT: 3456,
|
||||
Providers: [],
|
||||
Router: {},
|
||||
}
|
||||
// Create a minimal default config file
|
||||
await writeConfigFile(config);
|
||||
console.log(
|
||||
"Created minimal default configuration file at ~/.claude-code-router/config.json"
|
||||
);
|
||||
console.log(
|
||||
"Please edit this file with your actual configuration."
|
||||
);
|
||||
return config
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Failed to create default configuration:",
|
||||
error.message
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(`Failed to read config file at ${CONFIG_FILE}`);
|
||||
console.error("Error details:", readError.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const backupConfigFile = async () => {
|
||||
try {
|
||||
if (await fs.access(CONFIG_FILE).then(() => true).catch(() => false)) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${CONFIG_FILE}.${timestamp}.bak`;
|
||||
await fs.copyFile(CONFIG_FILE, backupPath);
|
||||
|
||||
// Clean up old backups, keeping only the 3 most recent
|
||||
try {
|
||||
const configDir = path.dirname(CONFIG_FILE);
|
||||
const configFileName = path.basename(CONFIG_FILE);
|
||||
const files = await fs.readdir(configDir);
|
||||
|
||||
// Find all backup files for this config
|
||||
const backupFiles = files
|
||||
.filter(file => file.startsWith(configFileName) && file.endsWith('.bak'))
|
||||
.sort()
|
||||
.reverse(); // Sort in descending order (newest first)
|
||||
|
||||
// Delete all but the 3 most recent backups
|
||||
if (backupFiles.length > 3) {
|
||||
for (let i = 3; i < backupFiles.length; i++) {
|
||||
const oldBackupPath = path.join(configDir, backupFiles[i]);
|
||||
await fs.unlink(oldBackupPath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn("Failed to clean up old backups:", cleanupError);
|
||||
}
|
||||
|
||||
return backupPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to backup config file:", error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const writeConfigFile = async (config: any) => {
|
||||
await ensureDir(HOME_DIR);
|
||||
const configWithComment = `${JSON.stringify(config, null, 2)}`;
|
||||
await fs.writeFile(CONFIG_FILE, configWithComment);
|
||||
};
|
||||
|
||||
export const initConfig = async () => {
|
||||
const config = await readConfigFile();
|
||||
Object.assign(process.env, config);
|
||||
return config;
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { HOME_DIR } from "../constants";
|
||||
|
||||
/**
|
||||
* Cleans up old log files, keeping only the most recent ones
|
||||
* @param maxFiles - Maximum number of log files to keep (default: 9)
|
||||
*/
|
||||
export async function cleanupLogFiles(maxFiles: number = 9): Promise<void> {
|
||||
try {
|
||||
const logsDir = path.join(HOME_DIR, "logs");
|
||||
|
||||
// Check if logs directory exists
|
||||
try {
|
||||
await fs.access(logsDir);
|
||||
} catch {
|
||||
// Logs directory doesn't exist, nothing to clean up
|
||||
return;
|
||||
}
|
||||
|
||||
// Read all files in the logs directory
|
||||
const files = await fs.readdir(logsDir);
|
||||
|
||||
// Filter for log files (files starting with 'ccr-' and ending with '.log')
|
||||
const logFiles = files
|
||||
.filter(file => file.startsWith('ccr-') && file.endsWith('.log'))
|
||||
.sort()
|
||||
.reverse(); // Sort in descending order (newest first)
|
||||
|
||||
// Delete files exceeding the maxFiles limit
|
||||
if (logFiles.length > maxFiles) {
|
||||
for (let i = maxFiles; i < logFiles.length; i++) {
|
||||
const filePath = path.join(logsDir, logFiles[i]);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete log file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to clean up log files:", error);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
|
||||
import { readConfigFile } from './index';
|
||||
import find from 'find-process';
|
||||
import { execSync } from 'child_process'; // 引入 execSync 来执行命令行
|
||||
|
||||
export async function isProcessRunning(pid: number): Promise<boolean> {
|
||||
try {
|
||||
const processes = await find('pid', pid);
|
||||
return processes.length > 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementReferenceCount() {
|
||||
let count = 0;
|
||||
if (existsSync(REFERENCE_COUNT_FILE)) {
|
||||
count = parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
|
||||
}
|
||||
count++;
|
||||
writeFileSync(REFERENCE_COUNT_FILE, count.toString());
|
||||
}
|
||||
|
||||
export function decrementReferenceCount() {
|
||||
let count = 0;
|
||||
if (existsSync(REFERENCE_COUNT_FILE)) {
|
||||
count = parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
|
||||
}
|
||||
count = Math.max(0, count - 1);
|
||||
writeFileSync(REFERENCE_COUNT_FILE, count.toString());
|
||||
}
|
||||
|
||||
export function getReferenceCount(): number {
|
||||
if (!existsSync(REFERENCE_COUNT_FILE)) {
|
||||
return 0;
|
||||
}
|
||||
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
|
||||
}
|
||||
|
||||
export function isServiceRunning(): boolean {
|
||||
if (!existsSync(PID_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pid: number;
|
||||
try {
|
||||
const pidStr = readFileSync(PID_FILE, 'utf-8');
|
||||
pid = parseInt(pidStr, 10);
|
||||
if (isNaN(pid)) {
|
||||
// PID 文件内容无效
|
||||
cleanupPidFile();
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// 读取文件失败
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// --- Windows 平台逻辑 ---
|
||||
// 使用 tasklist 命令并通过 PID 过滤器查找进程
|
||||
// stdio: 'pipe' 压制命令的输出,防止其显示在控制台
|
||||
const command = `tasklist /FI "PID eq ${pid}"`;
|
||||
const output = execSync(command, { stdio: 'pipe' }).toString();
|
||||
|
||||
// 如果输出中包含了 PID,说明进程存在
|
||||
// tasklist 找不到进程时会返回 "INFO: No tasks are running..."
|
||||
// 所以一个简单的包含检查就足够了
|
||||
if (output.includes(pid.toString())) {
|
||||
return true;
|
||||
} else {
|
||||
// 理论上如果 tasklist 成功执行但没找到,这里不会被命中
|
||||
// 但作为保险,我们仍然认为进程不存在
|
||||
cleanupPidFile();
|
||||
return false;
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- Linux, macOS 等其他平台逻辑 ---
|
||||
// 使用信号 0 来检查进程是否存在,这不会真的杀死进程
|
||||
process.kill(pid, 0);
|
||||
return true; // 如果没有抛出异常,说明进程存在
|
||||
}
|
||||
} catch (e) {
|
||||
// 捕获到异常,说明进程不存在 (无论是 kill 还是 execSync 失败)
|
||||
// 清理掉无效的 PID 文件
|
||||
cleanupPidFile();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePid(pid: number) {
|
||||
writeFileSync(PID_FILE, pid.toString());
|
||||
}
|
||||
|
||||
export function cleanupPidFile() {
|
||||
if (existsSync(PID_FILE)) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
fs.unlinkSync(PID_FILE);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getServicePid(): number | null {
|
||||
if (!existsSync(PID_FILE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
|
||||
return isNaN(pid) ? null : pid;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServiceInfo() {
|
||||
const pid = getServicePid();
|
||||
const running = await isServiceRunning();
|
||||
const config = await readConfigFile();
|
||||
const port = config.PORT || 3456;
|
||||
|
||||
return {
|
||||
running,
|
||||
pid,
|
||||
port,
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
pidFile: PID_FILE,
|
||||
referenceCount: getReferenceCount()
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getServiceInfo } from './processCheck';
|
||||
|
||||
export async function showStatus() {
|
||||
const info = await getServiceInfo();
|
||||
|
||||
console.log('\n📊 Claude Code Router Status');
|
||||
console.log('═'.repeat(40));
|
||||
|
||||
if (info.running) {
|
||||
console.log('✅ Status: Running');
|
||||
console.log(`🆔 Process ID: ${info.pid}`);
|
||||
console.log(`🌐 Port: ${info.port}`);
|
||||
console.log(`📡 API Endpoint: ${info.endpoint}`);
|
||||
console.log(`📄 PID File: ${info.pidFile}`);
|
||||
console.log('');
|
||||
console.log('🚀 Ready to use! Run the following commands:');
|
||||
console.log(' ccr code # Start coding with Claude');
|
||||
console.log(' ccr stop # Stop the service');
|
||||
} else {
|
||||
console.log('❌ Status: Not Running');
|
||||
console.log('');
|
||||
console.log('💡 To start the service:');
|
||||
console.log(' ccr start');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
@@ -1,813 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { execSync } from "child_process";
|
||||
import path from "node:path";
|
||||
import { CONFIG_FILE } from "../constants";
|
||||
import JSON5 from "json5";
|
||||
|
||||
export interface StatusLineModuleConfig {
|
||||
type: string;
|
||||
icon?: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
scriptPath?: string;
|
||||
}
|
||||
|
||||
export interface StatusLineThemeConfig {
|
||||
modules: StatusLineModuleConfig[];
|
||||
}
|
||||
|
||||
export interface StatusLineInput {
|
||||
hook_event_name: string;
|
||||
session_id: string;
|
||||
transcript_path: string;
|
||||
cwd: string;
|
||||
model: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
};
|
||||
workspace: {
|
||||
current_dir: string;
|
||||
project_dir: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: "assistant";
|
||||
message: {
|
||||
model: string;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ANSIColor代码
|
||||
const COLORS: Record<string, string> = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
// 标准颜色
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
// 亮色
|
||||
bright_black: "\x1b[90m",
|
||||
bright_red: "\x1b[91m",
|
||||
bright_green: "\x1b[92m",
|
||||
bright_yellow: "\x1b[93m",
|
||||
bright_blue: "\x1b[94m",
|
||||
bright_magenta: "\x1b[95m",
|
||||
bright_cyan: "\x1b[96m",
|
||||
bright_white: "\x1b[97m",
|
||||
// 背景颜色
|
||||
bg_black: "\x1b[40m",
|
||||
bg_red: "\x1b[41m",
|
||||
bg_green: "\x1b[42m",
|
||||
bg_yellow: "\x1b[43m",
|
||||
bg_blue: "\x1b[44m",
|
||||
bg_magenta: "\x1b[45m",
|
||||
bg_cyan: "\x1b[46m",
|
||||
bg_white: "\x1b[47m",
|
||||
// 亮背景色
|
||||
bg_bright_black: "\x1b[100m",
|
||||
bg_bright_red: "\x1b[101m",
|
||||
bg_bright_green: "\x1b[102m",
|
||||
bg_bright_yellow: "\x1b[103m",
|
||||
bg_bright_blue: "\x1b[104m",
|
||||
bg_bright_magenta: "\x1b[105m",
|
||||
bg_bright_cyan: "\x1b[106m",
|
||||
bg_bright_white: "\x1b[107m",
|
||||
};
|
||||
|
||||
// 使用TrueColor(24位色)支持十六进制颜色
|
||||
const TRUE_COLOR_PREFIX = "\x1b[38;2;";
|
||||
const TRUE_COLOR_BG_PREFIX = "\x1b[48;2;";
|
||||
|
||||
// 将十六进制颜色转为RGB格式
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
// 移除#和空格
|
||||
hex = hex.replace(/^#/, '').trim();
|
||||
|
||||
// 处理简写形式 (#RGB -> #RRGGBB)
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
|
||||
if (hex.length !== 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
// 验证RGB值是否有效
|
||||
if (isNaN(r) || isNaN(g) || isNaN(b) || r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
// 获取颜色代码
|
||||
function getColorCode(colorName: string): string {
|
||||
// 检查是否是十六进制颜色
|
||||
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||
const rgb = hexToRgb(colorName);
|
||||
if (rgb) {
|
||||
return `${TRUE_COLOR_PREFIX}${rgb.r};${rgb.g};${rgb.b}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回空字符串
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
// 变量替换函数,支持{{var}}格式的变量替换
|
||||
function replaceVariables(text: string, variables: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_match, varName) => {
|
||||
return variables[varName] || "";
|
||||
});
|
||||
}
|
||||
|
||||
// 执行脚本并获取输出
|
||||
async function executeScript(scriptPath: string, variables: Record<string, string>): Promise<string> {
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
await fs.access(scriptPath);
|
||||
|
||||
// 使用require动态加载脚本模块
|
||||
const scriptModule = require(scriptPath);
|
||||
|
||||
// 如果导出的是函数,则调用它并传入变量
|
||||
if (typeof scriptModule === 'function') {
|
||||
const result = scriptModule(variables);
|
||||
// 如果返回的是Promise,则等待它完成
|
||||
if (result instanceof Promise) {
|
||||
return await result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果导出的是default函数,则调用它
|
||||
if (scriptModule.default && typeof scriptModule.default === 'function') {
|
||||
const result = scriptModule.default(variables);
|
||||
// 如果返回的是Promise,则等待它完成
|
||||
if (result instanceof Promise) {
|
||||
return await result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果导出的是字符串,则直接返回
|
||||
if (typeof scriptModule === 'string') {
|
||||
return scriptModule;
|
||||
}
|
||||
|
||||
// 如果导出的是default字符串,则返回它
|
||||
if (scriptModule.default && typeof scriptModule.default === 'string') {
|
||||
return scriptModule.default;
|
||||
}
|
||||
|
||||
// 默认情况下返回空字符串
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error(`执行脚本 ${scriptPath} 时出错:`, error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// 默认主题配置 - 使用Nerd Fonts图标和美观配色
|
||||
const DEFAULT_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "", // nf-md-folder_outline
|
||||
text: "{{workDirName}}",
|
||||
color: "bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "", // nf-dev-git_branch
|
||||
text: "{{gitBranch}}",
|
||||
color: "bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "", // nf-md-robot_outline
|
||||
text: "{{model}}",
|
||||
color: "bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑", // 上箭头
|
||||
text: "{{inputTokens}}",
|
||||
color: "bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓", // 下箭头
|
||||
text: "{{outputTokens}}",
|
||||
color: "bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Powerline风格主题配置
|
||||
const POWERLINE_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "", // nf-md-folder_outline
|
||||
text: "{{workDirName}}",
|
||||
color: "white",
|
||||
background: "bg_bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "", // nf-dev-git_branch
|
||||
text: "{{gitBranch}}",
|
||||
color: "white",
|
||||
background: "bg_bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "", // nf-md-robot_outline
|
||||
text: "{{model}}",
|
||||
color: "white",
|
||||
background: "bg_bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑", // 上箭头
|
||||
text: "{{inputTokens}}",
|
||||
color: "white",
|
||||
background: "bg_bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓", // 下箭头
|
||||
text: "{{outputTokens}}",
|
||||
color: "white",
|
||||
background: "bg_bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 简单文本主题配置 - 用于图标无法显示时的fallback
|
||||
const SIMPLE_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "",
|
||||
text: "{{workDirName}}",
|
||||
color: "bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "",
|
||||
text: "{{gitBranch}}",
|
||||
color: "bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "",
|
||||
text: "{{model}}",
|
||||
color: "bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑",
|
||||
text: "{{inputTokens}}",
|
||||
color: "bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓",
|
||||
text: "{{outputTokens}}",
|
||||
color: "bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 格式化usage信息,如果大于1000则使用k单位
|
||||
function formatUsage(input_tokens: number, output_tokens: number): string {
|
||||
if (input_tokens > 1000 || output_tokens > 1000) {
|
||||
const inputFormatted = input_tokens > 1000 ? `${(input_tokens / 1000).toFixed(1)}k` : `${input_tokens}`;
|
||||
const outputFormatted = output_tokens > 1000 ? `${(output_tokens / 1000).toFixed(1)}k` : `${output_tokens}`;
|
||||
return `${inputFormatted} ${outputFormatted}`;
|
||||
}
|
||||
return `${input_tokens} ${output_tokens}`;
|
||||
}
|
||||
|
||||
// 读取用户主目录的主题配置
|
||||
async function getProjectThemeConfig(): Promise<{ theme: StatusLineThemeConfig | null, style: string }> {
|
||||
try {
|
||||
// 只使用主目录的固定配置文件
|
||||
const configPath = CONFIG_FILE;
|
||||
|
||||
// 检查配置文件是否存在
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return { theme: null, style: 'default' };
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 检查是否有StatusLine配置
|
||||
if (config.StatusLine) {
|
||||
// 获取当前使用的风格,默认为default
|
||||
const currentStyle = config.StatusLine.currentStyle || 'default';
|
||||
|
||||
// 检查是否有对应风格的配置
|
||||
if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) {
|
||||
return { theme: config.StatusLine[currentStyle], style: currentStyle };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果读取失败,返回null
|
||||
// console.error("Failed to read theme config:", error);
|
||||
}
|
||||
|
||||
return { theme: null, style: 'default' };
|
||||
}
|
||||
|
||||
// 检查是否应该使用简单主题(fallback方案)
|
||||
// 当环境变量 USE_SIMPLE_ICONS 被设置时,或者当检测到可能不支持Nerd Fonts的终端时
|
||||
function shouldUseSimpleTheme(): boolean {
|
||||
// 检查环境变量
|
||||
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查终端类型(一些常见的不支持复杂图标的终端)
|
||||
const term = process.env.TERM || '';
|
||||
const unsupportedTerms = ['dumb', 'unknown'];
|
||||
if (unsupportedTerms.includes(term)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设终端支持Nerd Fonts
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查Nerd Fonts图标是否能正确显示
|
||||
// 通过检查终端字体信息或使用试探性方法
|
||||
function canDisplayNerdFonts(): boolean {
|
||||
// 如果环境变量明确指定使用简单图标,则不能显示Nerd Fonts
|
||||
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查一些常见的支持Nerd Fonts的终端环境变量
|
||||
const fontEnvVars = ['NERD_FONT', 'NERDFONT', 'FONT'];
|
||||
for (const envVar of fontEnvVars) {
|
||||
const value = process.env[envVar];
|
||||
if (value && (value.includes('Nerd') || value.includes('nerd'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查终端类型
|
||||
const termProgram = process.env.TERM_PROGRAM || '';
|
||||
const supportedTerminals = ['iTerm.app', 'vscode', 'Hyper', 'kitty', 'alacritty'];
|
||||
if (supportedTerminals.includes(termProgram)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查COLORTERM环境变量
|
||||
const colorTerm = process.env.COLORTERM || '';
|
||||
if (colorTerm.includes('truecolor') || colorTerm.includes('24bit')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设可以显示Nerd Fonts(但允许用户通过环境变量覆盖)
|
||||
return process.env.USE_SIMPLE_ICONS !== 'true';
|
||||
}
|
||||
|
||||
// 检查特定Unicode字符是否能正确显示
|
||||
// 这是一个简单的试探性检查
|
||||
function canDisplayUnicodeCharacter(char: string): boolean {
|
||||
// 对于Nerd Fonts图标,我们假设支持UTF-8的终端可以显示
|
||||
// 但实际上很难准确检测,所以我们依赖环境变量和终端类型检测
|
||||
try {
|
||||
// 检查终端是否支持UTF-8
|
||||
const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
|
||||
if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查LC_*环境变量
|
||||
const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG'];
|
||||
for (const lcVar of lcVars) {
|
||||
const value = process.env[lcVar];
|
||||
if (value && (value.includes('UTF-8') || value.includes('utf8'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果检查失败,默认返回true
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设可以显示
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function parseStatusLineData(input: StatusLineInput): Promise<string> {
|
||||
try {
|
||||
// 检查是否应该使用简单主题
|
||||
const useSimpleTheme = shouldUseSimpleTheme();
|
||||
|
||||
// 检查是否可以显示Nerd Fonts图标
|
||||
const canDisplayNerd = canDisplayNerdFonts();
|
||||
|
||||
// 确定使用的主题:如果用户强制使用简单主题或无法显示Nerd Fonts,则使用简单主题
|
||||
const effectiveTheme = useSimpleTheme || !canDisplayNerd ? SIMPLE_THEME : DEFAULT_THEME;
|
||||
|
||||
// 获取主目录的主题配置,如果没有则使用确定的默认配置
|
||||
const { theme: projectTheme, style: currentStyle } = await getProjectThemeConfig();
|
||||
const theme = projectTheme || effectiveTheme;
|
||||
|
||||
// 获取当前工作目录和Git分支
|
||||
const workDir = input.workspace.current_dir;
|
||||
let gitBranch = "";
|
||||
|
||||
try {
|
||||
// 尝试获取Git分支名
|
||||
gitBranch = execSync("git branch --show-current", {
|
||||
cwd: workDir,
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (error) {
|
||||
// 如果不是Git仓库或获取失败,则忽略错误
|
||||
}
|
||||
|
||||
// 从transcript_path文件中读取最后一条assistant消息
|
||||
const transcriptContent = await fs.readFile(input.transcript_path, "utf-8");
|
||||
const lines = transcriptContent.trim().split("\n");
|
||||
|
||||
// 反向遍历寻找最后一条assistant消息
|
||||
let model = "";
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const message: AssistantMessage = JSON.parse(lines[i]);
|
||||
if (message.type === "assistant" && message.message.model) {
|
||||
model = message.message.model;
|
||||
|
||||
if (message.message.usage) {
|
||||
inputTokens = message.message.usage.input_tokens;
|
||||
outputTokens = message.message.usage.output_tokens;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (parseError) {
|
||||
// 忽略解析错误,继续查找
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有从transcript中获取到模型名称,则尝试从配置文件中获取
|
||||
if (!model) {
|
||||
try {
|
||||
// 获取项目配置文件路径
|
||||
const projectConfigPath = path.join(workDir, ".claude-code-router", "config.json");
|
||||
let configPath = projectConfigPath;
|
||||
|
||||
// 检查项目配置文件是否存在,如果不存在则使用用户主目录的配置文件
|
||||
try {
|
||||
await fs.access(projectConfigPath);
|
||||
} catch {
|
||||
configPath = CONFIG_FILE;
|
||||
}
|
||||
|
||||
// 读取配置文件
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 从Router字段的default内容中获取模型名称
|
||||
if (config.Router && config.Router.default) {
|
||||
const [, defaultModel] = config.Router.default.split(",");
|
||||
if (defaultModel) {
|
||||
model = defaultModel.trim();
|
||||
}
|
||||
}
|
||||
} catch (configError) {
|
||||
// 如果配置文件读取失败,则忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有获取到模型名称,则使用传入的JSON数据中的model字段的display_name
|
||||
if (!model) {
|
||||
model = input.model.display_name;
|
||||
}
|
||||
|
||||
// 获取工作目录名
|
||||
const workDirName = workDir.split("/").pop() || "";
|
||||
|
||||
// 格式化usage信息
|
||||
const usage = formatUsage(inputTokens, outputTokens);
|
||||
const [formattedInputTokens, formattedOutputTokens] = usage.split(" ");
|
||||
|
||||
// 定义变量替换映射
|
||||
const variables = {
|
||||
workDirName,
|
||||
gitBranch,
|
||||
model,
|
||||
inputTokens: formattedInputTokens,
|
||||
outputTokens: formattedOutputTokens
|
||||
};
|
||||
|
||||
// 确定使用的风格
|
||||
const isPowerline = currentStyle === 'powerline';
|
||||
|
||||
// 根据风格渲染状态行
|
||||
if (isPowerline) {
|
||||
return await renderPowerlineStyle(theme, variables);
|
||||
} else {
|
||||
return await renderDefaultStyle(theme, variables);
|
||||
}
|
||||
} catch (error) {
|
||||
// 发生错误时返回空字符串
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// 读取用户主目录的主题配置(指定风格)
|
||||
async function getProjectThemeConfigForStyle(style: string): Promise<StatusLineThemeConfig | null> {
|
||||
try {
|
||||
// 只使用主目录的固定配置文件
|
||||
const configPath = CONFIG_FILE;
|
||||
|
||||
// 检查配置文件是否存在
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 检查是否有StatusLine配置
|
||||
if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) {
|
||||
return config.StatusLine[style];
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果读取失败,返回null
|
||||
// console.error("Failed to read theme config:", error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 渲染默认风格的状态行
|
||||
async function renderDefaultStyle(
|
||||
theme: StatusLineThemeConfig,
|
||||
variables: Record<string, string>
|
||||
): Promise<string> {
|
||||
const modules = theme.modules || DEFAULT_THEME.modules;
|
||||
const parts: string[] = [];
|
||||
|
||||
// 遍历模块数组,渲染每个模块
|
||||
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||
const module = modules[i];
|
||||
const color = module.color ? getColorCode(module.color) : "";
|
||||
const background = module.background ? getColorCode(module.background) : "";
|
||||
const icon = module.icon || "";
|
||||
|
||||
// 如果是script类型,执行脚本获取文本
|
||||
let text = "";
|
||||
if (module.type === "script" && module.scriptPath) {
|
||||
text = await executeScript(module.scriptPath, variables);
|
||||
} else {
|
||||
text = replaceVariables(module.text, variables);
|
||||
}
|
||||
|
||||
// 构建显示文本
|
||||
let displayText = "";
|
||||
if (icon) {
|
||||
displayText += `${icon} `;
|
||||
}
|
||||
displayText += text;
|
||||
|
||||
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||
if (!displayText || !text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建模块字符串
|
||||
let part = `${background}${color}`;
|
||||
part += `${displayText}${COLORS.reset}`;
|
||||
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
// 使用空格连接所有部分
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// Powerline符号
|
||||
const SEP_RIGHT = "\uE0B0"; //
|
||||
|
||||
// 颜色编号(256色表)
|
||||
const COLOR_MAP: Record<string, number> = {
|
||||
// 基础颜色映射到256色
|
||||
black: 0,
|
||||
red: 1,
|
||||
green: 2,
|
||||
yellow: 3,
|
||||
blue: 4,
|
||||
magenta: 5,
|
||||
cyan: 6,
|
||||
white: 7,
|
||||
bright_black: 8,
|
||||
bright_red: 9,
|
||||
bright_green: 10,
|
||||
bright_yellow: 11,
|
||||
bright_blue: 12,
|
||||
bright_magenta: 13,
|
||||
bright_cyan: 14,
|
||||
bright_white: 15,
|
||||
// 亮背景色映射
|
||||
bg_black: 0,
|
||||
bg_red: 1,
|
||||
bg_green: 2,
|
||||
bg_yellow: 3,
|
||||
bg_blue: 4,
|
||||
bg_magenta: 5,
|
||||
bg_cyan: 6,
|
||||
bg_white: 7,
|
||||
bg_bright_black: 8,
|
||||
bg_bright_red: 9,
|
||||
bg_bright_green: 10,
|
||||
bg_bright_yellow: 11,
|
||||
bg_bright_blue: 12,
|
||||
bg_bright_magenta: 13,
|
||||
bg_bright_cyan: 14,
|
||||
bg_bright_white: 15,
|
||||
// 自定义颜色映射
|
||||
bg_bright_orange: 202,
|
||||
bg_bright_purple: 129,
|
||||
};
|
||||
|
||||
// 获取TrueColor的RGB值
|
||||
function getTrueColorRgb(colorName: string): { r: number; g: number; b: number } | null {
|
||||
// 如果是预定义颜色,返回对应RGB
|
||||
if (COLOR_MAP[colorName] !== undefined) {
|
||||
const color256 = COLOR_MAP[colorName];
|
||||
return color256ToRgb(color256);
|
||||
}
|
||||
|
||||
// 处理十六进制颜色
|
||||
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||
return hexToRgb(colorName);
|
||||
}
|
||||
|
||||
// 处理背景色十六进制
|
||||
if (colorName.startsWith('bg_#')) {
|
||||
return hexToRgb(colorName.substring(3));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将256色表索引转换为RGB值
|
||||
function color256ToRgb(index: number): { r: number; g: number; b: number } | null {
|
||||
if (index < 0 || index > 255) return null;
|
||||
|
||||
// ANSI 256色表转换
|
||||
if (index < 16) {
|
||||
// 基本颜色
|
||||
const basicColors = [
|
||||
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
|
||||
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
|
||||
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
|
||||
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
|
||||
];
|
||||
return { r: basicColors[index][0], g: basicColors[index][1], b: basicColors[index][2] };
|
||||
} else if (index < 232) {
|
||||
// 216色:6×6×6的颜色立方体
|
||||
const i = index - 16;
|
||||
const r = Math.floor(i / 36);
|
||||
const g = Math.floor((i % 36) / 6);
|
||||
const b = i % 6;
|
||||
const rgb = [0, 95, 135, 175, 215, 255];
|
||||
return { r: rgb[r], g: rgb[g], b: rgb[b] };
|
||||
} else {
|
||||
// 灰度色
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return { r: gray, g: gray, b: gray };
|
||||
}
|
||||
}
|
||||
|
||||
// 生成一个无缝拼接的段:文本在 bgN 上显示,分隔符从 bgN 过渡到 nextBgN
|
||||
function segment(text: string, textFg: string, bgColor: string, nextBgColor: string | null): string {
|
||||
const bgRgb = getTrueColorRgb(bgColor);
|
||||
if (!bgRgb) {
|
||||
// 如果无法获取RGB,使用默认蓝色背景
|
||||
const defaultBlueRgb = { r: 33, g: 150, b: 243 };
|
||||
const curBg = `\x1b[48;2;${defaultBlueRgb.r};${defaultBlueRgb.g};${defaultBlueRgb.b}m`;
|
||||
const fgColor = `\x1b[38;2;255;255;255m`;
|
||||
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||
return body;
|
||||
}
|
||||
|
||||
const curBg = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
|
||||
// 获取前景色RGB
|
||||
let fgRgb = { r: 255, g: 255, b: 255 }; // 默认前景色为白色
|
||||
const textFgRgb = getTrueColorRgb(textFg);
|
||||
if (textFgRgb) {
|
||||
fgRgb = textFgRgb;
|
||||
}
|
||||
|
||||
const fgColor = `\x1b[38;2;${fgRgb.r};${fgRgb.g};${fgRgb.b}m`;
|
||||
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||
|
||||
if (nextBgColor != null) {
|
||||
const nextBgRgb = getTrueColorRgb(nextBgColor);
|
||||
if (nextBgRgb) {
|
||||
// 分隔符:前景色是当前段的背景色,背景色是下一段的背景色
|
||||
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const sepNextBg = `\x1b[48;2;${nextBgRgb.r};${nextBgRgb.g};${nextBgRgb.b}m`;
|
||||
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||
return body + sep;
|
||||
}
|
||||
// 如果没有下一个背景色,假设终端背景为黑色并渲染黑色箭头
|
||||
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const sepNextBg = `\x1b[48;2;0;0;0m`; // 黑色背景
|
||||
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||
return body + sep;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// 渲染Powerline风格的状态行
|
||||
async function renderPowerlineStyle(
|
||||
theme: StatusLineThemeConfig,
|
||||
variables: Record<string, string>
|
||||
): Promise<string> {
|
||||
const modules = theme.modules || POWERLINE_THEME.modules;
|
||||
const segments: string[] = [];
|
||||
|
||||
// 遍历模块数组,渲染每个模块
|
||||
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||
const module = modules[i];
|
||||
const color = module.color || "white";
|
||||
const backgroundName = module.background || "";
|
||||
const icon = module.icon || "";
|
||||
|
||||
// 如果是script类型,执行脚本获取文本
|
||||
let text = "";
|
||||
if (module.type === "script" && module.scriptPath) {
|
||||
text = await executeScript(module.scriptPath, variables);
|
||||
} else {
|
||||
text = replaceVariables(module.text, variables);
|
||||
}
|
||||
|
||||
// 构建显示文本
|
||||
let displayText = "";
|
||||
if (icon) {
|
||||
displayText += `${icon} `;
|
||||
}
|
||||
displayText += text;
|
||||
|
||||
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||
if (!displayText || !text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取下一个模块的背景色(用于分隔符)
|
||||
let nextBackground: string | null = null;
|
||||
if (i < modules.length - 1) {
|
||||
const nextModule = modules[i + 1];
|
||||
nextBackground = nextModule.background || null;
|
||||
}
|
||||
|
||||
// 使用模块定义的背景色,或者为Powerline风格提供默认背景色
|
||||
const actualBackground = backgroundName || "bg_bright_blue";
|
||||
|
||||
// 生成段,支持十六进制颜色
|
||||
const segmentStr = segment(displayText, color, actualBackground, nextBackground);
|
||||
segments.push(segmentStr);
|
||||
}
|
||||
|
||||
return segments.join("");
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* 检查是否有新版本可用
|
||||
* @param currentVersion 当前版本
|
||||
* @returns 包含更新信息的对象
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string) {
|
||||
try {
|
||||
// 从npm registry获取最新版本信息
|
||||
const { stdout } = await execPromise("npm view @musistudio/claude-code-router version");
|
||||
const latestVersion = stdout.trim();
|
||||
|
||||
// 比较版本
|
||||
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
|
||||
|
||||
// 如果有更新,获取更新日志
|
||||
let changelog = "";
|
||||
|
||||
return { hasUpdate, latestVersion, changelog };
|
||||
} catch (error) {
|
||||
console.error("Error checking for updates:", error);
|
||||
// 如果检查失败,假设没有更新
|
||||
return { hasUpdate: false, latestVersion: currentVersion, changelog: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行更新操作
|
||||
* @returns 更新结果
|
||||
*/
|
||||
export async function performUpdate() {
|
||||
try {
|
||||
// 执行npm update命令
|
||||
const { stdout, stderr } = await execPromise("npm update -g @musistudio/claude-code-router");
|
||||
|
||||
if (stderr) {
|
||||
console.error("Update stderr:", stderr);
|
||||
}
|
||||
|
||||
console.log("Update stdout:", stdout);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Update completed successfully. Please restart the application to apply changes."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error performing update:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to perform update: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个版本号
|
||||
* @param v1 版本号1
|
||||
* @param v2 版本号2
|
||||
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
*/
|
||||
function compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split(".").map(Number);
|
||||
const parts2 = v2.split(".").map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const num1 = i < parts1.length ? parts1[i] : 0;
|
||||
const num2 = i < parts2.length ? parts2[i] : 0;
|
||||
|
||||
if (num1 > num2) return 1;
|
||||
if (num1 < num2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@claude-code-router/core",
|
||||
"version": "1.0.54",
|
||||
"description": "Core library for Claude Code Router",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
"dev": "node scripts/build.js --watch",
|
||||
"clean": "shx rm -rf dist"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"code",
|
||||
"router",
|
||||
"llm",
|
||||
"anthropic",
|
||||
"core"
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"lru-cache": "^11.0.2",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
"@musistudio/llms": "^1.0.35",
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"fastify": "^5.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"shx": "^0.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tiktoken": "^1.0.21"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
console.log('Building Claude Code Router Core...');
|
||||
|
||||
try {
|
||||
// Build the core library
|
||||
console.log('Building core library...');
|
||||
execSync('esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js --minify --external:@musistudio/llms --external:fastify --external:@anthropic-ai/sdk', { stdio: 'inherit' });
|
||||
|
||||
console.log('Core build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Core build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import {IAgent, ITool} from "./type";
|
||||
import { createHash } from 'crypto';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
interface ImageCacheEntry {
|
||||
source: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
class ImageCache {
|
||||
private cache: LRUCache<string, ImageCacheEntry>;
|
||||
|
||||
constructor(maxSize = 100) {
|
||||
this.cache = new LRUCache({
|
||||
max: maxSize,
|
||||
ttl: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
storeImage(id: string, source: any): void {
|
||||
if (this.hasImage(id)) return;
|
||||
this.cache.set(id, {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
getImage(id: string): any {
|
||||
const entry = this.cache.get(id);
|
||||
return entry ? entry.source : null;
|
||||
}
|
||||
|
||||
hasImage(hash: string): boolean {
|
||||
return this.cache.has(hash);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
|
||||
const imageCache = new ImageCache();
|
||||
|
||||
export class ImageAgent implements IAgent {
|
||||
name = "image";
|
||||
tools: Map<string, ITool>;
|
||||
|
||||
constructor() {
|
||||
this.tools = new Map<string, ITool>();
|
||||
this.appendTools()
|
||||
}
|
||||
|
||||
shouldHandle(req: any): boolean {
|
||||
if (!req.__config__.Router.image || req.body.model === req.__config__.Router.image) return false;
|
||||
const lastMessage = req.body.messages[req.body.messages.length - 1]
|
||||
if (!req.__config__.forceUseImageAgent && lastMessage.role === 'user' && Array.isArray(lastMessage.content) && lastMessage.content.find((item: any) => item.type === 'image' || (Array.isArray(item?.content) && item.content.some((sub: any) => sub.type === 'image')))) {
|
||||
req.body.model = req.__config__.Router.image
|
||||
const images = []
|
||||
lastMessage.content.filter((item: any) => item.type === 'tool_result').forEach((item: any) => {
|
||||
item.content.forEach((element: any) => {
|
||||
if (element.type === 'image') {
|
||||
images.push(element);
|
||||
}
|
||||
})
|
||||
item.content = 'read image successfully';
|
||||
})
|
||||
lastMessage.content.push(...images);
|
||||
return false;
|
||||
}
|
||||
return req.body.messages.some((msg: any) => msg.role === 'user' && Array.isArray(msg.content) && msg.content.some((item: any) => item.type === 'image' || (Array.isArray(item?.content) && item.content.some((sub: any) => sub.type === 'image'))))
|
||||
}
|
||||
|
||||
appendTools() {
|
||||
this.tools.set('analyzeImage', {
|
||||
name: "analyzeImage",
|
||||
description: "Analyse image or images by ID and extract information such as OCR text, objects, layout, colors, or safety signals.",
|
||||
input_schema: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"imageId": {
|
||||
"type": "array",
|
||||
"description": "an array of IDs to analyse",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "Details of task to perform on the image.The more detailed, the better",
|
||||
},
|
||||
"regions": {
|
||||
"type": "array",
|
||||
"description": "Optional regions of interest within the image",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Optional label for the region"},
|
||||
"x": {"type": "number", "description": "X coordinate"},
|
||||
"y": {"type": "number", "description": "Y coordinate"},
|
||||
"w": {"type": "number", "description": "Width of the region"},
|
||||
"h": {"type": "number", "description": "Height of the region"},
|
||||
"units": {"type": "string", "enum": ["px", "pct"], "description": "Units for coordinates and size"}
|
||||
},
|
||||
"required": ["x", "y", "w", "h", "units"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["imageId", "task"]
|
||||
},
|
||||
handler: async (args, context) => {
|
||||
const imageMessages = [];
|
||||
let imageId;
|
||||
|
||||
// Create image messages from cached images
|
||||
if (args.imageId) {
|
||||
if (Array.isArray(args.imageId)) {
|
||||
args.imageId.forEach((imgId: string) => {
|
||||
const image = imageCache.getImage(`${context.req.id}_Image#${imgId}`);
|
||||
if (image) {
|
||||
imageMessages.push({
|
||||
type: "image",
|
||||
source: image,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const image = imageCache.getImage(`${context.req.id}_Image#${args.imageId}`);
|
||||
if (image) {
|
||||
imageMessages.push({
|
||||
type: "image",
|
||||
source: image,
|
||||
});
|
||||
}
|
||||
}
|
||||
imageId = args.imageId;
|
||||
delete args.imageId;
|
||||
}
|
||||
|
||||
const userMessage = context.req.body.messages[context.req.body.messages.length - 1]
|
||||
if (userMessage.role === 'user' && Array.isArray(userMessage.content)) {
|
||||
const msgs = userMessage.content.filter(item => item.type === 'text' && !item.text.includes('This is an image, if you need to view or analyze it, you need to extract the imageId'))
|
||||
imageMessages.push(...msgs)
|
||||
}
|
||||
|
||||
if (Object.keys(args).length > 0) {
|
||||
imageMessages.push({
|
||||
type: "text",
|
||||
text: JSON.stringify(args),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Send to analysis agent and get response
|
||||
const agentResponse = await fetch(`http://127.0.0.1:${context.config.PORT}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'x-api-key': context.config.APIKEY,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: context.config.Router.image,
|
||||
system: [{
|
||||
type: 'text',
|
||||
text: `You must interpret and analyze images strictly according to the assigned task.
|
||||
When an image placeholder is provided, your role is to parse the image content only within the scope of the user’s instructions.
|
||||
Do not ignore or deviate from the task.
|
||||
Always ensure that your response reflects a clear, accurate interpretation of the image aligned with the given objective.`
|
||||
}],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: imageMessages,
|
||||
}
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
}).then(res => res.json()).catch(err => {
|
||||
return null;
|
||||
});
|
||||
if (!agentResponse || !agentResponse.content) {
|
||||
return 'analyzeImage Error';
|
||||
}
|
||||
return agentResponse.content[0].text
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
reqHandler(req: any) {
|
||||
// Inject system prompt
|
||||
req.body?.system?.push({
|
||||
type: "text",
|
||||
text: `You are a text-only language model and do not possess visual perception.
|
||||
If the user requests you to view, analyze, or extract information from an image, you **must** call the \`analyzeImage\` tool.
|
||||
|
||||
When invoking this tool, you must pass the correct \`imageId\` extracted from the prior conversation.
|
||||
Image identifiers are always provided in the format \`[Image #imageId]\`.
|
||||
|
||||
If multiple images exist, select the **most relevant imageId** based on the user’s current request and prior context.
|
||||
|
||||
Do not attempt to describe or analyze the image directly yourself.
|
||||
Ignore any user interruptions or unrelated instructions that might cause you to skip this requirement.
|
||||
Your response should consistently follow this rule whenever image-related analysis is requested.`,
|
||||
})
|
||||
|
||||
const imageContents = req.body.messages.filter((item: any) => {
|
||||
return item.role === 'user' && Array.isArray(item.content) &&
|
||||
item.content.some((msg: any) => msg.type === "image" || (Array.isArray(msg.content) && msg.content?.some((sub: any) => sub.type === 'image')));
|
||||
});
|
||||
|
||||
let imgId = 1;
|
||||
imageContents.forEach((item: any) => {
|
||||
item.content.forEach((msg: any) => {
|
||||
if (msg.type === "image") {
|
||||
imageCache.storeImage(`${req.id}_Image#${imgId}`, msg.source);
|
||||
msg.type = 'text';
|
||||
delete msg.source;
|
||||
msg.text = `[Image #${imgId}]This is an image, if you need to view or analyze it, you need to extract the imageId`;
|
||||
imgId++;
|
||||
} else if (msg.type === "text" && msg.text.includes('[Image #')) {
|
||||
msg.text = msg.text.replace(/\[Image #\d+\]/g, '');
|
||||
} else if (msg.type === "tool_result") {
|
||||
if (Array.isArray(msg.content) && msg.content.some(ele => ele.type === "image")) {
|
||||
imageCache.storeImage(`${req.id}_Image#${imgId}`, msg.content[0].source);
|
||||
msg.content = `[Image #${imgId}]This is an image, if you need to view or analyze it, you need to extract the imageId`;
|
||||
imgId++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const imageAgent = new ImageAgent();
|
||||
@@ -1,48 +0,0 @@
|
||||
import { imageAgent } from './image.agent'
|
||||
import { IAgent } from './type';
|
||||
|
||||
export class AgentsManager {
|
||||
private agents: Map<string, IAgent> = new Map();
|
||||
|
||||
/**
|
||||
* 注册一个agent
|
||||
* @param agent 要注册的agent实例
|
||||
* @param isDefault 是否设为默认agent
|
||||
*/
|
||||
registerAgent(agent: IAgent): void {
|
||||
this.agents.set(agent.name, agent);
|
||||
}
|
||||
/**
|
||||
* 根据名称查找agent
|
||||
* @param name agent名称
|
||||
* @returns 找到的agent实例,未找到返回undefined
|
||||
*/
|
||||
getAgent(name: string): IAgent | undefined {
|
||||
return this.agents.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的agents
|
||||
* @returns 所有agent实例的数组
|
||||
*/
|
||||
getAllAgents(): IAgent[] {
|
||||
return Array.from(this.agents.values());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有agent的工具
|
||||
* @returns 工具数组
|
||||
*/
|
||||
getAllTools(): any[] {
|
||||
const allTools: any[] = [];
|
||||
for (const agent of this.agents.values()) {
|
||||
allTools.push(...agent.tools.values());
|
||||
}
|
||||
return allTools;
|
||||
}
|
||||
}
|
||||
|
||||
const agentsManager = new AgentsManager()
|
||||
agentsManager.registerAgent(imageAgent)
|
||||
export default agentsManager
|
||||
@@ -1,19 +0,0 @@
|
||||
export interface ITool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: any;
|
||||
|
||||
handler: (args: any, context: any) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface IAgent {
|
||||
name: string;
|
||||
|
||||
tools: Map<string, ITool>;
|
||||
|
||||
shouldHandle: (req: any) => boolean;
|
||||
|
||||
reqHandler: (req: any) => void;
|
||||
|
||||
resHandler?: (payload: any) => void;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { createServer } from "./server";
|
||||
@@ -1,250 +0,0 @@
|
||||
import Server from "@musistudio/llms";
|
||||
import {calculateTokenCount, router} from "./utils/router";
|
||||
import { sessionUsageCache } from "./utils/cache";
|
||||
import {SSEParserTransform} from "./utils/SSEParser.transform";
|
||||
import {SSESerializerTransform} from "./utils/SSESerializer.transform";
|
||||
import {rewriteStream} from "./utils/rewriteStream";
|
||||
import JSON5 from "json5";
|
||||
import { IAgent } from "./agents/type";
|
||||
import agentsManager from "./agents";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const event = new EventEmitter();
|
||||
|
||||
export const createServer = (config: any): Server => {
|
||||
const server = new Server({
|
||||
initialConfig: config,
|
||||
logger: config.logger,
|
||||
});
|
||||
|
||||
server.addHook('onRequest', async (req, reply) => {
|
||||
// inject config object
|
||||
req.__config__ = config;
|
||||
})
|
||||
|
||||
server.app.post("/v1/messages/count_tokens", async (req, reply) => {
|
||||
const {messages, tools, system} = req.body;
|
||||
const tokenCount = calculateTokenCount(messages, system, tools);
|
||||
return { "input_tokens": tokenCount }
|
||||
});
|
||||
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
if (req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
|
||||
const useAgents = []
|
||||
|
||||
for (const agent of agentsManager.getAllAgents()) {
|
||||
if (agent.shouldHandle(req)) {
|
||||
// 设置agent标识
|
||||
useAgents.push(agent.name)
|
||||
|
||||
// change request body
|
||||
agent.reqHandler(req);
|
||||
|
||||
// append agent tools
|
||||
if (agent.tools.size) {
|
||||
if (!req.body?.tools?.length) {
|
||||
req.body.tools = []
|
||||
}
|
||||
req.body.tools.unshift(...Array.from(agent.tools.values()).map(item => {
|
||||
return {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
input_schema: item.input_schema
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useAgents.length) {
|
||||
req.agents = useAgents;
|
||||
}
|
||||
await router(req, reply, {
|
||||
event
|
||||
});
|
||||
}
|
||||
});
|
||||
server.addHook("onSend", (req, reply, payload, done) => {
|
||||
if (req.sessionId && req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
|
||||
if (payload instanceof ReadableStream) {
|
||||
if (req.agents) {
|
||||
const abortController = new AbortController();
|
||||
const eventStream = payload.pipeThrough(new SSEParserTransform())
|
||||
let currentAgent: undefined | IAgent;
|
||||
let currentToolIndex = -1
|
||||
let currentToolName = ''
|
||||
let currentToolArgs = ''
|
||||
let currentToolId = ''
|
||||
const toolMessages: any[] = []
|
||||
const assistantMessages: any[] = []
|
||||
// 存储Anthropic格式的消息体,区分文本和工具类型
|
||||
return done(null, rewriteStream(eventStream, async (data, controller) => {
|
||||
try {
|
||||
// 检测工具调用开始
|
||||
if (data.event === 'content_block_start' && data?.data?.content_block?.name) {
|
||||
const agent = req.agents.find((name: string) => agentsManager.getAgent(name)?.tools.get(data.data.content_block.name))
|
||||
if (agent) {
|
||||
currentAgent = agentsManager.getAgent(agent)
|
||||
currentToolIndex = data.data.index
|
||||
currentToolName = data.data.content_block.name
|
||||
currentToolId = data.data.content_block.id
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 收集工具参数
|
||||
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data?.delta?.type === 'input_json_delta') {
|
||||
currentToolArgs += data.data?.delta?.partial_json;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 工具调用完成,处理agent调用
|
||||
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data.type === 'content_block_stop') {
|
||||
try {
|
||||
const args = JSON5.parse(currentToolArgs);
|
||||
assistantMessages.push({
|
||||
type: "tool_use",
|
||||
id: currentToolId,
|
||||
name: currentToolName,
|
||||
input: args
|
||||
})
|
||||
const toolResult = await currentAgent?.tools.get(currentToolName)?.handler(args, {
|
||||
req,
|
||||
config: req.__config__
|
||||
});
|
||||
toolMessages.push({
|
||||
"tool_use_id": currentToolId,
|
||||
"type": "tool_result",
|
||||
"content": toolResult
|
||||
})
|
||||
currentAgent = undefined
|
||||
currentToolIndex = -1
|
||||
currentToolName = ''
|
||||
currentToolArgs = ''
|
||||
currentToolId = ''
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (data.event === 'message_delta' && toolMessages.length) {
|
||||
req.body.messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantMessages
|
||||
})
|
||||
req.body.messages.push({
|
||||
role: 'user',
|
||||
content: toolMessages
|
||||
})
|
||||
const response = await fetch(`http://127.0.0.1:${req.__config__.PORT}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'x-api-key': req.__config__.APIKEY,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const stream = response.body!.pipeThrough(new SSEParserTransform())
|
||||
const reader = stream.getReader()
|
||||
while (true) {
|
||||
try {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (['message_start', 'message_stop'].includes(value.event)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查流是否仍然可写
|
||||
if (!controller.desiredSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
controller.enqueue(value)
|
||||
}catch (readError: any) {
|
||||
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
abortController.abort(); // 中止所有相关操作
|
||||
break;
|
||||
}
|
||||
throw readError;
|
||||
}
|
||||
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return data
|
||||
}catch (error: any) {
|
||||
console.error('Unexpected error in stream processing:', error);
|
||||
|
||||
// 处理流提前关闭的错误
|
||||
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
abortController.abort();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 其他错误仍然抛出
|
||||
throw error;
|
||||
}
|
||||
}).pipeThrough(new SSESerializerTransform()))
|
||||
}
|
||||
|
||||
const [originalStream, clonedStream] = payload.tee();
|
||||
const read = async (stream: ReadableStream) => {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Process the value if needed
|
||||
const dataStr = new TextDecoder().decode(value);
|
||||
if (!dataStr.startsWith("event: message_delta")) {
|
||||
continue;
|
||||
}
|
||||
const str = dataStr.slice(27);
|
||||
try {
|
||||
const message = JSON.parse(str);
|
||||
sessionUsageCache.put(req.sessionId, message.usage);
|
||||
} catch {}
|
||||
}
|
||||
} catch (readError: any) {
|
||||
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
console.error('Background read stream closed prematurely');
|
||||
} else {
|
||||
console.error('Error in background stream reading:', readError);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
read(clonedStream);
|
||||
return done(null, originalStream)
|
||||
}
|
||||
sessionUsageCache.put(req.sessionId, payload.usage);
|
||||
if (typeof payload ==='object') {
|
||||
if (payload.error) {
|
||||
return done(payload.error, null)
|
||||
} else {
|
||||
return done(payload, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof payload ==='object' && payload.error) {
|
||||
return done(payload.error, null)
|
||||
}
|
||||
done(null, payload)
|
||||
});
|
||||
server.addHook("onSend", async (req, reply, payload) => {
|
||||
event.emit('onSend', req, reply, payload);
|
||||
return payload;
|
||||
})
|
||||
server.addHook("onError", async (request, reply, error) => {
|
||||
event.emit('onError', request, reply, error);
|
||||
})
|
||||
|
||||
return server;
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
export class SSEParserTransform extends TransformStream<string, any> {
|
||||
private buffer = '';
|
||||
private currentEvent: Record<string, any> = {};
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
transform: (chunk: string, controller) => {
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(chunk);
|
||||
this.buffer += text;
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// 保留最后一行(可能不完整)
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const event = this.processLine(line);
|
||||
if (event) {
|
||||
controller.enqueue(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
flush: (controller) => {
|
||||
// 处理缓冲区中剩余的内容
|
||||
if (this.buffer.trim()) {
|
||||
const events: any[] = [];
|
||||
this.processLine(this.buffer.trim(), events);
|
||||
events.forEach(event => controller.enqueue(event));
|
||||
}
|
||||
|
||||
// 推送最后一个事件(如果有)
|
||||
if (Object.keys(this.currentEvent).length > 0) {
|
||||
controller.enqueue(this.currentEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private processLine(line: string, events?: any[]): any | null {
|
||||
if (!line.trim()) {
|
||||
if (Object.keys(this.currentEvent).length > 0) {
|
||||
const event = { ...this.currentEvent };
|
||||
this.currentEvent = {};
|
||||
if (events) {
|
||||
events.push(event);
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
this.currentEvent.event = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const data = line.slice(5).trim();
|
||||
if (data === '[DONE]') {
|
||||
this.currentEvent.data = { type: 'done' };
|
||||
} else {
|
||||
try {
|
||||
this.currentEvent.data = JSON.parse(data);
|
||||
} catch (e) {
|
||||
this.currentEvent.data = { raw: data, error: 'JSON parse failed' };
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('id:')) {
|
||||
this.currentEvent.id = line.slice(3).trim();
|
||||
} else if (line.startsWith('retry:')) {
|
||||
this.currentEvent.retry = parseInt(line.slice(6).trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export class SSESerializerTransform extends TransformStream<any, string> {
|
||||
constructor() {
|
||||
super({
|
||||
transform: (event, controller) => {
|
||||
let output = '';
|
||||
|
||||
if (event.event) {
|
||||
output += `event: ${event.event}\n`;
|
||||
}
|
||||
if (event.id) {
|
||||
output += `id: ${event.id}\n`;
|
||||
}
|
||||
if (event.retry) {
|
||||
output += `retry: ${event.retry}\n`;
|
||||
}
|
||||
if (event.data) {
|
||||
if (event.data.type === 'done') {
|
||||
output += 'data: [DONE]\n';
|
||||
} else {
|
||||
output += `data: ${JSON.stringify(event.data)}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
controller.enqueue(output);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// LRU cache for session usage
|
||||
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
|
||||
class LRUCache<K, V> {
|
||||
private capacity: number;
|
||||
private cache: Map<K, V>;
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.capacity = capacity;
|
||||
this.cache = new Map<K, V>();
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (!this.cache.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = this.cache.get(key) as V;
|
||||
// Move to end to mark as recently used
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
put(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
// If key exists, delete it to update its position
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.capacity) {
|
||||
// If cache is full, delete the least recently used item
|
||||
const leastRecentlyUsedKey = this.cache.keys().next().value;
|
||||
if (leastRecentlyUsedKey !== undefined) {
|
||||
this.cache.delete(leastRecentlyUsedKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
values(): V[] {
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionUsageCache = new LRUCache<string, Usage>(100);
|
||||
@@ -1,31 +0,0 @@
|
||||
/**rewriteStream
|
||||
* 读取源readablestream,返回一个新的readablestream,由processor对源数据进行处理后将返回的新值推送到新的stream,如果没有返回值则不推送
|
||||
* @param stream
|
||||
* @param processor
|
||||
*/
|
||||
export const rewriteStream = (stream: ReadableStream, processor: (data: any, controller: ReadableStreamController<any>) => Promise<any>): ReadableStream => {
|
||||
const reader = stream.getReader()
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
controller.close()
|
||||
break
|
||||
}
|
||||
|
||||
const processed = await processor(value, controller)
|
||||
if (processed !== undefined) {
|
||||
controller.enqueue(processed)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error)
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import {
|
||||
MessageCreateParamsBase,
|
||||
MessageParam,
|
||||
Tool,
|
||||
} from "@anthropic-ai/sdk/resources/messages";
|
||||
import { get_encoding } from "tiktoken";
|
||||
import { sessionUsageCache, Usage } from "./cache";
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
const enc = get_encoding("cl100k_base");
|
||||
|
||||
export const calculateTokenCount = (
|
||||
messages: MessageParam[],
|
||||
system: any,
|
||||
tools: Tool[]
|
||||
) => {
|
||||
let tokenCount = 0;
|
||||
if (Array.isArray(messages)) {
|
||||
messages.forEach((message) => {
|
||||
if (typeof message.content === "string") {
|
||||
tokenCount += enc.encode(message.content).length;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
message.content.forEach((contentPart: any) => {
|
||||
if (contentPart.type === "text") {
|
||||
tokenCount += enc.encode(contentPart.text).length;
|
||||
} else if (contentPart.type === "tool_use") {
|
||||
tokenCount += enc.encode(JSON.stringify(contentPart.input)).length;
|
||||
} else if (contentPart.type === "tool_result") {
|
||||
tokenCount += enc.encode(
|
||||
typeof contentPart.content === "string"
|
||||
? contentPart.content
|
||||
: JSON.stringify(contentPart.content)
|
||||
).length;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof system === "string") {
|
||||
tokenCount += enc.encode(system).length;
|
||||
} else if (Array.isArray(system)) {
|
||||
system.forEach((item: any) => {
|
||||
if (item.type !== "text") return;
|
||||
if (typeof item.text === "string") {
|
||||
tokenCount += enc.encode(item.text).length;
|
||||
} else if (Array.isArray(item.text)) {
|
||||
item.text.forEach((textPart: any) => {
|
||||
tokenCount += enc.encode(textPart || "").length;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (tools) {
|
||||
tools.forEach((tool: Tool) => {
|
||||
if (tool.description) {
|
||||
tokenCount += enc.encode(tool.name + tool.description).length;
|
||||
}
|
||||
if (tool.input_schema) {
|
||||
tokenCount += enc.encode(JSON.stringify(tool.input_schema)).length;
|
||||
}
|
||||
});
|
||||
}
|
||||
return tokenCount;
|
||||
};
|
||||
|
||||
const getUseModel = async (
|
||||
req: any,
|
||||
tokenCount: number,
|
||||
config: any,
|
||||
lastUsage?: Usage | undefined
|
||||
) => {
|
||||
if (req.body.model.includes(",")) {
|
||||
const [provider, model] = req.body.model.split(",");
|
||||
const finalProvider = config.Providers.find(
|
||||
(p: any) => p.name.toLowerCase() === provider
|
||||
);
|
||||
const finalModel = finalProvider?.models?.find(
|
||||
(m: any) => m.toLowerCase() === model
|
||||
);
|
||||
if (finalProvider && finalModel) {
|
||||
return `${finalProvider.name},${finalModel}`;
|
||||
}
|
||||
return req.body.model;
|
||||
}
|
||||
|
||||
// if tokenCount is greater than the configured threshold, use the long context model
|
||||
const longContextThreshold = config.Router.longContextThreshold || 60000;
|
||||
const lastUsageThreshold =
|
||||
lastUsage &&
|
||||
lastUsage.input_tokens > longContextThreshold &&
|
||||
tokenCount > 20000;
|
||||
const tokenCountThreshold = tokenCount > longContextThreshold;
|
||||
if (
|
||||
(lastUsageThreshold || tokenCountThreshold) &&
|
||||
config.Router.longContext
|
||||
) {
|
||||
req.log.info(
|
||||
`Using long context model due to token count: ${tokenCount}, threshold: ${longContextThreshold}`
|
||||
);
|
||||
return config.Router.longContext;
|
||||
}
|
||||
if (
|
||||
req.body?.system?.length > 1 &&
|
||||
req.body?.system[1]?.text?.startsWith("<CCR-SUBAGENT-MODEL>")
|
||||
) {
|
||||
const model = req.body?.system[1].text.match(
|
||||
/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s
|
||||
);
|
||||
if (model) {
|
||||
req.body.system[1].text = req.body.system[1].text.replace(
|
||||
`<CCR-SUBAGENT-MODEL>${model[1]}</CCR-SUBAGENT-MODEL>`,
|
||||
""
|
||||
);
|
||||
return model[1];
|
||||
}
|
||||
}
|
||||
// If the model is claude-3-5-haiku, use the background model
|
||||
if (
|
||||
req.body.model?.startsWith("claude-3-5-haiku") &&
|
||||
config.Router.background
|
||||
) {
|
||||
req.log.info(`Using background model for ${req.body.model}`);
|
||||
return config.Router.background;
|
||||
}
|
||||
// if exits thinking, use the think model
|
||||
if (req.body.thinking && config.Router.think) {
|
||||
req.log.info(`Using think model for ${req.body.thinking}`);
|
||||
return config.Router.think;
|
||||
}
|
||||
if (
|
||||
Array.isArray(req.body.tools) &&
|
||||
req.body.tools.some((tool: any) => tool.type?.startsWith("web_search")) &&
|
||||
config.Router.webSearch
|
||||
) {
|
||||
return config.Router.webSearch;
|
||||
}
|
||||
return config.Router!.default;
|
||||
};
|
||||
|
||||
export const router = async (req: any, _res: any, context: any) => {
|
||||
if (!req.__config__) {
|
||||
return
|
||||
}
|
||||
const config = req.__config__;
|
||||
const { event } = context;
|
||||
// Parse sessionId from metadata.user_id
|
||||
if (req.body.metadata?.user_id) {
|
||||
const parts = req.body.metadata.user_id.split("_session_");
|
||||
if (parts.length > 1) {
|
||||
req.sessionId = parts[1];
|
||||
}
|
||||
}
|
||||
const lastMessageUsage = sessionUsageCache.get(req.sessionId);
|
||||
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
|
||||
if (config.REWRITE_SYSTEM_PROMPT && system.length > 1 && system[1]?.text?.includes('<env>')) {
|
||||
const prompt = await readFile(config.REWRITE_SYSTEM_PROMPT, 'utf-8');
|
||||
system[1].text = `${prompt}<env>${system[1].text.split('<env>').pop()}`
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = calculateTokenCount(
|
||||
messages as MessageParam[],
|
||||
system,
|
||||
tools as Tool[]
|
||||
);
|
||||
|
||||
let model;
|
||||
if (config.CUSTOM_ROUTER_PATH) {
|
||||
try {
|
||||
const customRouter = require(config.CUSTOM_ROUTER_PATH);
|
||||
req.tokenCount = tokenCount; // Pass token count to custom router
|
||||
model = await customRouter(req, config, {
|
||||
event
|
||||
});
|
||||
} catch (e: any) {
|
||||
req.log.error(`failed to load custom router: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
model = await getUseModel(req, tokenCount, config, lastMessageUsage);
|
||||
}
|
||||
req.body.model = model;
|
||||
} catch (error: any) {
|
||||
req.log.error(`Error in router middleware: ${error.message}`);
|
||||
req.body.model = config.Router!.default;
|
||||
}
|
||||
return;
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitReturns": false,
|
||||
"noImplicitThis": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
@@ -0,0 +1,308 @@
|
||||
import { safeStorage } from "electron";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname } from "node:path";
|
||||
import initSqlJs from "sql.js";
|
||||
import { API_KEYS_DB_FILE } from "./constants";
|
||||
import type { ApiKeyConfig, ApiKeyLimitConfig } from "../shared/app";
|
||||
|
||||
type SqlDatabase = InstanceType<Awaited<ReturnType<typeof initSqlJs>>["Database"]>;
|
||||
type SqlValue = number | string | Uint8Array | null;
|
||||
type QueryExecResult = {
|
||||
columns: string[];
|
||||
values: SqlValue[][];
|
||||
};
|
||||
|
||||
type StoredApiKeyRow = {
|
||||
createdAt: string;
|
||||
encryptedKey: string;
|
||||
encryption: string;
|
||||
expiresAt: string;
|
||||
id: string;
|
||||
limitsJson: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
const plainStorage = "plain";
|
||||
const safeStorageEncryption = "electron-safe-storage";
|
||||
|
||||
class ApiKeyStore {
|
||||
private database?: SqlDatabase;
|
||||
private initPromise?: Promise<SqlDatabase>;
|
||||
|
||||
constructor(private readonly dbFile: string) {}
|
||||
|
||||
async list(): Promise<ApiKeyConfig[]> {
|
||||
const database = await this.getDatabase();
|
||||
const rows = readRows(
|
||||
database.exec(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
encrypted_key,
|
||||
encryption,
|
||||
created_at,
|
||||
expires_at,
|
||||
limits_json
|
||||
FROM api_keys
|
||||
ORDER BY rowid
|
||||
`)[0]
|
||||
);
|
||||
|
||||
return uniqueApiKeyConfigs(rows.map(toApiKeyConfig));
|
||||
}
|
||||
|
||||
async replace(apiKeys: ApiKeyConfig[]): Promise<ApiKeyConfig[]> {
|
||||
const normalized = uniqueApiKeyConfigs(apiKeys);
|
||||
const database = await this.getDatabase();
|
||||
const statement = database.prepare(`
|
||||
INSERT INTO api_keys (
|
||||
id,
|
||||
name,
|
||||
encrypted_key,
|
||||
encryption,
|
||||
created_at,
|
||||
expires_at,
|
||||
limits_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
try {
|
||||
database.run("BEGIN TRANSACTION");
|
||||
database.run("DELETE FROM api_keys");
|
||||
for (const apiKey of normalized) {
|
||||
const stored = encryptApiKey(apiKey.key);
|
||||
statement.run([
|
||||
apiKey.id,
|
||||
apiKey.name ?? "",
|
||||
stored.value,
|
||||
stored.encryption,
|
||||
apiKey.createdAt,
|
||||
apiKey.expiresAt ?? "",
|
||||
apiKey.limits ? JSON.stringify(apiKey.limits) : ""
|
||||
]);
|
||||
}
|
||||
database.run("COMMIT");
|
||||
this.persist();
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
try {
|
||||
database.run("ROLLBACK");
|
||||
} catch {
|
||||
// Ignore rollback errors; the original write error is more useful.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
statement.free();
|
||||
}
|
||||
}
|
||||
|
||||
private async getDatabase(): Promise<SqlDatabase> {
|
||||
if (this.database) {
|
||||
return this.database;
|
||||
}
|
||||
|
||||
this.initPromise ??= this.open();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async open(): Promise<SqlDatabase> {
|
||||
mkdirSync(dirname(this.dbFile), { recursive: true });
|
||||
const wasmFile = requireFromHere.resolve("sql.js/dist/sql-wasm.wasm");
|
||||
const SQL = await initSqlJs({ locateFile: () => wasmFile });
|
||||
const database = existsSync(this.dbFile)
|
||||
? new SQL.Database(readFileSync(this.dbFile))
|
||||
: new SQL.Database();
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
encrypted_key TEXT NOT NULL,
|
||||
encryption TEXT NOT NULL DEFAULT '${plainStorage}',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL DEFAULT '',
|
||||
limits_json TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS api_keys_created_at_idx ON api_keys(created_at);
|
||||
`);
|
||||
|
||||
this.database = database;
|
||||
this.persist();
|
||||
return database;
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (!this.database) {
|
||||
return;
|
||||
}
|
||||
writeFileSync(this.dbFile, Buffer.from(this.database.export()));
|
||||
}
|
||||
}
|
||||
|
||||
export const apiKeyStore = new ApiKeyStore(API_KEYS_DB_FILE);
|
||||
|
||||
export async function loadPersistedApiKeys(): Promise<ApiKeyConfig[]> {
|
||||
return apiKeyStore.list();
|
||||
}
|
||||
|
||||
export async function replacePersistedApiKeys(apiKeys: ApiKeyConfig[]): Promise<ApiKeyConfig[]> {
|
||||
return apiKeyStore.replace(apiKeys);
|
||||
}
|
||||
|
||||
function toApiKeyConfig(row: Record<string, SqlValue>): ApiKeyConfig | undefined {
|
||||
const stored = toStoredApiKeyRow(row);
|
||||
if (!stored) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = decryptApiKey(stored.encryptedKey, stored.encryption);
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const limits = parseApiKeyLimits(stored.limitsJson);
|
||||
return {
|
||||
createdAt: stored.createdAt,
|
||||
...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}),
|
||||
id: stored.id,
|
||||
key,
|
||||
...(limits ? { limits } : {}),
|
||||
...(stored.name ? { name: stored.name } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function toStoredApiKeyRow(row: Record<string, SqlValue>): StoredApiKeyRow | undefined {
|
||||
const id = readString(row.id);
|
||||
const encryptedKey = readString(row.encrypted_key);
|
||||
const createdAt = readString(row.created_at) || new Date(0).toISOString();
|
||||
if (!id || !encryptedKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
encryptedKey,
|
||||
encryption: readString(row.encryption) || plainStorage,
|
||||
expiresAt: readString(row.expires_at) || "",
|
||||
id,
|
||||
limitsJson: readString(row.limits_json) || "",
|
||||
name: readString(row.name) || ""
|
||||
};
|
||||
}
|
||||
|
||||
function encryptApiKey(key: string): { encryption: string; value: string } {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
encryption: safeStorageEncryption,
|
||||
value: safeStorage.encryptString(key).toString("base64")
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
encryption: plainStorage,
|
||||
value: key
|
||||
};
|
||||
}
|
||||
|
||||
function decryptApiKey(value: string, encryption: string): string | undefined {
|
||||
try {
|
||||
if (encryption === safeStorageEncryption) {
|
||||
return safeStorage.decryptString(Buffer.from(value, "base64")).trim() || undefined;
|
||||
}
|
||||
return value.trim() || undefined;
|
||||
} catch (error) {
|
||||
console.warn(`[api-keys] Failed to decrypt stored API key: ${formatError(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseApiKeyLimits(value: string): ApiKeyLimitConfig | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!isObject(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const limits: ApiKeyLimitConfig = {};
|
||||
for (const key of ["ipd", "iph", "ipm", "maxRequests", "maxTokens", "quotaWindowMs", "rpd", "rph", "rpm", "tpd", "tph", "tpm", "windowMs"] as const) {
|
||||
const limit = readPositiveInteger(parsed[key]);
|
||||
if (limit) {
|
||||
limits[key] = limit;
|
||||
}
|
||||
}
|
||||
return Object.keys(limits).length ? limits : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readRows(result: QueryExecResult | undefined): Array<Record<string, SqlValue>> {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.values.map((values) => {
|
||||
const row: Record<string, SqlValue> = {};
|
||||
result.columns.forEach((column, index) => {
|
||||
row[column] = values[index] ?? null;
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueApiKeyConfigs(values: Array<ApiKeyConfig | undefined>): ApiKeyConfig[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const seenIds = new Set<string>();
|
||||
const result: ApiKeyConfig[] = [];
|
||||
for (const [index, value] of values.entries()) {
|
||||
const key = value?.key.trim();
|
||||
if (!value || !key || seenKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
const id = uniqueApiKeyId(value.id || `key-${index + 1}`, seenIds, index);
|
||||
result.push({
|
||||
createdAt: value.createdAt || new Date(0).toISOString(),
|
||||
...(value.expiresAt ? { expiresAt: value.expiresAt } : {}),
|
||||
id,
|
||||
key,
|
||||
...(value.limits ? { limits: value.limits } : {}),
|
||||
...(value.name ? { name: value.name } : {})
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function uniqueApiKeyId(id: string, seenIds: Set<string>, index: number): string {
|
||||
const base = id.trim() || `key-${index + 1}`;
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (seenIds.has(candidate)) {
|
||||
candidate = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
seenIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | undefined {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import initSqlJs from "sql.js";
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
export type SqlDatabase = InstanceType<Awaited<ReturnType<typeof initSqlJs>>["Database"]>;
|
||||
export type SqliteValue = number | string | Uint8Array | null;
|
||||
|
||||
export type HttpBackendRegistration = {
|
||||
handler: (request: IncomingMessage, response: ServerResponse) => MaybePromise<void>;
|
||||
host?: string;
|
||||
id?: string;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
export type RegisteredHttpBackend = {
|
||||
host: string;
|
||||
id: string;
|
||||
port: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type SqliteStoreOptions = {
|
||||
filename?: string;
|
||||
migrate?: (database: SqlDatabase) => MaybePromise<void>;
|
||||
};
|
||||
|
||||
export type SqliteStore = {
|
||||
database: SqlDatabase;
|
||||
dbFile: string;
|
||||
exec: (sql: string, params?: SqliteValue[]) => ReturnType<SqlDatabase["exec"]>;
|
||||
persist: () => void;
|
||||
};
|
||||
|
||||
type RegisteredBackendServer = RegisteredHttpBackend & {
|
||||
ownerId: string;
|
||||
server: Server;
|
||||
};
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
|
||||
class BackendService {
|
||||
private backends: RegisteredBackendServer[] = [];
|
||||
private sqliteStores: SqliteStoreImpl[] = [];
|
||||
private sqlJs?: Promise<Awaited<ReturnType<typeof initSqlJs>>>;
|
||||
|
||||
async registerHttpBackend(ownerId: string, backend: HttpBackendRegistration): Promise<RegisteredHttpBackend> {
|
||||
const server = http.createServer((request, response) => {
|
||||
void Promise.resolve(backend.handler(request, response)).catch((error) => {
|
||||
if (!response.headersSent) {
|
||||
sendJson(response, 500, { error: { message: formatError(error) } });
|
||||
} else {
|
||||
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const host = backend.host || "127.0.0.1";
|
||||
const port = backend.port ?? 0;
|
||||
await listen(server, port, host);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await closeServer(server);
|
||||
throw new Error(`Backend ${backend.id || ownerId} failed to start.`);
|
||||
}
|
||||
|
||||
const registered = {
|
||||
host,
|
||||
id: backend.id || `${ownerId}:backend:${this.backends.length + 1}`,
|
||||
ownerId,
|
||||
port: address.port,
|
||||
server,
|
||||
url: `http://${formatHost(host)}:${address.port}`
|
||||
};
|
||||
this.backends.push(registered);
|
||||
return {
|
||||
host: registered.host,
|
||||
id: registered.id,
|
||||
port: registered.port,
|
||||
url: registered.url
|
||||
};
|
||||
}
|
||||
|
||||
async openSqliteStore(ownerId: string, dataDir: string, options: SqliteStoreOptions = {}): Promise<SqliteStore> {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const filename = options.filename || `${sanitizeFileSegment(ownerId)}.sqlite`;
|
||||
const dbFile = path.isAbsolute(filename) ? filename : path.join(dataDir, filename);
|
||||
mkdirSync(path.dirname(dbFile), { recursive: true });
|
||||
const SQL = await this.getSqlJs();
|
||||
const database = openSqliteDatabaseWithRecovery(SQL, ownerId, dbFile);
|
||||
const store = new SqliteStoreImpl(ownerId, dbFile, database);
|
||||
this.sqliteStores.push(store);
|
||||
if (options.migrate) {
|
||||
await options.migrate(database);
|
||||
store.persist();
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async stopOwner(ownerId: string): Promise<void> {
|
||||
const backends = this.backends.filter((backend) => backend.ownerId === ownerId);
|
||||
this.backends = this.backends.filter((backend) => backend.ownerId !== ownerId);
|
||||
await Promise.all(backends.map((backend) => closeServer(backend.server)));
|
||||
|
||||
const sqliteStores = this.sqliteStores.filter((store) => store.ownerId === ownerId);
|
||||
this.sqliteStores = this.sqliteStores.filter((store) => store.ownerId !== ownerId);
|
||||
for (const store of sqliteStores) {
|
||||
try {
|
||||
store.close();
|
||||
} catch (error) {
|
||||
console.warn(`[backend:${ownerId}] SQLite store close failed: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
const ownerIds = new Set([
|
||||
...this.backends.map((backend) => backend.ownerId),
|
||||
...this.sqliteStores.map((store) => store.ownerId)
|
||||
]);
|
||||
await Promise.all([...ownerIds].map((ownerId) => this.stopOwner(ownerId)));
|
||||
}
|
||||
|
||||
private getSqlJs(): Promise<Awaited<ReturnType<typeof initSqlJs>>> {
|
||||
this.sqlJs ??= initSqlJs({ locateFile: () => requireFromHere.resolve("sql.js/dist/sql-wasm.wasm") });
|
||||
return this.sqlJs;
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteStoreImpl implements SqliteStore {
|
||||
constructor(
|
||||
readonly ownerId: string,
|
||||
readonly dbFile: string,
|
||||
readonly database: SqlDatabase
|
||||
) {}
|
||||
|
||||
exec(sql: string, params?: SqliteValue[]): ReturnType<SqlDatabase["exec"]> {
|
||||
return this.database.exec(sql, params);
|
||||
}
|
||||
|
||||
persist(): void {
|
||||
writeFileSync(this.dbFile, Buffer.from(this.database.export()));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.persist();
|
||||
this.database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const backendService = new BackendService();
|
||||
|
||||
function openSqliteDatabaseWithRecovery(
|
||||
SQL: Awaited<ReturnType<typeof initSqlJs>>,
|
||||
ownerId: string,
|
||||
dbFile: string
|
||||
): SqlDatabase {
|
||||
if (!existsSync(dbFile)) {
|
||||
return new SQL.Database();
|
||||
}
|
||||
|
||||
try {
|
||||
const database = new SQL.Database(readFileSync(dbFile));
|
||||
assertSqliteDatabaseIntegrity(database);
|
||||
return database;
|
||||
} catch (error) {
|
||||
if (!isSqliteOpenCorruptionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const backupFile = nextCorruptSqliteBackupPath(dbFile);
|
||||
copyFileSync(dbFile, backupFile);
|
||||
console.warn(
|
||||
`[backend:${ownerId}] SQLite store is corrupt and will be rebuilt: ${dbFile}. ` +
|
||||
`Corrupt copy saved to ${backupFile}. Error: ${formatError(error)}`
|
||||
);
|
||||
return new SQL.Database();
|
||||
}
|
||||
}
|
||||
|
||||
function assertSqliteDatabaseIntegrity(database: SqlDatabase): void {
|
||||
const result = database.exec("PRAGMA integrity_check;");
|
||||
const status = result[0]?.values?.[0]?.[0];
|
||||
if (status !== "ok") {
|
||||
throw new Error(`database disk image is malformed: integrity_check returned ${String(status || "no result")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSqliteOpenCorruptionError(error: unknown): boolean {
|
||||
const message = formatError(error).toLowerCase();
|
||||
return message.includes("database disk image is malformed") ||
|
||||
message.includes("integrity_check") ||
|
||||
message.includes("file is not a database") ||
|
||||
message.includes("not an sqlite database");
|
||||
}
|
||||
|
||||
function nextCorruptSqliteBackupPath(dbFile: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const base = `${dbFile}.corrupt-${timestamp}`;
|
||||
if (!existsSync(base)) {
|
||||
return base;
|
||||
}
|
||||
for (let index = 1; index < 1000; index += 1) {
|
||||
const candidate = `${base}-${index}`;
|
||||
if (!existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return `${base}-${process.pid}`;
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, statusCode: number, body: unknown): void {
|
||||
response.writeHead(statusCode, { "content-type": "application/json" });
|
||||
response.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function listen(server: Server, port: number, host: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
server.close(() => resolve());
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function sanitizeFileSegment(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "backend";
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron";
|
||||
import { IPC_CHANNELS } from "../shared/ipc-channels";
|
||||
import type { BuiltInBrowserState } from "../shared/app";
|
||||
|
||||
contextBridge.exposeInMainWorld("ccrBrowser", {
|
||||
back: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserBack, tabId) as Promise<BuiltInBrowserState>,
|
||||
closeTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserCloseTab, tabId) as Promise<BuiltInBrowserState>,
|
||||
forward: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserForward, tabId) as Promise<BuiltInBrowserState>,
|
||||
getState: () => ipcRenderer.invoke(IPC_CHANNELS.browserGetState) as Promise<BuiltInBrowserState>,
|
||||
navigate: (url: string, tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNavigate, url, tabId) as Promise<BuiltInBrowserState>,
|
||||
newTab: (url?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNewTab, url) as Promise<BuiltInBrowserState>,
|
||||
reload: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserReload, tabId) as Promise<BuiltInBrowserState>,
|
||||
selectTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserSelectTab, tabId) as Promise<BuiltInBrowserState>,
|
||||
onStateChanged: (callback: (state: BuiltInBrowserState) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, state: BuiltInBrowserState) => callback(state);
|
||||
ipcRenderer.on(IPC_CHANNELS.browserStateChanged, handler);
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.browserStateChanged, handler);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
ipcMain,
|
||||
Menu,
|
||||
screen,
|
||||
session,
|
||||
shell,
|
||||
WebContentsView,
|
||||
type ContextMenuParams,
|
||||
type IpcMainInvokeEvent,
|
||||
type MenuItemConstructorOptions
|
||||
} from "electron";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { AppConfig, BuiltInBrowserState, BuiltInBrowserTabState, GatewayPluginAppConfig, InstalledBrowserApp } from "../shared/app";
|
||||
import { IPC_CHANNELS } from "../shared/ipc-channels";
|
||||
import { APP_NAME } from "./constants";
|
||||
import { pluginService } from "./plugins/service";
|
||||
import { proxyService } from "./proxy/service";
|
||||
|
||||
type BrowserTab = BuiltInBrowserTabState & {
|
||||
view: WebContentsView;
|
||||
};
|
||||
|
||||
const browserChromeHeight = 82;
|
||||
const browserHomeUrl = "about:blank";
|
||||
const browserPartition = "persist:ccr-built-in-browser";
|
||||
const titleBarHeight = 46;
|
||||
|
||||
class BuiltInBrowserService {
|
||||
private activeTabId?: string;
|
||||
private apps: InstalledBrowserApp[] = [];
|
||||
private proxyConfigKey = "";
|
||||
private tabOrder: string[] = [];
|
||||
private tabs = new Map<string, BrowserTab>();
|
||||
private window?: BrowserWindow;
|
||||
|
||||
constructor() {
|
||||
this.registerIpcHandlers();
|
||||
}
|
||||
|
||||
async open(config: AppConfig): Promise<void> {
|
||||
await this.syncProxy(config);
|
||||
|
||||
const window = this.window && !this.window.isDestroyed() ? this.window : this.createWindow();
|
||||
if (this.tabs.size === 0) {
|
||||
this.createTab(browserHomeUrl);
|
||||
}
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
this.layoutActiveView();
|
||||
window.show();
|
||||
window.focus();
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
async syncProxy(config: AppConfig): Promise<void> {
|
||||
this.syncApps(config);
|
||||
|
||||
const browserSession = session.fromPartition(browserPartition);
|
||||
if (config.proxy.enabled) {
|
||||
await proxyService.refreshUpstreamProxyFromCurrentSystem();
|
||||
}
|
||||
const proxyStatus = proxyService.getStatus();
|
||||
const shouldUseProxy = Boolean(
|
||||
config.proxy.enabled &&
|
||||
proxyStatus.state === "running" &&
|
||||
proxyStatus.endpoint
|
||||
);
|
||||
const proxyConfig = shouldUseProxy
|
||||
? {
|
||||
mode: "fixed_servers" as const,
|
||||
proxyBypassRules: "<-loopback>",
|
||||
proxyRules: electronProxyRules(proxyStatus.endpoint)
|
||||
}
|
||||
: {
|
||||
mode: "direct" as const
|
||||
};
|
||||
const nextKey = JSON.stringify(proxyConfig);
|
||||
if (nextKey === this.proxyConfigKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
await browserSession.setProxy(proxyConfig);
|
||||
await browserSession.forceReloadProxyConfig();
|
||||
this.proxyConfigKey = nextKey;
|
||||
}
|
||||
|
||||
private syncApps(config: AppConfig): void {
|
||||
const nextApps = resolveInstalledBrowserApps(config, pluginService.getApps());
|
||||
if (JSON.stringify(nextApps) === JSON.stringify(this.apps)) {
|
||||
return;
|
||||
}
|
||||
this.apps = nextApps;
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
async clearProxy(): Promise<void> {
|
||||
const browserSession = session.fromPartition(browserPartition);
|
||||
await browserSession.setProxy({ mode: "direct" });
|
||||
await browserSession.forceReloadProxyConfig();
|
||||
this.proxyConfigKey = JSON.stringify({ mode: "direct" });
|
||||
}
|
||||
|
||||
private registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.browserGetState, (event) => {
|
||||
this.assertBrowserSender(event);
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserNewTab, (event, url?: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.createTab(url);
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserSelectTab, (event, tabId: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.selectTab(tabId);
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserCloseTab, (event, tabId: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.closeTab(tabId);
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserNavigate, (event, url: string, tabId?: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.navigate(url, tabId);
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserBack, (event, tabId?: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.getTab(tabId)?.view.webContents.navigationHistory.goBack();
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserForward, (event, tabId?: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.getTab(tabId)?.view.webContents.navigationHistory.goForward();
|
||||
return this.getState();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.browserReload, (event, tabId?: string) => {
|
||||
this.assertBrowserSender(event);
|
||||
this.getTab(tabId)?.view.webContents.reload();
|
||||
return this.getState();
|
||||
});
|
||||
}
|
||||
|
||||
private createWindow(): BrowserWindow {
|
||||
const { height: availableHeight, width: availableWidth } = screen.getPrimaryDisplay().workAreaSize;
|
||||
const minHeight = 560;
|
||||
const minWidth = 820;
|
||||
const height = fitWindowSize(840, minHeight, availableHeight - 48);
|
||||
const width = fitWindowSize(1180, minWidth, availableWidth - 48);
|
||||
|
||||
const window = new BrowserWindow({
|
||||
height,
|
||||
minHeight,
|
||||
minWidth,
|
||||
show: false,
|
||||
title: `${APP_NAME} APPs`,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hiddenInset" as const,
|
||||
trafficLightPosition: {
|
||||
x: 16,
|
||||
y: Math.round((titleBarHeight - 14) / 2)
|
||||
}
|
||||
}
|
||||
: { titleBarStyle: "hidden" as const }),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, "browser-preload.js"),
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
},
|
||||
width
|
||||
});
|
||||
|
||||
this.window = window;
|
||||
window.on("resize", () => this.layoutActiveView());
|
||||
window.on("closed", () => {
|
||||
this.destroyTabs();
|
||||
if (this.window === window) {
|
||||
this.window = undefined;
|
||||
}
|
||||
});
|
||||
window.once("ready-to-show", () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.show();
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("did-finish-load", () => this.sendState());
|
||||
|
||||
void window.loadURL(this.resolveRendererUrl("pages/browser/index.html"));
|
||||
return window;
|
||||
}
|
||||
|
||||
private createTab(url = browserHomeUrl): BrowserTab {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
partition: browserPartition,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
}
|
||||
});
|
||||
const tab: BrowserTab = {
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
id: randomUUID(),
|
||||
isLoading: false,
|
||||
title: "New Tab",
|
||||
url: normalizeBrowserUrl(url),
|
||||
view
|
||||
};
|
||||
|
||||
this.tabs.set(tab.id, tab);
|
||||
this.tabOrder.push(tab.id);
|
||||
this.configureTab(tab);
|
||||
this.window?.contentView.addChildView(view);
|
||||
view.setVisible(false);
|
||||
this.selectTab(tab.id);
|
||||
void view.webContents.loadURL(tab.url);
|
||||
this.sendState();
|
||||
return tab;
|
||||
}
|
||||
|
||||
private configureTab(tab: BrowserTab): void {
|
||||
const { webContents } = tab.view;
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (isHttpUrl(url)) {
|
||||
this.createTab(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
webContents.on("context-menu", (_event, params) => {
|
||||
this.showContextMenu(tab, params);
|
||||
});
|
||||
webContents.on("page-title-updated", (_event, title) => {
|
||||
tab.title = title || titleFromUrl(tab.url);
|
||||
this.sendState();
|
||||
});
|
||||
webContents.on("did-start-loading", () => {
|
||||
tab.isLoading = true;
|
||||
this.updateTabNavigationState(tab);
|
||||
});
|
||||
webContents.on("did-stop-loading", () => {
|
||||
tab.isLoading = false;
|
||||
this.updateTabNavigationState(tab);
|
||||
});
|
||||
webContents.on("did-navigate", (_event, url) => {
|
||||
tab.url = url;
|
||||
tab.title = tab.title || titleFromUrl(url);
|
||||
if (tab.id === this.activeTabId) {
|
||||
this.layoutActiveView();
|
||||
}
|
||||
this.updateTabNavigationState(tab);
|
||||
});
|
||||
webContents.on("did-navigate-in-page", (_event, url) => {
|
||||
tab.url = url;
|
||||
if (tab.id === this.activeTabId) {
|
||||
this.layoutActiveView();
|
||||
}
|
||||
this.updateTabNavigationState(tab);
|
||||
});
|
||||
webContents.on("did-fail-load", (_event, errorCode, _errorDescription, validatedUrl) => {
|
||||
if (errorCode !== -3) {
|
||||
tab.isLoading = false;
|
||||
tab.url = validatedUrl || tab.url;
|
||||
if (tab.id === this.activeTabId) {
|
||||
this.layoutActiveView();
|
||||
}
|
||||
this.updateTabNavigationState(tab);
|
||||
}
|
||||
});
|
||||
webContents.on("destroyed", () => {
|
||||
if (this.tabs.get(tab.id) === tab) {
|
||||
this.tabs.delete(tab.id);
|
||||
this.tabOrder = this.tabOrder.filter((id) => id !== tab.id);
|
||||
if (this.activeTabId === tab.id) {
|
||||
this.activeTabId = this.tabOrder[0];
|
||||
}
|
||||
this.sendState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showContextMenu(tab: BrowserTab, params: ContextMenuParams): void {
|
||||
const window = this.window;
|
||||
if (!window || window.isDestroyed() || tab.view.webContents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { webContents } = tab.view;
|
||||
const { navigationHistory } = webContents;
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
click: () => navigationHistory.goBack(),
|
||||
enabled: navigationHistory.canGoBack(),
|
||||
label: "Back"
|
||||
},
|
||||
{
|
||||
click: () => navigationHistory.goForward(),
|
||||
enabled: navigationHistory.canGoForward(),
|
||||
label: "Forward"
|
||||
},
|
||||
{
|
||||
click: () => webContents.reload(),
|
||||
label: "Reload"
|
||||
},
|
||||
{ type: "separator" }
|
||||
];
|
||||
|
||||
if (isHttpUrl(params.linkURL)) {
|
||||
template.push(
|
||||
{
|
||||
click: () => this.createTab(params.linkURL),
|
||||
label: "Open Link in New Tab"
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
void shell.openExternal(params.linkURL);
|
||||
},
|
||||
label: "Open Link in System Browser"
|
||||
},
|
||||
{
|
||||
click: () => clipboard.writeText(params.linkURL),
|
||||
label: "Copy Link"
|
||||
},
|
||||
{ type: "separator" }
|
||||
);
|
||||
}
|
||||
|
||||
if (params.isEditable) {
|
||||
template.push(
|
||||
{
|
||||
click: () => webContents.cut(),
|
||||
enabled: params.editFlags.canCut,
|
||||
label: "Cut"
|
||||
},
|
||||
{
|
||||
click: () => webContents.copy(),
|
||||
enabled: params.editFlags.canCopy,
|
||||
label: "Copy"
|
||||
},
|
||||
{
|
||||
click: () => webContents.paste(),
|
||||
enabled: params.editFlags.canPaste,
|
||||
label: "Paste"
|
||||
},
|
||||
{
|
||||
click: () => webContents.selectAll(),
|
||||
enabled: params.editFlags.canSelectAll,
|
||||
label: "Select All"
|
||||
},
|
||||
{ type: "separator" }
|
||||
);
|
||||
} else if (params.selectionText.trim()) {
|
||||
template.push(
|
||||
{
|
||||
click: () => webContents.copy(),
|
||||
label: "Copy"
|
||||
},
|
||||
{ type: "separator" }
|
||||
);
|
||||
}
|
||||
|
||||
template.push(
|
||||
{
|
||||
click: () => webContents.openDevTools({ mode: "detach" }),
|
||||
enabled: !webContents.isDevToolsOpened(),
|
||||
label: "Open DevTools"
|
||||
},
|
||||
{
|
||||
click: () => webContents.inspectElement(params.x, params.y),
|
||||
label: "Inspect Element"
|
||||
}
|
||||
);
|
||||
|
||||
Menu.buildFromTemplate(template).popup({ window });
|
||||
}
|
||||
|
||||
private selectTab(tabId: string): void {
|
||||
const selected = this.tabs.get(tabId);
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeTabId = tabId;
|
||||
for (const tab of this.tabs.values()) {
|
||||
tab.view.setVisible(tab.id === tabId && !isBrowserHomeUrl(tab.url));
|
||||
}
|
||||
this.window?.contentView.addChildView(selected.view);
|
||||
this.layoutActiveView();
|
||||
if (isBrowserHomeUrl(selected.url)) {
|
||||
this.window?.webContents.focus();
|
||||
} else {
|
||||
selected.view.webContents.focus();
|
||||
}
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
private closeTab(tabId: string): void {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.window?.contentView.removeChildView(tab.view);
|
||||
this.tabs.delete(tabId);
|
||||
this.tabOrder = this.tabOrder.filter((id) => id !== tabId);
|
||||
tab.view.webContents.close({ waitForBeforeUnload: false });
|
||||
|
||||
if (this.tabOrder.length === 0) {
|
||||
this.createTab(browserHomeUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTabId === tabId) {
|
||||
this.selectTab(this.tabOrder[Math.max(0, this.tabOrder.length - 1)]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
private navigate(url: string, tabId?: string): void {
|
||||
const tab = this.getTab(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = normalizeBrowserUrl(url);
|
||||
tab.url = nextUrl;
|
||||
if (tab.id === this.activeTabId) {
|
||||
this.layoutActiveView();
|
||||
}
|
||||
void tab.view.webContents.loadURL(nextUrl);
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
private getTab(tabId?: string): BrowserTab | undefined {
|
||||
return this.tabs.get(tabId || this.activeTabId || "");
|
||||
}
|
||||
|
||||
private updateTabNavigationState(tab: BrowserTab): void {
|
||||
tab.canGoBack = tab.view.webContents.navigationHistory.canGoBack();
|
||||
tab.canGoForward = tab.view.webContents.navigationHistory.canGoForward();
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
private layoutActiveView(): void {
|
||||
const window = this.window;
|
||||
const activeTab = this.getTab();
|
||||
if (!window || window.isDestroyed() || !activeTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBrowserHomeUrl(activeTab.url)) {
|
||||
activeTab.view.setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = window.getContentBounds();
|
||||
activeTab.view.setVisible(true);
|
||||
activeTab.view.setBounds({
|
||||
height: Math.max(0, height - browserChromeHeight),
|
||||
width,
|
||||
x: 0,
|
||||
y: browserChromeHeight
|
||||
});
|
||||
}
|
||||
|
||||
private getState(): BuiltInBrowserState {
|
||||
return {
|
||||
activeTabId: this.activeTabId,
|
||||
apps: this.apps.map((app) => ({ ...app })),
|
||||
tabs: this.tabOrder
|
||||
.map((id) => this.tabs.get(id))
|
||||
.filter((tab): tab is BrowserTab => Boolean(tab))
|
||||
.map(({ view: _view, ...tab }) => tab)
|
||||
};
|
||||
}
|
||||
|
||||
private sendState(): void {
|
||||
const window = this.window;
|
||||
if (!window || window.isDestroyed() || window.webContents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
window.webContents.send(IPC_CHANNELS.browserStateChanged, this.getState());
|
||||
}
|
||||
|
||||
private assertBrowserSender(event: IpcMainInvokeEvent): void {
|
||||
if (!this.window || event.sender !== this.window.webContents) {
|
||||
throw new Error("Browser controls are only available from the built-in browser window.");
|
||||
}
|
||||
}
|
||||
|
||||
private destroyTabs(): void {
|
||||
for (const tab of this.tabs.values()) {
|
||||
if (!tab.view.webContents.isDestroyed()) {
|
||||
tab.view.webContents.close({ waitForBeforeUnload: false });
|
||||
}
|
||||
}
|
||||
this.tabs.clear();
|
||||
this.tabOrder = [];
|
||||
this.activeTabId = undefined;
|
||||
}
|
||||
|
||||
private resolveRendererUrl(relativeHtmlPath: string): string {
|
||||
return pathToFileURL(path.join(__dirname, "../renderer", relativeHtmlPath)).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export const builtInBrowserService = new BuiltInBrowserService();
|
||||
|
||||
function fitWindowSize(preferred: number, minimum: number, available: number): number {
|
||||
return Math.max(minimum, Math.min(preferred, available > 0 ? available : preferred));
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBrowserUrl(value: string | undefined): string {
|
||||
const trimmed = (value || "").trim();
|
||||
if (!trimmed) {
|
||||
return browserHomeUrl;
|
||||
}
|
||||
if (isBrowserHomeUrl(trimmed)) {
|
||||
return browserHomeUrl;
|
||||
}
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(trimmed) || trimmed.includes(".")) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
return `https://www.google.com/search?q=${encodeURIComponent(trimmed)}`;
|
||||
}
|
||||
|
||||
function resolveInstalledBrowserApps(config: AppConfig, runtimeApps: InstalledBrowserApp[]): InstalledBrowserApp[] {
|
||||
const apps = new Map<string, InstalledBrowserApp>();
|
||||
for (const plugin of config.plugins ?? []) {
|
||||
if (plugin.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const configuredApps = plugin.apps?.length ? plugin.apps : defaultBrowserAppsForPlugin(plugin);
|
||||
for (const app of configuredApps) {
|
||||
const normalized = normalizeConfiguredBrowserApp(plugin.id, app, apps.size + 1);
|
||||
if (normalized) {
|
||||
apps.set(`${normalized.pluginId}:${normalized.id}`, normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const app of runtimeApps) {
|
||||
apps.set(`${app.pluginId}:${app.id}`, { ...app });
|
||||
}
|
||||
return [...apps.values()];
|
||||
}
|
||||
|
||||
function defaultBrowserAppsForPlugin(plugin: AppConfig["plugins"][number]): GatewayPluginAppConfig[] {
|
||||
if (plugin.id !== "claude-design") {
|
||||
return [];
|
||||
}
|
||||
const config = isPlainRecord(plugin.config) ? plugin.config : {};
|
||||
const host = stringValue(config.host) || "claude.ai";
|
||||
return [
|
||||
{
|
||||
description: "Open Claude Design through the CCR browser proxy.",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url: `https://${host}/design`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeConfiguredBrowserApp(pluginId: string, app: GatewayPluginAppConfig, index: number): InstalledBrowserApp | undefined {
|
||||
const name = app.name?.trim();
|
||||
const url = app.url?.trim();
|
||||
if (!name || !url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(app.description?.trim() ? { description: app.description.trim() } : {}),
|
||||
...(app.icon?.trim() ? { icon: app.icon.trim() } : {}),
|
||||
id: app.id?.trim() || sanitizeBrowserAppId(`${name}-${url}`) || `app-${index}`,
|
||||
name,
|
||||
pluginId,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sanitizeBrowserAppId(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isBrowserHomeUrl(value: string): boolean {
|
||||
return value.trim().toLowerCase() === browserHomeUrl;
|
||||
}
|
||||
|
||||
function titleFromUrl(value: string): string {
|
||||
try {
|
||||
return new URL(value).hostname || "New Tab";
|
||||
} catch {
|
||||
return "New Tab";
|
||||
}
|
||||
}
|
||||
|
||||
function electronProxyRules(endpoint: string): string {
|
||||
const parsed = new URL(endpoint);
|
||||
const host = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname;
|
||||
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
||||
return `http=${host}:${port};https=${host}:${port}`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
export { IPC_CHANNELS } from "../shared/ipc-channels";
|
||||
|
||||
export const APP_NAME = "Claude Code Router";
|
||||
export const CONFIGDIR = path.join(app.getPath("home"), ".claude-code-router");
|
||||
export const CONFIG_FILE = path.join(CONFIGDIR, "config.json");
|
||||
export const DATADIR = app.getPath("userData");
|
||||
export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite");
|
||||
export const CERTDIR = path.join(DATADIR, "certs");
|
||||
export const PROXY_CA_CERT_FILE = path.join(CERTDIR, "ca.pem");
|
||||
export const PROXY_CA_KEY_FILE = path.join(CERTDIR, "key.pem");
|
||||
export const GATEWAY_CONFIG_FILE = path.join(CONFIGDIR, "gateway.config.json");
|
||||
export const REQUEST_LOGS_DB_FILE = path.join(DATADIR, "request-logs.sqlite");
|
||||
export const RAW_TRACE_SPOOL_DIR = path.join(DATADIR, "raw-trace-spool");
|
||||
export const USAGE_DB_FILE = path.join(DATADIR, "usage.sqlite");
|
||||
@@ -0,0 +1,66 @@
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
import { appDeepLinkProtocol, createProviderDeepLinkRequest, isAppDeepLinkUrl } from "../shared/deep-link";
|
||||
import type { ProviderDeepLinkRequest } from "../shared/app";
|
||||
import { IPC_CHANNELS } from "./constants";
|
||||
import windowsManager from "./windows";
|
||||
|
||||
class DeepLinkService {
|
||||
private pendingProviderRequests: ProviderDeepLinkRequest[] = [];
|
||||
|
||||
register(): void {
|
||||
this.registerProtocolClient();
|
||||
|
||||
app.on("open-url", (event, url) => {
|
||||
event.preventDefault();
|
||||
this.handleUrl(url);
|
||||
});
|
||||
}
|
||||
|
||||
consumePendingProviderRequests(): ProviderDeepLinkRequest[] {
|
||||
const requests = [...this.pendingProviderRequests];
|
||||
this.pendingProviderRequests = [];
|
||||
return requests;
|
||||
}
|
||||
|
||||
handleArgv(argv: string[]): boolean {
|
||||
const urls = argv.filter((item) => isAppDeepLinkUrl(item));
|
||||
for (const url of urls) {
|
||||
this.handleUrl(url);
|
||||
}
|
||||
return urls.length > 0;
|
||||
}
|
||||
|
||||
handleUrl(url: string): void {
|
||||
const request = createProviderDeepLinkRequest(url);
|
||||
this.pendingProviderRequests.push(request);
|
||||
if (this.pendingProviderRequests.length > 20) {
|
||||
this.pendingProviderRequests = this.pendingProviderRequests.slice(-20);
|
||||
}
|
||||
|
||||
if (!app.isReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
windowsManager.showMainWindow();
|
||||
windowsManager.broadcast(IPC_CHANNELS.appProviderDeepLink, request);
|
||||
}
|
||||
|
||||
private registerProtocolClient(): void {
|
||||
try {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(appDeepLinkProtocol, process.execPath, [path.resolve(process.argv[1])]);
|
||||
return;
|
||||
}
|
||||
app.setAsDefaultProtocolClient(appDeepLinkProtocol);
|
||||
} catch (error) {
|
||||
console.warn(`[deep-link] Failed to register ${appDeepLinkProtocol} protocol: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export const deepLinkService = new DeepLinkService();
|
||||
@@ -0,0 +1,346 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AppConfig, RouterConfig, RouterFallbackConfig, RouterRule } from "../../shared/app";
|
||||
|
||||
type HeaderValue = string | string[] | undefined;
|
||||
|
||||
export type MutableRequestLike = {
|
||||
body: Record<string, unknown>;
|
||||
headers: Record<string, HeaderValue>;
|
||||
log: Pick<Console, "debug" | "error" | "info" | "warn">;
|
||||
method: string;
|
||||
sessionId?: string;
|
||||
tokenCount?: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ClaudeCodeRouteDecision = {
|
||||
fallback?: RouterFallbackConfig;
|
||||
model?: string;
|
||||
reason: string;
|
||||
sessionId?: string;
|
||||
tokenCount: number;
|
||||
};
|
||||
|
||||
type ConfiguredRouteDecision = {
|
||||
fallback?: RouterFallbackConfig;
|
||||
model?: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
|
||||
export class ClaudeCodeRouterPlugin {
|
||||
private readonly event = new EventEmitter();
|
||||
|
||||
constructor(private readonly config: AppConfig) {}
|
||||
|
||||
async routeRequest(input: {
|
||||
body: Record<string, unknown>;
|
||||
headers: Record<string, HeaderValue>;
|
||||
method: string;
|
||||
url: string;
|
||||
}): Promise<{ body: Record<string, unknown>; decision: ClaudeCodeRouteDecision }> {
|
||||
const body = cloneRecord(input.body);
|
||||
const sessionId = resolveSessionId(body, input.headers);
|
||||
const tokenCount = calculateTokenCount(body.messages, body.system, body.tools);
|
||||
const request: MutableRequestLike = {
|
||||
body,
|
||||
headers: input.headers,
|
||||
log: console,
|
||||
method: input.method,
|
||||
sessionId,
|
||||
tokenCount,
|
||||
url: input.url
|
||||
};
|
||||
|
||||
const customModel = await this.resolveCustomRoute(request);
|
||||
const configuredDecision = resolveConfiguredRouteDecision(request, this.config, tokenCount);
|
||||
const routedModel = customModel ?? configuredDecision.model;
|
||||
if (routedModel) {
|
||||
body.model = routedModel;
|
||||
}
|
||||
|
||||
return {
|
||||
body,
|
||||
decision: {
|
||||
fallback: customModel ? this.config.Router.fallback : configuredDecision.fallback,
|
||||
model: routedModel,
|
||||
reason: customModel ? "custom-router" : configuredDecision.reason,
|
||||
sessionId,
|
||||
tokenCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
countTokens(body: Record<string, unknown>) {
|
||||
return {
|
||||
input_tokens: calculateTokenCount(body.messages, body.system, body.tools)
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveCustomRoute(request: MutableRequestLike): Promise<string | undefined> {
|
||||
const routerPath = this.config.CUSTOM_ROUTER_PATH;
|
||||
if (!routerPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
delete requireFromHere.cache[requireFromHere.resolve(routerPath)];
|
||||
const loaded = requireFromHere(routerPath) as unknown;
|
||||
const customRouter = typeof loaded === "function" ? loaded : readDefaultFunction(loaded);
|
||||
if (!customRouter) {
|
||||
request.log.warn(`Custom router does not export a function: ${routerPath}`);
|
||||
return undefined;
|
||||
}
|
||||
const result = await customRouter(request, this.config, { event: this.event });
|
||||
return normalizeRouteSelector(typeof result === "string" ? result : undefined);
|
||||
} catch (error) {
|
||||
request.log.error(`Failed to load custom router "${routerPath}": ${formatError(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredRouteDecision(
|
||||
request: MutableRequestLike,
|
||||
config: AppConfig,
|
||||
tokenCount: number
|
||||
): ConfiguredRouteDecision {
|
||||
const requestedModel = readString(request.body.model);
|
||||
const explicitModel = normalizeRouteSelector(requestedModel);
|
||||
if (explicitModel && isKnownInlineRoute(explicitModel, config)) {
|
||||
return { fallback: config.Router.fallback, model: explicitModel, reason: "inline-model" };
|
||||
}
|
||||
|
||||
const router = config.Router;
|
||||
const rules = router.rules ?? [];
|
||||
for (const rule of rules) {
|
||||
const decision = resolveRouterRule(rule, request, tokenCount, router);
|
||||
if (decision) {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
return { fallback: router.fallback, model: normalizeRouteSelector(router.default) ?? explicitModel, reason: "default" };
|
||||
}
|
||||
|
||||
function resolveRouterRule(
|
||||
rule: RouterRule,
|
||||
request: MutableRequestLike,
|
||||
tokenCount: number,
|
||||
router: RouterConfig
|
||||
): ConfiguredRouteDecision | undefined {
|
||||
if (!rule.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const fallback = rule.fallback ?? router.fallback;
|
||||
|
||||
if (rule.type === "subagent") {
|
||||
const subagentModel = extractSubagentModel(request.body.system);
|
||||
return subagentModel ? { fallback, model: normalizeRouteSelector(subagentModel), reason: "subagent" } : undefined;
|
||||
}
|
||||
|
||||
const target = normalizeRouteSelector(rule.target);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rule.type === "always") {
|
||||
return { fallback, model: target, reason: routerRuleReason(rule) };
|
||||
}
|
||||
|
||||
if (rule.type === "long-context") {
|
||||
const threshold = rule.threshold || router.longContextThreshold || 200000;
|
||||
return tokenCount > threshold ? { fallback, model: target, reason: routerRuleReason(rule) } : undefined;
|
||||
}
|
||||
|
||||
if (rule.type === "model-prefix") {
|
||||
const pattern = readString(rule.pattern);
|
||||
const requestedModel = readString(request.body.model);
|
||||
return pattern && requestedModel?.startsWith(pattern)
|
||||
? { fallback, model: target, reason: routerRuleReason(rule) }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (rule.type === "thinking") {
|
||||
return request.body.thinking ? { fallback, model: target, reason: routerRuleReason(rule) } : undefined;
|
||||
}
|
||||
|
||||
if (rule.type === "web-search") {
|
||||
return hasWebSearchTool(request.body.tools) ? { fallback, model: target, reason: routerRuleReason(rule) } : undefined;
|
||||
}
|
||||
|
||||
if (rule.type === "image") {
|
||||
return hasImageContent(request.body.messages) ? { fallback, model: target, reason: routerRuleReason(rule) } : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function routerRuleReason(rule: RouterRule): string {
|
||||
if (rule.id.startsWith("legacy-")) {
|
||||
return rule.id.replace(/^legacy-/, "");
|
||||
}
|
||||
return `rule:${rule.id}`;
|
||||
}
|
||||
|
||||
export function normalizeRouteSelector(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const commaIndex = trimmed.indexOf(",");
|
||||
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
|
||||
const provider = trimmed.slice(0, commaIndex).trim();
|
||||
const model = trimmed.slice(commaIndex + 1).trim();
|
||||
return provider && model ? `${provider}/${model}` : undefined;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isKnownInlineRoute(model: string | undefined, config: AppConfig): boolean {
|
||||
if (!model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const separator = model.indexOf("/");
|
||||
if (separator <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providerName = model.slice(0, separator).trim().toLowerCase();
|
||||
return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName);
|
||||
}
|
||||
|
||||
function calculateTokenCount(messages: unknown, system: unknown, tools: unknown): number {
|
||||
return countMessageTokens(messages) + countSystemTokens(system) + countToolTokens(tools);
|
||||
}
|
||||
|
||||
function countMessageTokens(messages: unknown): number {
|
||||
if (!Array.isArray(messages)) {
|
||||
return 0;
|
||||
}
|
||||
return messages.reduce((total, message) => total + countUnknownTokens(message), 0);
|
||||
}
|
||||
|
||||
function countSystemTokens(system: unknown): number {
|
||||
return countUnknownTokens(system);
|
||||
}
|
||||
|
||||
function countToolTokens(tools: unknown): number {
|
||||
if (!Array.isArray(tools)) {
|
||||
return 0;
|
||||
}
|
||||
return tools.reduce((total, tool) => total + countUnknownTokens(tool), 0);
|
||||
}
|
||||
|
||||
function countUnknownTokens(value: unknown): number {
|
||||
if (typeof value === "string") {
|
||||
return estimateTextTokens(value);
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce((total, item) => total + countUnknownTokens(item), 0);
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
total += estimateTextTokens(key);
|
||||
total += countUnknownTokens(item);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function estimateTextTokens(text: string): number {
|
||||
const asciiWords = text.match(/[A-Za-z0-9_]+|[^\sA-Za-z0-9_]/g)?.length ?? 0;
|
||||
const cjkChars = text.match(/[\u3400-\u9fff]/g)?.length ?? 0;
|
||||
return Math.max(1, Math.ceil((asciiWords + cjkChars) * 1.15));
|
||||
}
|
||||
|
||||
function extractSubagentModel(system: unknown): string | undefined {
|
||||
if (!Array.isArray(system) || system.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
const second = system[1];
|
||||
if (!isRecord(second) || typeof second.text !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = second.text.match(/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s);
|
||||
if (!match?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
second.text = second.text.replace(match[0], "");
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
function hasWebSearchTool(tools: unknown): boolean {
|
||||
return Array.isArray(tools) && tools.some((tool) => isRecord(tool) && readString(tool.type)?.startsWith("web_search"));
|
||||
}
|
||||
|
||||
function hasImageContent(messages: unknown): boolean {
|
||||
if (!Array.isArray(messages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return messages.some((message) => JSON.stringify(message).includes("\"image\""));
|
||||
}
|
||||
|
||||
function resolveSessionId(body: Record<string, unknown>, headers: Record<string, HeaderValue>): string | undefined {
|
||||
const fromHeader = readHeader(headers["x-claude-code-session-id"]) || readHeader(headers["x-claude-session-id"]);
|
||||
if (fromHeader) {
|
||||
return fromHeader;
|
||||
}
|
||||
|
||||
const metadata = body.metadata;
|
||||
if (isRecord(metadata) && typeof metadata.user_id === "string") {
|
||||
const parts = metadata.user_id.split("_session_");
|
||||
if (parts.length > 1) {
|
||||
return parts.at(-1);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readDefaultFunction(value: unknown): ((...args: unknown[]) => unknown) | undefined {
|
||||
if (isRecord(value) && typeof value.default === "function") {
|
||||
return value.default as (...args: unknown[]) => unknown;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readHeader(value: HeaderValue): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0]?.trim();
|
||||
}
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { AppConfig, GatewayProviderConfig } from "../../shared/app";
|
||||
import { normalizeRouteSelector } from "./claude-code-router-plugin";
|
||||
|
||||
type CodexAppRequestPreparationInput = {
|
||||
body?: Buffer;
|
||||
client?: string;
|
||||
config: AppConfig;
|
||||
headers: Record<string, string>;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type CodexAppRequestPreparation = {
|
||||
body?: Buffer;
|
||||
diagnostic: "model-remembered" | "model-rewritten";
|
||||
routedModel?: string;
|
||||
};
|
||||
|
||||
type RememberedCodexModel = {
|
||||
expiresAt: number;
|
||||
model: string;
|
||||
providerName?: string;
|
||||
};
|
||||
|
||||
const codexModelRewriteSessionTtlMs = 6 * 60 * 60 * 1000;
|
||||
const rememberedCodexModels = new Map<string, RememberedCodexModel>();
|
||||
|
||||
export function prepareCodexAppRequest(input: CodexAppRequestPreparationInput): CodexAppRequestPreparation | undefined {
|
||||
if (!isOpenAIResponsesPath(input.path) || !isCodexClient(input.client, input.headers)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const body = parseJsonObjectSafe(input.body);
|
||||
if (!body) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headerModel = normalizeRouteSelector(readHeader(input.headers, "x-target-model"));
|
||||
const bodyModel = normalizeRouteSelector(stringValue(body.model));
|
||||
const requestedModel = headerModel || bodyModel;
|
||||
if (!requestedModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionKeys = codexSessionKeys(input.headers, body);
|
||||
const provider = providerForModelSelector(input.config, requestedModel);
|
||||
if (provider && isRememberableGatewayModel(requestedModel)) {
|
||||
rememberCodexModel(sessionKeys, {
|
||||
expiresAt: Date.now() + codexModelRewriteSessionTtlMs,
|
||||
model: requestedModel,
|
||||
providerName: provider.name
|
||||
});
|
||||
return {
|
||||
diagnostic: "model-remembered"
|
||||
};
|
||||
}
|
||||
|
||||
if (!isCodexInternalModel(requestedModel)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const remembered = findRememberedCodexModel(sessionKeys);
|
||||
if (!remembered || remembered.model === requestedModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextBody = {
|
||||
...body,
|
||||
model: remembered.model
|
||||
};
|
||||
if (headerModel) {
|
||||
input.headers["x-target-model"] = remembered.model;
|
||||
}
|
||||
|
||||
return {
|
||||
body: Buffer.from(`${JSON.stringify(nextBody)}\n`, "utf8"),
|
||||
diagnostic: "model-rewritten",
|
||||
routedModel: remembered.model
|
||||
};
|
||||
}
|
||||
|
||||
function rememberCodexModel(keys: string[], value: RememberedCodexModel): void {
|
||||
pruneRememberedCodexModels();
|
||||
for (const key of keys) {
|
||||
rememberedCodexModels.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function findRememberedCodexModel(keys: string[]): RememberedCodexModel | undefined {
|
||||
pruneRememberedCodexModels();
|
||||
for (const key of keys) {
|
||||
const value = rememberedCodexModels.get(key);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pruneRememberedCodexModels(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of rememberedCodexModels) {
|
||||
if (value.expiresAt <= now) {
|
||||
rememberedCodexModels.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function codexSessionKeys(headers: Record<string, string>, body: Record<string, unknown>): string[] {
|
||||
const accountId =
|
||||
readHeader(headers, "x-codex-account-id") ||
|
||||
stringValue(body.account_id) ||
|
||||
stringValue(body.accountId);
|
||||
const sessionId =
|
||||
readHeader(headers, "x-codex-session-id") ||
|
||||
readHeader(headers, "x-codex-conversation-id") ||
|
||||
readHeader(headers, "x-codex-thread-id") ||
|
||||
readHeader(headers, "x-agent-session-id") ||
|
||||
stringValue(body.session_id) ||
|
||||
stringValue(body.sessionId) ||
|
||||
stringValue(body.conversation_id) ||
|
||||
stringValue(body.conversationId);
|
||||
const keys: string[] = [];
|
||||
if (accountId && sessionId) {
|
||||
keys.push(`codex:${accountId}:${sessionId}`);
|
||||
}
|
||||
if (sessionId) {
|
||||
keys.push(`codex:session:${sessionId}`);
|
||||
}
|
||||
if (accountId) {
|
||||
keys.push(`codex:account:${accountId}`);
|
||||
}
|
||||
keys.push("codex");
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
function providerForModelSelector(config: AppConfig, selector: string): GatewayProviderConfig | undefined {
|
||||
const providerName = selector.split("/", 1)[0]?.trim().toLowerCase();
|
||||
if (!providerName || providerName === selector.toLowerCase()) {
|
||||
return undefined;
|
||||
}
|
||||
return config.Providers.find((provider) =>
|
||||
provider.name.trim().toLowerCase() === providerName ||
|
||||
provider.provider?.trim().toLowerCase() === providerName
|
||||
);
|
||||
}
|
||||
|
||||
function isRememberableGatewayModel(model: string): boolean {
|
||||
const slashIndex = model.indexOf("/");
|
||||
return slashIndex > 0 && slashIndex < model.length - 1;
|
||||
}
|
||||
|
||||
function isCodexInternalModel(model: string): boolean {
|
||||
if (model.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
const normalized = model.trim().toLowerCase();
|
||||
return /^gpt(?:[-_]|$)/.test(normalized) || /^o\d(?:[-_]|$)/.test(normalized);
|
||||
}
|
||||
|
||||
function isOpenAIResponsesPath(path: string): boolean {
|
||||
const normalized = path.toLowerCase();
|
||||
return normalized === "/v1/responses" || normalized === "/responses" || normalized.endsWith("/responses");
|
||||
}
|
||||
|
||||
function isCodexClient(client: string | undefined, headers: Record<string, string>): boolean {
|
||||
if (client?.toLowerCase().includes("codex")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
readHeader(headers, "x-codex-session-id") ||
|
||||
readHeader(headers, "x-codex-conversation-id") ||
|
||||
readHeader(headers, "x-codex-thread-id") ||
|
||||
readHeader(headers, "x-codex-account-id")
|
||||
);
|
||||
}
|
||||
|
||||
function parseJsonObjectSafe(buffer: Buffer | undefined): Record<string, unknown> | undefined {
|
||||
if (!buffer || buffer.byteLength === 0) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.toString("utf8")) as unknown;
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readHeader(headers: Record<string, string>, name: string): string | undefined {
|
||||
const target = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === target && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions } from "electron";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { builtInBrowserService } from "./built-in-browser";
|
||||
import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config";
|
||||
import { API_KEYS_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants";
|
||||
import { deepLinkService } from "./deep-link";
|
||||
import { gatewayService } from "./gateway/service";
|
||||
import { probeGatewayProvider } from "./provider-probe";
|
||||
import { applyProfileConfig } from "./profile-service";
|
||||
import { ensureProxyCertificateAuthority } from "./proxy/certificates";
|
||||
import { proxyService } from "./proxy/service";
|
||||
import { getAgentAnalysis, getRequestLogs } from "./request-log-store";
|
||||
import trayController from "./tray-controller";
|
||||
import { getUsageStats } from "./usage-store";
|
||||
import windowsManager from "./windows";
|
||||
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
|
||||
|
||||
const pluginMarketplace: PluginMarketplaceEntry[] = [
|
||||
{
|
||||
apps: [
|
||||
{
|
||||
description: "Open Claude Design through the CCR browser proxy.",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url: "https://claude.ai/design"
|
||||
}
|
||||
],
|
||||
capabilities: ["Wrapper runtime", "Browser app", "Claude Design", "Model routing"],
|
||||
dependencies: [],
|
||||
description: "Routes Claude Design traffic through the local CCR wrapper backend with configurable model routing.",
|
||||
id: "claude-design",
|
||||
modulePath: path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs"),
|
||||
name: "Claude Design"
|
||||
},
|
||||
{
|
||||
capabilities: ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"],
|
||||
dependencies: [],
|
||||
description: "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.",
|
||||
id: "cursor-proxy",
|
||||
modulePath: path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs"),
|
||||
name: "Cursor Proxy"
|
||||
}
|
||||
];
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
||||
return {
|
||||
apiKeysDbFile: API_KEYS_DB_FILE,
|
||||
configDir: CONFIGDIR,
|
||||
configFile: CONFIG_FILE,
|
||||
dataDir: DATADIR,
|
||||
gatewayConfigFile: GATEWAY_CONFIG_FILE,
|
||||
name: APP_NAME,
|
||||
platform: process.platform,
|
||||
requestLogsDbFile: REQUEST_LOGS_DB_FILE,
|
||||
usageDbFile: USAGE_DB_FILE,
|
||||
version: app.getVersion()
|
||||
} satisfies AppInfo;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetAgentAnalysis, (_event, filter?: AgentAnalysisFilter) => getAgentAnalysis(filter));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetGatewayStatus, () => gatewayService.getStatus());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProxyCertificateStatus, () => proxyService.getCertificateStatus());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNetworkCaptures());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => pluginMarketplace);
|
||||
ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter));
|
||||
ipcMain.handle(IPC_CHANNELS.appInstallProxyCertificate, () => proxyService.installCertificate());
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => {
|
||||
const config = await loadAppConfig();
|
||||
await ensureBuiltInBrowserProxyReady(config);
|
||||
await builtInBrowserService.open(config);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appCloseTray, () => {
|
||||
trayController.hidePopover();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appClearProxyNetworkCaptures, () => proxyService.clearNetworkCaptures());
|
||||
ipcMain.handle(IPC_CHANNELS.appSetProxyNetworkCaptureEnabled, (_event, enabled: boolean) => {
|
||||
return proxyService.setNetworkCaptureEnabled(Boolean(enabled));
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appSelectPluginDirectory, async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
const options: OpenDialogOptions = {
|
||||
buttonLabel: "Select plugin",
|
||||
properties: ["openDirectory"],
|
||||
title: "Select plugin directory"
|
||||
};
|
||||
const result = window ? await dialog.showOpenDialog(window, options) : await dialog.showOpenDialog(options);
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return inspectPluginDirectory(result.filePaths[0]);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenExternal, async (_event, url: string) => {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
throw new Error("Only http and https URLs can be opened.");
|
||||
}
|
||||
await shell.openExternal(parsed.toString());
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appApplyProfile, async () => {
|
||||
const config = await loadAppConfig();
|
||||
return applyProfileConfig(config);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appProbeProvider, (_event, request: GatewayProviderProbeRequest) => {
|
||||
return probeGatewayProvider(request);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appQuit, () => {
|
||||
app.quit();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appRevealProxyCertificate, () => {
|
||||
ensureProxyCertificateAuthority();
|
||||
shell.showItemInFolder(PROXY_CA_CERT_FILE);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig) => {
|
||||
const previousConfig = await loadAppConfig();
|
||||
if (config.proxy.enabled) {
|
||||
const certificateStatus = await proxyService.getCertificateStatus();
|
||||
if (!certificateStatus.trusted) {
|
||||
throw new Error(certificateStatus.message);
|
||||
}
|
||||
}
|
||||
const savedConfig = await saveAppConfig(config);
|
||||
let runtimeStatus = gatewayService.getStatus();
|
||||
if (shouldRestartForRuntimeChange(previousConfig, savedConfig)) {
|
||||
runtimeStatus = await gatewayService.start(savedConfig);
|
||||
} else {
|
||||
gatewayService.updateConfig(savedConfig);
|
||||
}
|
||||
await applyProfileIfServiceRunning(savedConfig, runtimeStatus);
|
||||
await builtInBrowserService.syncProxy(savedConfig);
|
||||
await trayController.refreshIconFromConfig(savedConfig);
|
||||
return savedConfig;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appSaveApiKeys, async (_event, apiKeys: ApiKeyConfig[]) => {
|
||||
const savedConfig = await saveApiKeysConfig(apiKeys);
|
||||
gatewayService.updateConfig(savedConfig);
|
||||
await applyProfileIfServiceRunning(savedConfig, gatewayService.getStatus());
|
||||
return savedConfig;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => {
|
||||
const config = await loadAppConfig();
|
||||
const status = await gatewayService.start(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
await builtInBrowserService.syncProxy(config);
|
||||
return status;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appStartGateway, async () => {
|
||||
const config = await loadAppConfig();
|
||||
const status = await gatewayService.start(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
await builtInBrowserService.syncProxy(config);
|
||||
return status;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appStopGateway, async () => {
|
||||
const status = await gatewayService.stop();
|
||||
await builtInBrowserService.clearProxy();
|
||||
return status;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appSetTrayDetailOpen, (_event, open: boolean, provider?: string) => {
|
||||
trayController.setDetailOpen(Boolean(open), provider);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appShowMainWindow, () => {
|
||||
trayController.hidePopover();
|
||||
windowsManager.showMainWindow();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appRestartProxy, async () => {
|
||||
const config = await loadAppConfig();
|
||||
const status = await gatewayService.start(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
await builtInBrowserService.syncProxy(config);
|
||||
return proxyService.getStatus();
|
||||
});
|
||||
|
||||
async function applyProfileIfServiceRunning(config: AppConfig, status: GatewayStatus): Promise<void> {
|
||||
if (status.state !== "running") {
|
||||
return;
|
||||
}
|
||||
logProfileApplyResult(await applyProfileConfig(config));
|
||||
}
|
||||
|
||||
function logProfileApplyResult(result: ProfileApplyResult): void {
|
||||
for (const client of result.clients) {
|
||||
if (client.ok) {
|
||||
continue;
|
||||
}
|
||||
console.warn(`[profile:${client.client}] ${client.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
type ProxyConnectProbeResult = {
|
||||
detail?: string;
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
const browserProxyConnectProbeTarget = "claude.ai:443";
|
||||
const proxyConnectProbeTimeoutMs = 3000;
|
||||
|
||||
async function ensureBuiltInBrowserProxyReady(config: AppConfig): Promise<void> {
|
||||
if (!config.proxy.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let proxyStatus = proxyService.getStatus();
|
||||
if (proxyStatus.state === "running" && proxyStatus.endpoint) {
|
||||
const probe = await probeProxyConnect(proxyStatus.endpoint);
|
||||
if (probe.ok) {
|
||||
return;
|
||||
}
|
||||
console.warn(`[browser] Proxy CONNECT probe failed at ${proxyStatus.endpoint}; restarting proxy: ${probe.detail || "unknown error"}`);
|
||||
}
|
||||
|
||||
const status = await gatewayService.start(config);
|
||||
if (status.state === "error") {
|
||||
throw new Error(status.lastError || "Failed to start proxy mode.");
|
||||
}
|
||||
|
||||
proxyStatus = proxyService.getStatus();
|
||||
if (proxyStatus.state !== "running" || !proxyStatus.endpoint) {
|
||||
throw new Error(proxyStatus.lastError || "Proxy mode is not running.");
|
||||
}
|
||||
|
||||
const probe = await probeProxyConnect(proxyStatus.endpoint);
|
||||
if (probe.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[browser] Shared proxy endpoint ${proxyStatus.endpoint} still does not accept CONNECT after restart; starting dedicated proxy endpoint: ${probe.detail || "unknown error"}`
|
||||
);
|
||||
const dedicatedProxyConfig = await createBuiltInBrowserProxyConfig(config);
|
||||
const dedicatedProxyStatus = await proxyService.start(dedicatedProxyConfig);
|
||||
if (dedicatedProxyStatus.state !== "running" || !dedicatedProxyStatus.endpoint) {
|
||||
throw new Error(dedicatedProxyStatus.lastError || "Failed to start the dedicated proxy endpoint for the built-in browser.");
|
||||
}
|
||||
|
||||
const dedicatedProbe = await probeProxyConnect(dedicatedProxyStatus.endpoint);
|
||||
if (!dedicatedProbe.ok) {
|
||||
const detail = dedicatedProbe.detail ? `: ${dedicatedProbe.detail}` : "";
|
||||
throw new Error(`Proxy mode is running at ${dedicatedProxyStatus.endpoint}, but HTTPS CONNECT is not available${detail}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBuiltInBrowserProxyConfig(config: AppConfig): Promise<AppConfig> {
|
||||
return {
|
||||
...config,
|
||||
proxy: {
|
||||
...config.proxy,
|
||||
host: "127.0.0.1",
|
||||
port: await findAvailableLoopbackPort(),
|
||||
systemProxy: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function findAvailableLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Failed to allocate a local proxy port."));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function probeProxyConnect(endpoint: string): Promise<ProxyConnectProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(endpoint);
|
||||
} catch {
|
||||
resolve({ detail: `Invalid proxy endpoint: ${endpoint}`, ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
resolve({ detail: `Invalid proxy port in endpoint: ${endpoint}`, ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
const socket = net.connect({ host, port });
|
||||
let response = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: ProxyConnectProbeResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
const parseResponse = (): ProxyConnectProbeResult | undefined => {
|
||||
const firstLine = response.split(/\r?\n/, 1)[0]?.trim();
|
||||
if (!firstLine) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^HTTP\/1\.[01]\s+200\b/i.test(firstLine)) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (/^HTTP\/1\.[01]\s+\d{3}\b/i.test(firstLine)) {
|
||||
return { detail: firstLine, ok: false };
|
||||
}
|
||||
return { detail: `Unexpected response: ${firstLine}`, ok: false };
|
||||
};
|
||||
|
||||
socket.setTimeout(proxyConnectProbeTimeoutMs, () => {
|
||||
finish({ detail: `Timed out after ${proxyConnectProbeTimeoutMs}ms`, ok: false });
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
finish({ detail: formatError(error), ok: false });
|
||||
});
|
||||
socket.once("connect", () => {
|
||||
socket.write(`CONNECT ${browserProxyConnectProbeTarget} HTTP/1.1\r\nHost: ${browserProxyConnectProbeTarget}\r\n\r\n`);
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
response += chunk.toString("latin1");
|
||||
const result = parseResponse();
|
||||
if (result) {
|
||||
finish(result);
|
||||
}
|
||||
});
|
||||
socket.once("close", () => {
|
||||
finish(parseResponse() ?? { detail: "Connection closed without a CONNECT response", ok: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRestartForRuntimeChange(previousConfig: AppConfig, nextConfig: AppConfig): boolean {
|
||||
return (
|
||||
previousConfig.gateway.enabled !== nextConfig.gateway.enabled ||
|
||||
previousConfig.gateway.host !== nextConfig.gateway.host ||
|
||||
previousConfig.gateway.port !== nextConfig.gateway.port ||
|
||||
previousConfig.gateway.coreHost !== nextConfig.gateway.coreHost ||
|
||||
previousConfig.gateway.corePort !== nextConfig.gateway.corePort ||
|
||||
previousConfig.proxy.enabled !== nextConfig.proxy.enabled ||
|
||||
previousConfig.proxy.host !== nextConfig.proxy.host ||
|
||||
previousConfig.proxy.mode !== nextConfig.proxy.mode ||
|
||||
previousConfig.proxy.port !== nextConfig.proxy.port ||
|
||||
previousConfig.proxy.systemProxy !== nextConfig.proxy.systemProxy ||
|
||||
JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) ||
|
||||
JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) ||
|
||||
JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) ||
|
||||
JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) ||
|
||||
JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) ||
|
||||
JSON.stringify(previousConfig.virtualModelProfiles) !== JSON.stringify(nextConfig.virtualModelProfiles)
|
||||
);
|
||||
}
|
||||
|
||||
function inspectPluginDirectory(directory: string): PluginDirectorySelection {
|
||||
const manifest = readFirstJson([
|
||||
path.join(directory, "plugin.json"),
|
||||
path.join(directory, "ccr-plugin.json"),
|
||||
path.join(directory, ".ccr-plugin", "plugin.json"),
|
||||
path.join(directory, ".codex-plugin", "plugin.json")
|
||||
]);
|
||||
const packageJson = readFirstJson([path.join(directory, "package.json")]);
|
||||
const moduleValue =
|
||||
readString(manifest?.module) ||
|
||||
readString(manifest?.main) ||
|
||||
readString(manifest?.path) ||
|
||||
readString(readRecord(packageJson?.ccr)?.module) ||
|
||||
readString(readRecord(packageJson?.ccrPlugin)?.module) ||
|
||||
readString(packageJson?.main);
|
||||
const id =
|
||||
pluginIdValue(readString(manifest?.id) || readString(manifest?.key) || readString(packageJson?.name)) ||
|
||||
pluginIdValue(path.basename(directory)) ||
|
||||
"plugin";
|
||||
const name = readString(manifest?.name) || readString(packageJson?.displayName) || readString(packageJson?.name);
|
||||
const apps = readPluginApps(manifest, packageJson);
|
||||
return {
|
||||
...(apps.length ? { apps } : {}),
|
||||
dependencies: readPluginDependencies(directory, manifest, packageJson),
|
||||
directory,
|
||||
id,
|
||||
modulePath: resolvePluginDirectoryModule(directory, moduleValue),
|
||||
...(name ? { name } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function readPluginApps(
|
||||
manifest: Record<string, unknown> | undefined,
|
||||
packageJson: Record<string, unknown> | undefined
|
||||
): GatewayPluginAppConfig[] {
|
||||
const values = [
|
||||
manifest?.apps,
|
||||
readRecord(manifest?.ccr)?.apps,
|
||||
readRecord(manifest?.ccrPlugin)?.apps,
|
||||
readRecord(packageJson?.ccr)?.apps,
|
||||
readRecord(packageJson?.ccrPlugin)?.apps
|
||||
];
|
||||
const apps = values.flatMap(parsePluginApps);
|
||||
const byId = new Map<string, GatewayPluginAppConfig>();
|
||||
for (const app of apps) {
|
||||
const key = app.id || `${app.name}:${app.url}`;
|
||||
if (byId.has(key)) {
|
||||
continue;
|
||||
}
|
||||
byId.set(key, app);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function parsePluginApps(value: unknown): GatewayPluginAppConfig[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map(parsePluginAppItem).filter((item): item is GatewayPluginAppConfig => Boolean(item));
|
||||
}
|
||||
|
||||
function parsePluginAppItem(value: unknown): GatewayPluginAppConfig | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const name = readString(record.name) || readString(record.title);
|
||||
const url = readString(record.url) || readString(record.href) || readString(record.target);
|
||||
if (!name || !url) {
|
||||
return undefined;
|
||||
}
|
||||
const id = pluginIdValue(readString(record.id) || name);
|
||||
const description = readString(record.description);
|
||||
const icon = readString(record.icon);
|
||||
return {
|
||||
...(description ? { description } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
...(id ? { id } : {}),
|
||||
name,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
function readPluginDependencies(
|
||||
directory: string,
|
||||
manifest: Record<string, unknown> | undefined,
|
||||
packageJson: Record<string, unknown> | undefined
|
||||
): PluginDependency[] {
|
||||
const values = [
|
||||
manifest?.dependencies,
|
||||
manifest?.pluginDependencies,
|
||||
readRecord(manifest?.ccr)?.dependencies,
|
||||
readRecord(manifest?.ccrPlugin)?.dependencies,
|
||||
readRecord(packageJson?.ccr)?.dependencies,
|
||||
readRecord(packageJson?.ccrPlugin)?.dependencies
|
||||
];
|
||||
const dependencies = values.flatMap((value) => parsePluginDependencies(value, directory));
|
||||
const byId = new Map<string, PluginDependency>();
|
||||
for (const dependency of dependencies) {
|
||||
if (!dependency.id || byId.has(dependency.id)) {
|
||||
continue;
|
||||
}
|
||||
byId.set(dependency.id, dependency);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function parsePluginDependencies(value: unknown, directory: string): PluginDependency[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => parsePluginDependencyItem(item, directory)).filter((item): item is PluginDependency => Boolean(item));
|
||||
}
|
||||
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.map(([id, item]) => parsePluginDependencyEntry(id, item, directory))
|
||||
.filter((item): item is PluginDependency => Boolean(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parsePluginDependencyEntry(idValue: string, value: unknown, directory: string): PluginDependency | undefined {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return parsePluginDependencyItem({ id: idValue, ...(value as Record<string, unknown>) }, directory);
|
||||
}
|
||||
|
||||
const id = pluginIdValue(idValue);
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const specifier = readString(value);
|
||||
const modulePath = specifier && looksLikeDependencyModulePath(specifier) ? resolveDependencyModulePath(directory, specifier) : undefined;
|
||||
return {
|
||||
id,
|
||||
...(modulePath ? { modulePath } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function parsePluginDependencyItem(value: unknown, directory: string): PluginDependency | undefined {
|
||||
if (typeof value === "string") {
|
||||
const id = pluginIdValue(value);
|
||||
return id ? { id } : undefined;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = pluginIdValue(readString(record.id) || readString(record.key) || readString(record.name));
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const moduleValue = readString(record.module) || readString(record.path) || readString(record.modulePath);
|
||||
const modulePath = moduleValue ? resolveDependencyModulePath(directory, moduleValue) : undefined;
|
||||
const name = readString(record.name);
|
||||
return {
|
||||
id,
|
||||
...(modulePath ? { modulePath } : {}),
|
||||
...(name ? { name } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDependencyModulePath(directory: string, value: string): string {
|
||||
if (value === "~" || value.startsWith("~/")) {
|
||||
return path.join(app.getPath("home"), value.slice(2));
|
||||
}
|
||||
return path.isAbsolute(value) ? value : path.join(directory, value);
|
||||
}
|
||||
|
||||
function looksLikeDependencyModulePath(value: string): boolean {
|
||||
return value.startsWith(".") || value.startsWith("/") || value.startsWith("~");
|
||||
}
|
||||
|
||||
function resolvePluginDirectoryModule(directory: string, moduleValue: string | undefined): string {
|
||||
if (moduleValue) {
|
||||
return path.isAbsolute(moduleValue) ? moduleValue : path.join(directory, moduleValue);
|
||||
}
|
||||
|
||||
for (const filename of ["index.cjs", "index.mjs", "index.js", "plugin.cjs", "plugin.mjs", "plugin.js"]) {
|
||||
const candidate = path.join(directory, filename);
|
||||
if (isFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
function readFirstJson(files: string[]): Record<string, unknown> | undefined {
|
||||
for (const file of files) {
|
||||
if (!isFile(file)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid plugin metadata and fall back to directory inference.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function pluginIdValue(value: string | undefined): string {
|
||||
return value?.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "";
|
||||
}
|
||||
|
||||
function isFile(file: string): boolean {
|
||||
try {
|
||||
return existsSync(file) && statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { app } from "electron";
|
||||
import { loadAppConfig } from "./config";
|
||||
import { deepLinkService } from "./deep-link";
|
||||
import { gatewayService } from "./gateway/service";
|
||||
import "./ipc";
|
||||
import { applyProfileConfig } from "./profile-service";
|
||||
import { proxyService } from "./proxy/service";
|
||||
import trayController from "./tray-controller";
|
||||
import windowsManager from "./windows";
|
||||
|
||||
deepLinkService.register();
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
const quitProxyRestoreTimeoutMs = 30_000;
|
||||
let quitPrepared = false;
|
||||
let stoppingForQuit = false;
|
||||
let ensureProxyModePromise: Promise<void> | undefined;
|
||||
let startServicesPromise: Promise<void> | undefined;
|
||||
let stopForQuitPromise: Promise<void> | undefined;
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
deepLinkService.handleArgv(process.argv);
|
||||
|
||||
app.on("second-instance", (_event, argv) => {
|
||||
windowsManager.showMainWindow();
|
||||
deepLinkService.handleArgv(argv);
|
||||
queueEnsureConfiguredProxyModeActive("second-instance");
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
windowsManager.createMainWindow();
|
||||
trayController.start();
|
||||
void startConfiguredServices("startup");
|
||||
|
||||
app.on("activate", () => {
|
||||
windowsManager.showMainWindow();
|
||||
queueEnsureConfiguredProxyModeActive("activate");
|
||||
});
|
||||
});
|
||||
|
||||
app.on("before-quit", (event) => {
|
||||
if (quitPrepared) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
prepareAndQuit();
|
||||
});
|
||||
|
||||
app.on("will-quit", (event) => {
|
||||
if (quitPrepared) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
prepareAndQuit();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
process.once("SIGINT", () => handleTerminationSignal("SIGINT"));
|
||||
process.once("SIGTERM", () => handleTerminationSignal("SIGTERM"));
|
||||
|
||||
function prepareAndQuit(): void {
|
||||
if (stoppingForQuit) {
|
||||
return;
|
||||
}
|
||||
|
||||
stoppingForQuit = true;
|
||||
void stopServicesForQuit().finally(() => {
|
||||
quitPrepared = true;
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
function handleTerminationSignal(signal: NodeJS.Signals): void {
|
||||
if (stoppingForQuit) {
|
||||
return;
|
||||
}
|
||||
|
||||
stoppingForQuit = true;
|
||||
void stopServicesForQuit().finally(() => {
|
||||
quitPrepared = true;
|
||||
app.exit(signal === "SIGINT" ? 130 : 143);
|
||||
});
|
||||
}
|
||||
|
||||
function stopServicesForQuit(): Promise<void> {
|
||||
if (!stopForQuitPromise) {
|
||||
stopForQuitPromise = gatewayService
|
||||
.stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs })
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
console.error(`Failed to stop services before quit: ${formatError(error)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
trayController.destroy();
|
||||
});
|
||||
}
|
||||
return stopForQuitPromise;
|
||||
}
|
||||
|
||||
function startConfiguredServices(reason: string): Promise<void> {
|
||||
if (!startServicesPromise) {
|
||||
startServicesPromise = loadAppConfig()
|
||||
.then(async (config) => {
|
||||
const status = await gatewayService.start(config);
|
||||
if (status.state === "error") {
|
||||
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
|
||||
}
|
||||
if (status.state === "running") {
|
||||
const profileResult = await applyProfileConfig(config);
|
||||
for (const client of profileResult.clients) {
|
||||
if (!client.ok) {
|
||||
console.error(`Failed to apply ${client.client} profile during ${reason}: ${client.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.proxy.enabled && config.proxy.systemProxy) {
|
||||
const proxyStatus = await proxyService.ensureSystemProxyActive();
|
||||
logProxySystemProxyIssue(reason, proxyStatus);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
trayController.refreshUsageTitle();
|
||||
startServicesPromise = undefined;
|
||||
});
|
||||
}
|
||||
return startServicesPromise;
|
||||
}
|
||||
|
||||
function queueEnsureConfiguredProxyModeActive(reason: string): void {
|
||||
void (startServicesPromise ?? Promise.resolve())
|
||||
.then(() => ensureConfiguredProxyModeActive(reason))
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ensure proxy mode during ${reason}: ${formatError(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConfiguredProxyModeActive(reason: string): Promise<void> {
|
||||
if (stoppingForQuit) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!ensureProxyModePromise) {
|
||||
ensureProxyModePromise = loadAppConfig()
|
||||
.then(async (config) => {
|
||||
if (!config.proxy.enabled || !config.proxy.systemProxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyStatus = proxyService.getStatus();
|
||||
if (proxyStatus.state !== "running") {
|
||||
await startConfiguredServices(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const ensuredStatus = await proxyService.ensureSystemProxyActive();
|
||||
logProxySystemProxyIssue(reason, ensuredStatus);
|
||||
})
|
||||
.finally(() => {
|
||||
ensureProxyModePromise = undefined;
|
||||
});
|
||||
}
|
||||
return ensureProxyModePromise;
|
||||
}
|
||||
|
||||
function logProxySystemProxyIssue(reason: string, status: ReturnType<typeof proxyService.getStatus>): void {
|
||||
if (status.systemProxy.state === "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
const details = status.systemProxy.lastError ? `: ${status.systemProxy.lastError}` : "";
|
||||
console.error(`Proxy mode is enabled, but system proxy is ${status.systemProxy.state} during ${reason}${details}`);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { app } from "electron";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ProxyNetworkExchange } from "../../shared/app";
|
||||
import { proxyService } from "../proxy/service";
|
||||
|
||||
type JsonPrimitive = boolean | null | number | string;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
type JsonRpcRequest = {
|
||||
id?: null | number | string;
|
||||
jsonrpc?: string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type JsonRpcResponse =
|
||||
| {
|
||||
id: null | number | string;
|
||||
jsonrpc: "2.0";
|
||||
result: JsonValue;
|
||||
}
|
||||
| {
|
||||
error: {
|
||||
code: number;
|
||||
data?: JsonValue;
|
||||
message: string;
|
||||
};
|
||||
id: null | number | string;
|
||||
jsonrpc: "2.0";
|
||||
};
|
||||
|
||||
type McpTool = {
|
||||
description: string;
|
||||
inputSchema: JsonValue;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ToolCallResult = {
|
||||
content: Array<{ text: string; type: "text" }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
const protocolVersion = "2024-11-05";
|
||||
const maxMcpRequestBytes = 2 * 1024 * 1024;
|
||||
|
||||
const networkCaptureTools: McpTool[] = [
|
||||
{
|
||||
description: "Return CCR proxy capture status, proxy status, capture limits, and current capture count.",
|
||||
inputSchema: objectSchema({}),
|
||||
name: "network_capture_status"
|
||||
},
|
||||
{
|
||||
description: "List captured network exchanges. Bodies are omitted by default to keep responses compact.",
|
||||
inputSchema: objectSchema({
|
||||
includeBodies: { description: "Include captured request and response bodies.", type: "boolean" },
|
||||
limit: { description: "Maximum number of exchanges to return.", maximum: 200, minimum: 1, type: "number" },
|
||||
query: { description: "Filter by URL, host, client, method, state, status code, or error text.", type: "string" }
|
||||
}),
|
||||
name: "network_capture_list"
|
||||
},
|
||||
{
|
||||
description: "Get one captured network exchange by id, including captured request and response bodies.",
|
||||
inputSchema: objectSchema({
|
||||
id: { description: "Capture id returned by network_capture_list.", type: "string" }
|
||||
}, ["id"]),
|
||||
name: "network_capture_get"
|
||||
},
|
||||
{
|
||||
description: "Clear all captured network exchanges.",
|
||||
inputSchema: objectSchema({}),
|
||||
name: "network_capture_clear"
|
||||
},
|
||||
{
|
||||
description: "Enable or pause future network capture recording. Traffic continues to proxy while recording is paused.",
|
||||
inputSchema: objectSchema({
|
||||
enabled: { description: "true to record future captures, false to pause recording.", type: "boolean" }
|
||||
}, ["enabled"]),
|
||||
name: "network_capture_set_enabled"
|
||||
}
|
||||
];
|
||||
|
||||
export function isNetworkCaptureMcpPath(path: string): boolean {
|
||||
return path === "/mcp" || path === "/mcp/";
|
||||
}
|
||||
|
||||
export async function handleNetworkCaptureMcpRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
response.setHeader("MCP-Protocol-Version", protocolVersion);
|
||||
|
||||
if (!proxyService.isNetworkCaptureEnabled()) {
|
||||
sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET") {
|
||||
sendJson(response, 200, {
|
||||
name: "ccr-network-capture",
|
||||
protocol: "mcp",
|
||||
transport: "streamable-http",
|
||||
endpoint: "/mcp"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== "POST") {
|
||||
sendJson(response, 405, { error: { message: "MCP endpoint only supports GET and POST." } });
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse((await readRequestBody(request, maxMcpRequestBytes)).toString("utf8")) as unknown;
|
||||
} catch (error) {
|
||||
sendJson(response, 400, jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = Array.isArray(payload) ? payload : [payload];
|
||||
const responses = await Promise.all(requests.map((item) => handleJsonRpcRequest(item)));
|
||||
const filtered = responses.filter((item): item is JsonRpcResponse => Boolean(item));
|
||||
if (filtered.length === 0) {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, Array.isArray(payload) ? filtered : filtered[0]);
|
||||
}
|
||||
|
||||
async function handleJsonRpcRequest(payload: unknown): Promise<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(payload)) {
|
||||
return jsonRpcError(null, -32600, "JSON-RPC request must be an object.");
|
||||
}
|
||||
|
||||
const request = payload as JsonRpcRequest;
|
||||
const id = request.id ?? null;
|
||||
if (request.id === undefined && request.method?.startsWith("notifications/")) {
|
||||
return undefined;
|
||||
}
|
||||
if (request.jsonrpc !== "2.0" || !request.method) {
|
||||
return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request.");
|
||||
}
|
||||
|
||||
try {
|
||||
switch (request.method) {
|
||||
case "initialize":
|
||||
return jsonRpcResult(id, {
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
protocolVersion,
|
||||
serverInfo: {
|
||||
name: "ccr-network-capture",
|
||||
title: "CCR Network Capture",
|
||||
version: app.getVersion()
|
||||
}
|
||||
});
|
||||
case "ping":
|
||||
return jsonRpcResult(id, {});
|
||||
case "tools/list":
|
||||
return jsonRpcResult(id, { tools: proxyService.isNetworkCaptureEnabled() ? networkCaptureTools : [] });
|
||||
case "tools/call":
|
||||
return jsonRpcResult(id, await callTool(request.params));
|
||||
default:
|
||||
return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`);
|
||||
}
|
||||
} catch (error) {
|
||||
return jsonRpcError(id, -32603, formatError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function callTool(params: unknown): Promise<JsonValue> {
|
||||
if (!proxyService.isNetworkCaptureEnabled()) {
|
||||
throw new Error("Network capture MCP is disabled.");
|
||||
}
|
||||
|
||||
if (!isRecord(params) || typeof params.name !== "string") {
|
||||
throw new Error("tools/call params must include a tool name.");
|
||||
}
|
||||
|
||||
const args = isRecord(params.arguments) ? params.arguments : {};
|
||||
switch (params.name) {
|
||||
case "network_capture_status":
|
||||
return toolResult(captureStatus());
|
||||
case "network_capture_list":
|
||||
return toolResult(listCaptures(args));
|
||||
case "network_capture_get":
|
||||
return toolResult(getCapture(args));
|
||||
case "network_capture_clear":
|
||||
return toolResult(clearCaptures());
|
||||
case "network_capture_set_enabled":
|
||||
return toolResult(setCaptureEnabled(args));
|
||||
default:
|
||||
throw new Error(`Unknown network capture tool: ${params.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function captureStatus(): JsonValue {
|
||||
const snapshot = proxyService.getNetworkCaptures();
|
||||
const status = proxyService.getStatus();
|
||||
return {
|
||||
captureEnabled: snapshot.captureEnabled,
|
||||
capturedAt: snapshot.capturedAt,
|
||||
count: snapshot.items.length,
|
||||
maxBodyBytes: snapshot.maxBodyBytes,
|
||||
maxEntries: snapshot.maxEntries,
|
||||
proxy: {
|
||||
endpoint: status.endpoint,
|
||||
mode: status.mode,
|
||||
state: status.state,
|
||||
systemProxy: status.systemProxy
|
||||
}
|
||||
} as JsonValue;
|
||||
}
|
||||
|
||||
function listCaptures(args: Record<string, unknown>): JsonValue {
|
||||
const snapshot = proxyService.getNetworkCaptures();
|
||||
const query = readString(args.query)?.trim().toLowerCase();
|
||||
const limit = clampInteger(readNumber(args.limit) ?? 50, 1, Math.min(snapshot.maxEntries, 200));
|
||||
const includeBodies = args.includeBodies === true;
|
||||
const items = snapshot.items
|
||||
.filter((item) => !query || captureMatchesQuery(item, query))
|
||||
.slice(0, limit)
|
||||
.map((item) => includeBodies ? item : summarizeCapture(item));
|
||||
return {
|
||||
captureEnabled: snapshot.captureEnabled,
|
||||
capturedAt: snapshot.capturedAt,
|
||||
count: items.length,
|
||||
items,
|
||||
total: snapshot.items.length
|
||||
} as JsonValue;
|
||||
}
|
||||
|
||||
function getCapture(args: Record<string, unknown>): JsonValue {
|
||||
const id = readString(args.id);
|
||||
if (!id) {
|
||||
throw new Error("network_capture_get requires id.");
|
||||
}
|
||||
|
||||
const item = proxyService.getNetworkCaptures().items.find((capture) => capture.id === id);
|
||||
if (!item) {
|
||||
throw new Error(`Network capture not found: ${id}`);
|
||||
}
|
||||
return item as unknown as JsonValue;
|
||||
}
|
||||
|
||||
function clearCaptures(): JsonValue {
|
||||
const snapshot = proxyService.clearNetworkCaptures();
|
||||
return {
|
||||
captureEnabled: snapshot.captureEnabled,
|
||||
cleared: true,
|
||||
count: snapshot.items.length
|
||||
};
|
||||
}
|
||||
|
||||
function setCaptureEnabled(args: Record<string, unknown>): JsonValue {
|
||||
if (typeof args.enabled !== "boolean") {
|
||||
throw new Error("network_capture_set_enabled requires boolean enabled.");
|
||||
}
|
||||
const snapshot = proxyService.setNetworkCaptureEnabled(args.enabled);
|
||||
return {
|
||||
captureEnabled: snapshot.captureEnabled,
|
||||
count: snapshot.items.length
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCapture(item: ProxyNetworkExchange): JsonValue {
|
||||
return {
|
||||
client: item.client,
|
||||
completedAt: item.completedAt,
|
||||
durationMs: item.durationMs,
|
||||
error: item.error,
|
||||
host: item.host,
|
||||
id: item.id,
|
||||
method: item.method,
|
||||
mode: item.mode,
|
||||
path: item.path,
|
||||
protocol: item.protocol,
|
||||
requestBody: summarizeBody(item.requestBody),
|
||||
requestHeaders: item.requestHeaders,
|
||||
responseBody: item.responseBody ? summarizeBody(item.responseBody) : undefined,
|
||||
responseHeaders: item.responseHeaders,
|
||||
routedToGateway: item.routedToGateway,
|
||||
startedAt: item.startedAt,
|
||||
state: item.state,
|
||||
statusCode: item.statusCode,
|
||||
upstreamUrl: item.upstreamUrl,
|
||||
url: item.url
|
||||
} as JsonValue;
|
||||
}
|
||||
|
||||
function summarizeBody(body: ProxyNetworkExchange["requestBody"]): JsonValue {
|
||||
const summary: Record<string, JsonValue> = {
|
||||
encoding: body.encoding,
|
||||
sizeBytes: body.sizeBytes,
|
||||
truncated: body.truncated
|
||||
};
|
||||
if (body.contentType) {
|
||||
summary.contentType = body.contentType;
|
||||
}
|
||||
if (body.decodedFrom) {
|
||||
summary.decodedFrom = body.decodedFrom;
|
||||
}
|
||||
if (body.error) {
|
||||
summary.error = body.error;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function captureMatchesQuery(item: ProxyNetworkExchange, query: string): boolean {
|
||||
return [
|
||||
item.client,
|
||||
item.error,
|
||||
item.host,
|
||||
item.method,
|
||||
item.mode,
|
||||
item.path,
|
||||
item.protocol,
|
||||
item.state,
|
||||
item.statusCode === undefined ? undefined : String(item.statusCode),
|
||||
item.upstreamUrl,
|
||||
item.url
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) => value.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function toolResult(value: JsonValue): ToolCallResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
text: JSON.stringify(value, null, 2),
|
||||
type: "text"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function objectSchema(properties: Record<string, JsonValue>, required: string[] = []): JsonValue {
|
||||
return {
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
required,
|
||||
type: "object"
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse {
|
||||
return {
|
||||
id,
|
||||
jsonrpc: "2.0",
|
||||
result
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRpcError(id: null | number | string, code: number, message: string, data?: JsonValue): JsonRpcResponse {
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
...(data === undefined ? {} : { data }),
|
||||
message
|
||||
},
|
||||
id,
|
||||
jsonrpc: "2.0"
|
||||
};
|
||||
}
|
||||
|
||||
function readRequestBody(request: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
request.on("data", (chunk) => {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += buffer.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
reject(new Error(`Request body exceeds ${maxBytes} bytes.`));
|
||||
request.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
});
|
||||
request.on("end", () => resolve(Buffer.concat(chunks, totalBytes)));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void {
|
||||
response.writeHead(statusCode, { "content-type": "application/json" });
|
||||
response.end(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function clampInteger(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { AppConfig, GatewayPluginAppConfig, GatewayPluginConfig, GatewayPluginProxyRouteConfig, InstalledBrowserApp } from "../../shared/app";
|
||||
import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "../backend-service";
|
||||
import { CONFIGDIR, DATADIR } from "../constants";
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
type PluginLogger = {
|
||||
debug: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
export type GatewayPluginRouteHandler = (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
context: GatewayPluginRouteContext
|
||||
) => MaybePromise<void>;
|
||||
|
||||
export type GatewayPluginRouteRegistration = {
|
||||
auth?: "gateway" | "none";
|
||||
handler: GatewayPluginRouteHandler;
|
||||
id?: string;
|
||||
method?: string;
|
||||
methods?: string[];
|
||||
path?: string;
|
||||
pathPrefix?: string;
|
||||
};
|
||||
|
||||
export type GatewayPluginProxyRouteRegistration = Omit<GatewayPluginProxyRouteConfig, "upstream"> & {
|
||||
upstream: string | URL | (() => string | URL);
|
||||
};
|
||||
|
||||
export type GatewayPluginHttpBackendRegistration = {
|
||||
handler: GatewayPluginRouteHandler;
|
||||
host?: string;
|
||||
id?: string;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
export type GatewayPluginRegistration = {
|
||||
apps?: GatewayPluginAppConfig[];
|
||||
coreGateway?: {
|
||||
config?: Record<string, unknown>;
|
||||
providerPlugins?: unknown[];
|
||||
virtualModelProfiles?: unknown[];
|
||||
};
|
||||
gatewayRoutes?: GatewayPluginRouteRegistration[];
|
||||
onStop?: () => MaybePromise<void>;
|
||||
proxyRoutes?: GatewayPluginProxyRouteRegistration[];
|
||||
stop?: () => MaybePromise<void>;
|
||||
virtualModelProfiles?: unknown[];
|
||||
};
|
||||
|
||||
export type GatewayPluginContext = {
|
||||
config: AppConfig;
|
||||
logger: PluginLogger;
|
||||
paths: {
|
||||
configDir: string;
|
||||
dataDir: string;
|
||||
pluginDataDir: string;
|
||||
};
|
||||
pluginConfig: unknown;
|
||||
pluginId: string;
|
||||
openSqliteStore: (options?: PluginSqliteStoreOptions) => Promise<PluginSqliteStore>;
|
||||
registerCoreGatewayProviderPlugin: (providerPlugin: unknown) => void;
|
||||
registerCoreGatewayVirtualModelProfile: (profile: unknown) => void;
|
||||
registerApp: (app: GatewayPluginAppConfig) => void;
|
||||
registerGatewayRoute: (route: GatewayPluginRouteRegistration) => void;
|
||||
registerHttpBackend: (backend: GatewayPluginHttpBackendRegistration) => Promise<RegisteredHttpBackend>;
|
||||
registerProxyRoute: (route: GatewayPluginProxyRouteRegistration) => void;
|
||||
};
|
||||
|
||||
export type GatewayPluginRouteContext = Pick<
|
||||
GatewayPluginContext,
|
||||
"config" | "logger" | "openSqliteStore" | "paths" | "pluginConfig" | "pluginId"
|
||||
> & {
|
||||
readBody: (request: IncomingMessage) => Promise<Buffer>;
|
||||
readJson: (request: IncomingMessage) => Promise<unknown>;
|
||||
sendJson: (response: ServerResponse, statusCode: number, body: unknown) => void;
|
||||
};
|
||||
|
||||
export type PluginSqliteStoreOptions = SqliteStoreOptions;
|
||||
export type PluginSqliteStore = SqliteStore;
|
||||
|
||||
export type GatewayPluginRouteMatch = RegisteredGatewayRoute;
|
||||
|
||||
export type GatewayPluginProxyRouteMatch = {
|
||||
headers?: Record<string, string>;
|
||||
id: string;
|
||||
preserveHost: boolean;
|
||||
pluginId: string;
|
||||
targetUrl: URL;
|
||||
upstreamUrl: URL;
|
||||
};
|
||||
|
||||
type RegisteredGatewayRoute = Required<Pick<GatewayPluginRouteRegistration, "handler" | "id">> & {
|
||||
auth: "gateway" | "none";
|
||||
methods?: string[];
|
||||
path?: string;
|
||||
pathPrefix?: string;
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
type RegisteredProxyRoute = Omit<GatewayPluginProxyRouteRegistration, "host" | "id" | "paths"> & {
|
||||
host: string;
|
||||
id: string;
|
||||
paths?: string[];
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
type LoadedPlugin = {
|
||||
activate?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
||||
setup?: (context: GatewayPluginContext) => MaybePromise<GatewayPluginRegistration | void>;
|
||||
stop?: () => MaybePromise<void>;
|
||||
};
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
const builtInMarketplacePluginModules = new Map<string, string>([
|
||||
["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")],
|
||||
["cursor-proxy", path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs")]
|
||||
]);
|
||||
|
||||
class GatewayPluginService {
|
||||
private config?: AppConfig;
|
||||
private coreGatewayConfig: Record<string, unknown> = {};
|
||||
private coreProviderPlugins: unknown[] = [];
|
||||
private apps: InstalledBrowserApp[] = [];
|
||||
private gatewayRoutes: RegisteredGatewayRoute[] = [];
|
||||
private proxyRoutes: RegisteredProxyRoute[] = [];
|
||||
private resourceOwnerIds = new Set<string>();
|
||||
private running = false;
|
||||
private stopHooks: Array<() => MaybePromise<void>> = [];
|
||||
private virtualModelProfiles: unknown[] = [];
|
||||
|
||||
async start(config: AppConfig): Promise<void> {
|
||||
await this.stop();
|
||||
this.config = config;
|
||||
this.running = true;
|
||||
|
||||
for (const pluginConfig of config.plugins ?? []) {
|
||||
if (pluginConfig.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
this.resourceOwnerIds.add(pluginConfig.id);
|
||||
await this.loadConfiguredPlugin(pluginConfig);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const stopHooks = [...this.stopHooks].reverse();
|
||||
this.stopHooks = [];
|
||||
|
||||
for (const stopHook of stopHooks) {
|
||||
try {
|
||||
await stopHook();
|
||||
} catch (error) {
|
||||
console.warn(`[plugin] Stop hook failed: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const resourceOwnerIds = [...this.resourceOwnerIds].reverse();
|
||||
this.resourceOwnerIds.clear();
|
||||
for (const ownerId of resourceOwnerIds) {
|
||||
await backendService.stopOwner(ownerId);
|
||||
}
|
||||
|
||||
this.config = undefined;
|
||||
this.apps = [];
|
||||
this.coreGatewayConfig = {};
|
||||
this.coreProviderPlugins = [];
|
||||
this.gatewayRoutes = [];
|
||||
this.proxyRoutes = [];
|
||||
this.running = false;
|
||||
this.virtualModelProfiles = [];
|
||||
}
|
||||
|
||||
hasGatewayRoutes(): boolean {
|
||||
return this.gatewayRoutes.length > 0;
|
||||
}
|
||||
|
||||
getCoreGatewayConfig(): Record<string, unknown> {
|
||||
return { ...this.coreGatewayConfig };
|
||||
}
|
||||
|
||||
getCoreProviderPlugins(): unknown[] {
|
||||
return [...this.coreProviderPlugins];
|
||||
}
|
||||
|
||||
getVirtualModelProfiles(): unknown[] {
|
||||
return [...this.virtualModelProfiles];
|
||||
}
|
||||
|
||||
getApps(): InstalledBrowserApp[] {
|
||||
return this.apps.map((app) => ({ ...app }));
|
||||
}
|
||||
|
||||
getProxyRouteHosts(): string[] {
|
||||
return [...new Set(this.proxyRoutes.map((route) => route.host))];
|
||||
}
|
||||
|
||||
getProxyRouteTargets(): Array<{ host: string; paths?: string[] }> {
|
||||
return this.proxyRoutes.map((route) => ({
|
||||
host: route.host,
|
||||
paths: route.paths ? [...route.paths] : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
matchGatewayRoute(method: string | undefined, requestPath: string): GatewayPluginRouteMatch | undefined {
|
||||
const normalizedMethod = (method || "GET").toUpperCase();
|
||||
return this.gatewayRoutes.find((route) => {
|
||||
if (route.methods?.length && !route.methods.includes(normalizedMethod)) {
|
||||
return false;
|
||||
}
|
||||
if (route.path && requestPath === route.path) {
|
||||
return true;
|
||||
}
|
||||
if (route.pathPrefix && matchesPathPrefix(route.pathPrefix, requestPath)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async handleGatewayRoute(route: GatewayPluginRouteMatch, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
if (!this.config) {
|
||||
throw new Error("Gateway plugin service is not configured.");
|
||||
}
|
||||
await route.handler(request, response, this.createRouteContext(route.pluginId));
|
||||
}
|
||||
|
||||
resolveProxyRoute(targetUrl: URL): GatewayPluginProxyRouteMatch | undefined {
|
||||
let bestMatch: { matchedPathPrefix: string; route: RegisteredProxyRoute } | undefined;
|
||||
for (const route of this.proxyRoutes) {
|
||||
const matchedPathPrefix = matchProxyRoute(route, targetUrl);
|
||||
if (matchedPathPrefix === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bestMatch || matchedPathPrefix.length > bestMatch.matchedPathPrefix.length) {
|
||||
bestMatch = { matchedPathPrefix, route };
|
||||
}
|
||||
}
|
||||
if (!bestMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
headers: bestMatch.route.headers,
|
||||
id: bestMatch.route.id,
|
||||
pluginId: bestMatch.route.pluginId,
|
||||
preserveHost: bestMatch.route.preserveHost === true,
|
||||
targetUrl,
|
||||
upstreamUrl: buildPluginProxyUpstreamUrl(bestMatch.route, targetUrl, bestMatch.matchedPathPrefix)
|
||||
};
|
||||
}
|
||||
|
||||
private async loadConfiguredPlugin(pluginConfig: GatewayPluginConfig): Promise<void> {
|
||||
this.registerConfiguredCoreGateway(pluginConfig);
|
||||
this.registerConfiguredApps(pluginConfig);
|
||||
for (const route of pluginConfig.proxy?.routes ?? []) {
|
||||
this.registerProxyRoute(pluginConfig.id, route);
|
||||
}
|
||||
|
||||
const modulePath = pluginConfig.module || builtInMarketplacePluginModules.get(pluginConfig.id);
|
||||
if (!modulePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedPlugin = await loadPluginModule(modulePath);
|
||||
const plugin = normalizeLoadedPlugin(loadedPlugin);
|
||||
const context = this.createPluginContext(pluginConfig);
|
||||
const registration = plugin.setup
|
||||
? await plugin.setup(context)
|
||||
: plugin.activate
|
||||
? await plugin.activate(context)
|
||||
: undefined;
|
||||
|
||||
if (registration) {
|
||||
this.applyPluginRegistration(pluginConfig.id, registration);
|
||||
}
|
||||
if (plugin.stop) {
|
||||
this.stopHooks.push(() => plugin.stop?.());
|
||||
}
|
||||
}
|
||||
|
||||
private applyPluginRegistration(pluginId: string, registration: GatewayPluginRegistration): void {
|
||||
for (const app of registration.apps ?? []) {
|
||||
this.registerApp(pluginId, app);
|
||||
}
|
||||
for (const route of registration.gatewayRoutes ?? []) {
|
||||
this.registerGatewayRoute(pluginId, route);
|
||||
}
|
||||
for (const route of registration.proxyRoutes ?? []) {
|
||||
this.registerProxyRoute(pluginId, route);
|
||||
}
|
||||
for (const providerPlugin of registration.coreGateway?.providerPlugins ?? []) {
|
||||
this.coreProviderPlugins.push(providerPlugin);
|
||||
}
|
||||
for (const profile of [
|
||||
...(registration.coreGateway?.virtualModelProfiles ?? []),
|
||||
...(registration.virtualModelProfiles ?? [])
|
||||
]) {
|
||||
this.virtualModelProfiles.push(profile);
|
||||
}
|
||||
if (registration.coreGateway?.config) {
|
||||
this.coreGatewayConfig = {
|
||||
...this.coreGatewayConfig,
|
||||
...registration.coreGateway.config
|
||||
};
|
||||
}
|
||||
if (registration.stop) {
|
||||
this.stopHooks.push(registration.stop);
|
||||
}
|
||||
if (registration.onStop) {
|
||||
this.stopHooks.push(registration.onStop);
|
||||
}
|
||||
}
|
||||
|
||||
private registerConfiguredApps(pluginConfig: GatewayPluginConfig): void {
|
||||
for (const app of pluginConfig.apps ?? []) {
|
||||
this.registerApp(pluginConfig.id, app);
|
||||
}
|
||||
}
|
||||
|
||||
private registerApp(pluginId: string, app: GatewayPluginAppConfig): void {
|
||||
const normalized = normalizePluginApp(pluginId, app, this.apps.length + 1);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
this.apps = this.apps.filter((item) => !(item.pluginId === pluginId && item.id === normalized.id));
|
||||
this.apps.push(normalized);
|
||||
}
|
||||
|
||||
private registerConfiguredCoreGateway(pluginConfig: GatewayPluginConfig): void {
|
||||
for (const providerPlugin of pluginConfig.coreGateway?.providerPlugins ?? []) {
|
||||
this.coreProviderPlugins.push(providerPlugin);
|
||||
}
|
||||
for (const profile of pluginConfig.coreGateway?.virtualModelProfiles ?? []) {
|
||||
this.virtualModelProfiles.push(profile);
|
||||
}
|
||||
if (pluginConfig.coreGateway?.config) {
|
||||
this.coreGatewayConfig = {
|
||||
...this.coreGatewayConfig,
|
||||
...pluginConfig.coreGateway.config
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private registerGatewayRoute(pluginId: string, route: GatewayPluginRouteRegistration): void {
|
||||
if (!route.path && !route.pathPrefix) {
|
||||
throw new Error(`Plugin ${pluginId} registered a gateway route without path or pathPrefix.`);
|
||||
}
|
||||
|
||||
this.gatewayRoutes.push({
|
||||
auth: route.auth ?? "gateway",
|
||||
handler: route.handler,
|
||||
id: route.id || `${pluginId}:gateway:${this.gatewayRoutes.length + 1}`,
|
||||
methods: normalizeMethods(route),
|
||||
path: normalizeRoutePath(route.path),
|
||||
pathPrefix: normalizeRoutePath(route.pathPrefix),
|
||||
pluginId
|
||||
});
|
||||
}
|
||||
|
||||
private registerProxyRoute(pluginId: string, route: GatewayPluginProxyRouteRegistration): void {
|
||||
const host = route.host.trim().toLowerCase();
|
||||
if (!host) {
|
||||
throw new Error(`Plugin ${pluginId} registered a proxy route without host.`);
|
||||
}
|
||||
|
||||
this.proxyRoutes.push({
|
||||
...route,
|
||||
host,
|
||||
id: route.id || `${pluginId}:proxy:${this.proxyRoutes.length + 1}`,
|
||||
paths: route.paths?.map(normalizeRoutePath).filter((path): path is string => Boolean(path)),
|
||||
pluginId
|
||||
});
|
||||
}
|
||||
|
||||
private createPluginContext(pluginConfig: GatewayPluginConfig): GatewayPluginContext {
|
||||
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginConfig.id));
|
||||
mkdirSync(pluginDataDir, { recursive: true });
|
||||
const logger = createPluginLogger(pluginConfig.id);
|
||||
|
||||
return {
|
||||
config: this.config ?? ({} as AppConfig),
|
||||
logger,
|
||||
paths: {
|
||||
configDir: CONFIGDIR,
|
||||
dataDir: DATADIR,
|
||||
pluginDataDir
|
||||
},
|
||||
pluginConfig: pluginConfig.config,
|
||||
pluginId: pluginConfig.id,
|
||||
openSqliteStore: (options) => this.openSqliteStore(pluginConfig.id, pluginDataDir, options),
|
||||
registerCoreGatewayProviderPlugin: (providerPlugin) => {
|
||||
this.coreProviderPlugins.push(providerPlugin);
|
||||
},
|
||||
registerCoreGatewayVirtualModelProfile: (profile) => {
|
||||
this.virtualModelProfiles.push(profile);
|
||||
},
|
||||
registerApp: (app) => this.registerApp(pluginConfig.id, app),
|
||||
registerGatewayRoute: (route) => this.registerGatewayRoute(pluginConfig.id, route),
|
||||
registerHttpBackend: (backend) => this.registerHttpBackend(pluginConfig.id, pluginDataDir, logger, backend),
|
||||
registerProxyRoute: (route) => this.registerProxyRoute(pluginConfig.id, route)
|
||||
};
|
||||
}
|
||||
|
||||
private createRouteContext(pluginId: string): GatewayPluginRouteContext {
|
||||
const pluginDataDir = path.join(DATADIR, "plugins", sanitizeFileSegment(pluginId));
|
||||
const logger = createPluginLogger(pluginId);
|
||||
return {
|
||||
config: this.config ?? ({} as AppConfig),
|
||||
logger,
|
||||
paths: {
|
||||
configDir: CONFIGDIR,
|
||||
dataDir: DATADIR,
|
||||
pluginDataDir
|
||||
},
|
||||
pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config,
|
||||
pluginId,
|
||||
openSqliteStore: (options) => this.openSqliteStore(pluginId, pluginDataDir, options),
|
||||
readBody,
|
||||
readJson,
|
||||
sendJson
|
||||
};
|
||||
}
|
||||
|
||||
private async registerHttpBackend(
|
||||
pluginId: string,
|
||||
pluginDataDir: string,
|
||||
logger: PluginLogger,
|
||||
backend: GatewayPluginHttpBackendRegistration
|
||||
): Promise<RegisteredHttpBackend> {
|
||||
return backendService.registerHttpBackend(pluginId, {
|
||||
host: backend.host,
|
||||
id: backend.id,
|
||||
port: backend.port,
|
||||
handler: (request, response) =>
|
||||
backend.handler(request, response, {
|
||||
config: this.config ?? ({} as AppConfig),
|
||||
logger,
|
||||
paths: {
|
||||
configDir: CONFIGDIR,
|
||||
dataDir: DATADIR,
|
||||
pluginDataDir
|
||||
},
|
||||
pluginConfig: this.config?.plugins.find((plugin) => plugin.id === pluginId)?.config,
|
||||
pluginId,
|
||||
openSqliteStore: (options) => this.openSqliteStore(pluginId, pluginDataDir, options),
|
||||
readBody,
|
||||
readJson,
|
||||
sendJson
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private async openSqliteStore(
|
||||
pluginId: string,
|
||||
pluginDataDir: string,
|
||||
options: PluginSqliteStoreOptions = {}
|
||||
): Promise<PluginSqliteStore> {
|
||||
return backendService.openSqliteStore(pluginId, pluginDataDir, options);
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginService = new GatewayPluginService();
|
||||
|
||||
async function loadPluginModule(modulePath: string): Promise<unknown> {
|
||||
const resolved = resolvePluginModule(modulePath);
|
||||
return import(pathToFileURL(resolved).href);
|
||||
}
|
||||
|
||||
function resolvePluginModule(modulePath: string): string {
|
||||
const expanded = expandHome(modulePath);
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return requireFromHere.resolve(expanded);
|
||||
}
|
||||
if (expanded.startsWith(".")) {
|
||||
return requireFromHere.resolve(path.resolve(CONFIGDIR, expanded));
|
||||
}
|
||||
return requireFromHere.resolve(expanded, { paths: [CONFIGDIR, process.cwd()] });
|
||||
}
|
||||
|
||||
function normalizeLoadedPlugin(moduleValue: unknown): LoadedPlugin {
|
||||
const record = isRecord(moduleValue) ? moduleValue : {};
|
||||
const candidate = record.default ?? record.plugin ?? moduleValue;
|
||||
if (typeof candidate === "function") {
|
||||
return { setup: candidate as LoadedPlugin["setup"] };
|
||||
}
|
||||
if (isRecord(candidate)) {
|
||||
return candidate as LoadedPlugin;
|
||||
}
|
||||
throw new Error("Plugin module must export a function, default plugin, or plugin object.");
|
||||
}
|
||||
|
||||
function matchProxyRoute(route: RegisteredProxyRoute, targetUrl: URL): string | undefined {
|
||||
if (!matchesHost(route.host, targetUrl.hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!route.paths?.length) {
|
||||
return "";
|
||||
}
|
||||
let matchedPathPrefix: string | undefined;
|
||||
for (const pathPrefix of route.paths) {
|
||||
const normalizedPathPrefix = normalizeRoutePath(pathPrefix) ?? "/";
|
||||
if (!matchesPathPrefix(normalizedPathPrefix, targetUrl.pathname)) {
|
||||
continue;
|
||||
}
|
||||
if (!matchedPathPrefix || normalizedPathPrefix.length > matchedPathPrefix.length) {
|
||||
matchedPathPrefix = normalizedPathPrefix;
|
||||
}
|
||||
}
|
||||
return matchedPathPrefix;
|
||||
}
|
||||
|
||||
function buildPluginProxyUpstreamUrl(route: RegisteredProxyRoute, targetUrl: URL, matchedPathPrefix: string): URL {
|
||||
const upstreamValue = typeof route.upstream === "function" ? route.upstream() : route.upstream;
|
||||
const upstreamUrl = new URL(upstreamValue.toString());
|
||||
const basePath = upstreamUrl.pathname === "/" ? "" : upstreamUrl.pathname.replace(/\/+$/, "");
|
||||
let forwardedPath = targetUrl.pathname;
|
||||
const stripPrefix = resolveStripPathPrefix(route.stripPathPrefix, matchedPathPrefix);
|
||||
|
||||
if (stripPrefix && matchesPathPrefix(stripPrefix, forwardedPath)) {
|
||||
forwardedPath = forwardedPath.slice(stripPrefix.length) || "/";
|
||||
if (!forwardedPath.startsWith("/")) {
|
||||
forwardedPath = `/${forwardedPath}`;
|
||||
}
|
||||
}
|
||||
if (route.rewritePathPrefix !== undefined) {
|
||||
const rewritePrefix = normalizeRoutePath(route.rewritePathPrefix) ?? "/";
|
||||
const suffix = matchedPathPrefix && matchesPathPrefix(matchedPathPrefix, targetUrl.pathname)
|
||||
? targetUrl.pathname.slice(matchedPathPrefix.length)
|
||||
: targetUrl.pathname;
|
||||
forwardedPath = joinUrlPaths(rewritePrefix, suffix || "/");
|
||||
}
|
||||
|
||||
upstreamUrl.pathname = joinUrlPaths(basePath, forwardedPath);
|
||||
upstreamUrl.search = targetUrl.search;
|
||||
return upstreamUrl;
|
||||
}
|
||||
|
||||
function resolveStripPathPrefix(value: boolean | string | undefined, matchedPathPrefix: string): string | undefined {
|
||||
if (value === true) {
|
||||
return matchedPathPrefix || undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return normalizeRoutePath(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePluginApp(pluginId: string, app: GatewayPluginAppConfig, index: number): InstalledBrowserApp | undefined {
|
||||
const name = app.name?.trim();
|
||||
const url = app.url?.trim();
|
||||
if (!name || !url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(app.description?.trim() ? { description: app.description.trim() } : {}),
|
||||
...(app.icon?.trim() ? { icon: app.icon.trim() } : {}),
|
||||
id: app.id?.trim() || sanitizeFileSegment(`${name}-${url}`) || `app-${index}`,
|
||||
name,
|
||||
pluginId,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMethods(route: GatewayPluginRouteRegistration): string[] | undefined {
|
||||
const methods = [...(route.methods ?? []), ...(route.method ? [route.method] : [])]
|
||||
.map((method) => method.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
return methods.length ? [...new Set(methods)] : undefined;
|
||||
}
|
||||
|
||||
function normalizeRoutePath(value: string | undefined): string | undefined {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function matchesHost(pattern: string, hostname: string): boolean {
|
||||
const normalizedPattern = pattern.toLowerCase();
|
||||
const normalizedHost = hostname.toLowerCase();
|
||||
if (normalizedPattern === normalizedHost) {
|
||||
return true;
|
||||
}
|
||||
if (normalizedPattern.startsWith("*.")) {
|
||||
const suffix = normalizedPattern.slice(1);
|
||||
return normalizedHost.endsWith(suffix) && normalizedHost !== suffix.slice(1);
|
||||
}
|
||||
if (normalizedPattern.startsWith(".")) {
|
||||
return normalizedHost.endsWith(normalizedPattern);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchesPathPrefix(prefix: string, requestPath: string): boolean {
|
||||
const normalizedPrefix = normalizeRoutePath(prefix) ?? "/";
|
||||
const normalizedPath = normalizeRoutePath(requestPath) ?? "/";
|
||||
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix.replace(/\/+$/, "")}/`);
|
||||
}
|
||||
|
||||
function joinUrlPaths(prefix: string, suffix: string): string {
|
||||
const normalizedPrefix = prefix === "/" ? "" : prefix.replace(/\/+$/, "");
|
||||
const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`;
|
||||
return `${normalizedPrefix}${normalizedSuffix}` || "/";
|
||||
}
|
||||
|
||||
function createPluginLogger(pluginId: string): PluginLogger {
|
||||
const prefix = `[plugin:${pluginId}]`;
|
||||
return {
|
||||
debug: (...args) => console.debug(prefix, ...args),
|
||||
error: (...args) => console.error(prefix, ...args),
|
||||
info: (...args) => console.info(prefix, ...args),
|
||||
warn: (...args) => console.warn(prefix, ...args)
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, statusCode: number, body: unknown): void {
|
||||
response.writeHead(statusCode, { "content-type": "application/json" });
|
||||
response.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function readJson(request: IncomingMessage): Promise<unknown> {
|
||||
return readBody(request).then((body) => JSON.parse(body.toString("utf8") || "{}") as unknown);
|
||||
}
|
||||
|
||||
function readBody(request: IncomingMessage): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
request.once("end", () => resolve(Buffer.concat(chunks)));
|
||||
request.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function expandHome(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeFileSegment(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "plugin";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { IPC_CHANNELS } from "../shared/ipc-channels";
|
||||
import type {
|
||||
AgentAnalysisFilter,
|
||||
AgentAnalysisSnapshot,
|
||||
AppConfig,
|
||||
AppInfo,
|
||||
ApiKeyConfig,
|
||||
GatewayProviderProbeRequest,
|
||||
GatewayProviderProbeResult,
|
||||
GatewayStatus,
|
||||
PluginDirectorySelection,
|
||||
PluginMarketplaceEntry,
|
||||
ProviderDeepLinkRequest,
|
||||
ProfileApplyResult,
|
||||
ProxyCertificateInstallResult,
|
||||
ProxyCertificateStatus,
|
||||
ProxyNetworkSnapshot,
|
||||
ProxyStatus,
|
||||
RequestLogListFilter,
|
||||
RequestLogPage,
|
||||
UsageStatsFilter,
|
||||
UsageStatsRange,
|
||||
UsageStatsSnapshot
|
||||
} from "../shared/app";
|
||||
|
||||
contextBridge.exposeInMainWorld("ccr", {
|
||||
applyProfile: () => ipcRenderer.invoke(IPC_CHANNELS.appApplyProfile) as Promise<ProfileApplyResult>,
|
||||
clearProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
|
||||
closeTray: () => ipcRenderer.invoke(IPC_CHANNELS.appCloseTray) as Promise<void>,
|
||||
getAgentAnalysis: (filter?: AgentAnalysisFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetAgentAnalysis, filter) as Promise<AgentAnalysisSnapshot>,
|
||||
getAppInfo: () => ipcRenderer.invoke(IPC_CHANNELS.appGetInfo) as Promise<AppInfo>,
|
||||
getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.appGetConfig) as Promise<AppConfig>,
|
||||
getGatewayStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetGatewayStatus) as Promise<GatewayStatus>,
|
||||
getPendingProviderDeepLinks: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPendingProviderDeepLinks) as Promise<ProviderDeepLinkRequest[]>,
|
||||
getPluginMarketplace: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPluginMarketplace) as Promise<PluginMarketplaceEntry[]>,
|
||||
getProxyCertificateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise<ProxyCertificateStatus>,
|
||||
getProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
|
||||
getProxyStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyStatus) as Promise<ProxyStatus>,
|
||||
getRequestLogs: (filter?: RequestLogListFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise<RequestLogPage>,
|
||||
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise<UsageStatsSnapshot>,
|
||||
installProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appInstallProxyCertificate) as Promise<ProxyCertificateInstallResult>,
|
||||
openBuiltInBrowser: () => ipcRenderer.invoke(IPC_CHANNELS.appOpenBuiltInBrowser) as Promise<void>,
|
||||
openExternal: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.appOpenExternal, url) as Promise<void>,
|
||||
probeProvider: (request: GatewayProviderProbeRequest) => ipcRenderer.invoke(IPC_CHANNELS.appProbeProvider, request) as Promise<GatewayProviderProbeResult>,
|
||||
quitApp: () => ipcRenderer.invoke(IPC_CHANNELS.appQuit) as Promise<void>,
|
||||
revealProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appRevealProxyCertificate) as Promise<void>,
|
||||
restartGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appRestartGateway) as Promise<GatewayStatus>,
|
||||
restartProxy: () => ipcRenderer.invoke(IPC_CHANNELS.appRestartProxy) as Promise<ProxyStatus>,
|
||||
saveApiKeys: (apiKeys: ApiKeyConfig[]) => ipcRenderer.invoke(IPC_CHANNELS.appSaveApiKeys, apiKeys) as Promise<AppConfig>,
|
||||
saveConfig: (config: AppConfig) => ipcRenderer.invoke(IPC_CHANNELS.appSaveConfig, config) as Promise<AppConfig>,
|
||||
selectPluginDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.appSelectPluginDirectory) as Promise<PluginDirectorySelection | undefined>,
|
||||
setProxyNetworkCaptureEnabled: (enabled: boolean) => ipcRenderer.invoke(IPC_CHANNELS.appSetProxyNetworkCaptureEnabled, enabled) as Promise<ProxyNetworkSnapshot>,
|
||||
setTrayDetailOpen: (open: boolean, provider?: string) => ipcRenderer.invoke(IPC_CHANNELS.appSetTrayDetailOpen, open, provider) as Promise<void>,
|
||||
showMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.appShowMainWindow) as Promise<void>,
|
||||
startGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStartGateway) as Promise<GatewayStatus>,
|
||||
stopGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStopGateway) as Promise<GatewayStatus>,
|
||||
onBeforeQuit: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on(IPC_CHANNELS.appBeforeQuit, handler);
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.appBeforeQuit, handler);
|
||||
},
|
||||
onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, request: ProviderDeepLinkRequest) => callback(request);
|
||||
ipcRenderer.on(IPC_CHANNELS.appProviderDeepLink, handler);
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.appProviderDeepLink, handler);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ProfileApplyResult, ProfileClientApplyStatus, ProfileConfig } from "../shared/app";
|
||||
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import { normalizeRouteSelector } from "./gateway/claude-code-router-plugin";
|
||||
|
||||
const managedRootStart = "# BEGIN CCR managed profile";
|
||||
const managedRootEnd = "# END CCR managed profile";
|
||||
const managedProviderStart = "# BEGIN CCR managed Codex provider";
|
||||
const managedProviderEnd = "# END CCR managed Codex provider";
|
||||
const fallbackClientToken = "ccr-local";
|
||||
|
||||
export async function applyProfileConfig(config: AppConfig): Promise<ProfileApplyResult> {
|
||||
const appliedAt = new Date().toISOString();
|
||||
const profiles = profileEntries(config);
|
||||
const result: ProfileApplyResult = {
|
||||
appliedAt,
|
||||
clients: [],
|
||||
enabled: Boolean(config.profile.enabled)
|
||||
};
|
||||
|
||||
if (!config.profile.enabled) {
|
||||
result.clients.push(...profiles.map((profile) => disabledStatus(profile.agent, profilePath(profile), "Profile takeover is disabled.")));
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
result.clients.push(
|
||||
profile.agent === "claude-code"
|
||||
? applyClaudeCodeProfile(config, profile, appliedAt)
|
||||
: applyCodexProfile(config, profile, appliedAt)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, appliedAt: string): ProfileClientApplyStatus {
|
||||
const settingsFile = resolveUserPath(profile.settingsFile || "~/.claude/settings.json");
|
||||
if (!profile.enabled) {
|
||||
return disabledStatus("claude-code", settingsFile, "Claude Code takeover is disabled.");
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = gatewayEndpoint(config);
|
||||
const token = clientToken(config);
|
||||
const settings = readJsonObject(settingsFile);
|
||||
const env = isRecord(settings.env) ? { ...settings.env } : {};
|
||||
env.ANTHROPIC_BASE_URL = endpoint;
|
||||
env.ANTHROPIC_API_BASE_URL = endpoint;
|
||||
env.CLAUDE_AGENT_API_BASE_URL = endpoint;
|
||||
env.ANTHROPIC_AUTH_TOKEN = token;
|
||||
env.ANTHROPIC_API_KEY = token;
|
||||
if (profile.model.trim()) {
|
||||
env.ANTHROPIC_MODEL = normalizeClientModel(profile.model);
|
||||
} else {
|
||||
delete env.ANTHROPIC_MODEL;
|
||||
}
|
||||
if (profile.smallFastModel?.trim()) {
|
||||
env.ANTHROPIC_SMALL_FAST_MODEL = normalizeClientModel(profile.smallFastModel);
|
||||
} else {
|
||||
delete env.ANTHROPIC_SMALL_FAST_MODEL;
|
||||
}
|
||||
|
||||
const nextSettings = {
|
||||
...settings,
|
||||
env
|
||||
};
|
||||
const writeResult = writeFileWithBackup(settingsFile, `${JSON.stringify(nextSettings, null, 2)}\n`);
|
||||
return {
|
||||
appliedAt,
|
||||
backupFile: writeResult.backupFile,
|
||||
client: "claude-code",
|
||||
enabled: true,
|
||||
message: writeResult.changed ? "Claude Code settings are managed by CCR." : "Claude Code settings already match CCR.",
|
||||
ok: true,
|
||||
path: settingsFile
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
client: "claude-code",
|
||||
enabled: true,
|
||||
message: formatError(error),
|
||||
ok: false,
|
||||
path: settingsFile
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function applyCodexProfile(config: AppConfig, profile: ProfileConfig, appliedAt: string): ProfileClientApplyStatus {
|
||||
const configFile = resolveCodexConfigFile(profile);
|
||||
if (!profile.enabled) {
|
||||
return disabledStatus("codex", configFile, "Codex takeover is disabled.");
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`;
|
||||
const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const providerName = profile.providerName?.trim() || "Claude Code Router";
|
||||
const model = normalizeClientModel(profile.model) || defaultClientModel(config);
|
||||
const token = clientToken(config);
|
||||
const source = existsSync(configFile) ? readFileSync(configFile, "utf8") : "";
|
||||
const configFormat = normalizeCodexConfigFormat(profile.configFormat);
|
||||
const nextConfig = buildCodexConfigToml(source, {
|
||||
baseUrl: endpoint,
|
||||
configFormat,
|
||||
model,
|
||||
providerId,
|
||||
providerName,
|
||||
token
|
||||
});
|
||||
const writeResult = writeFileWithBackup(configFile, nextConfig);
|
||||
const separateProfileResult = maybeWriteSeparateCodexProfileFile(configFile, source, {
|
||||
configFormat,
|
||||
model,
|
||||
providerId
|
||||
});
|
||||
const middlewareResult = profile.cliMiddleware
|
||||
? writeCodexCliMiddleware(profile, {
|
||||
configFormat,
|
||||
configFile,
|
||||
model,
|
||||
providerId
|
||||
})
|
||||
: undefined;
|
||||
const changed = writeResult.changed || Boolean(separateProfileResult?.changed) || Boolean(middlewareResult?.changed);
|
||||
const extras = [
|
||||
separateProfileResult?.file ? `profile ${separateProfileResult.file}` : "",
|
||||
middlewareResult?.file ? `middleware ${middlewareResult.file}` : ""
|
||||
].filter(Boolean);
|
||||
return {
|
||||
appliedAt,
|
||||
backupFile: writeResult.backupFile,
|
||||
client: "codex",
|
||||
enabled: true,
|
||||
message: changed
|
||||
? `Codex config is managed by CCR${extras.length ? ` (${extras.join(", ")})` : ""}.`
|
||||
: "Codex config already matches CCR.",
|
||||
ok: true,
|
||||
path: configFile
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
client: "codex",
|
||||
enabled: true,
|
||||
message: formatError(error),
|
||||
ok: false,
|
||||
path: configFile
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function profileEntries(config: AppConfig): ProfileConfig[] {
|
||||
return config.profile.profiles;
|
||||
}
|
||||
|
||||
function profilePath(profile: ProfileConfig): string {
|
||||
return profile.agent === "claude-code"
|
||||
? profile.settingsFile || "~/.claude/settings.json"
|
||||
: resolveCodexConfigFile(profile);
|
||||
}
|
||||
|
||||
function resolveCodexConfigFile(profile: ProfileConfig): string {
|
||||
const codexHome = profile.codexHome?.trim();
|
||||
if (codexHome) {
|
||||
return path.join(resolveUserPath(codexHome), "config.toml");
|
||||
}
|
||||
return resolveUserPath(profile.configFile || "~/.codex/config.toml");
|
||||
}
|
||||
|
||||
function buildCodexConfigToml(
|
||||
source: string,
|
||||
values: {
|
||||
baseUrl: string;
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
model: string;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
token: string;
|
||||
}
|
||||
): string {
|
||||
let content = removeManagedBlock(source, managedRootStart, managedRootEnd);
|
||||
content = removeManagedBlock(content, managedProviderStart, managedProviderEnd);
|
||||
content = removeCodexProviderTable(content, values.providerId);
|
||||
if (values.configFormat === "separate_profile_files") {
|
||||
content = removeCodexProfileTable(content, values.providerId);
|
||||
}
|
||||
|
||||
const firstTableIndex = firstTomlTableIndex(content);
|
||||
const rootSource = firstTableIndex === -1 ? content : content.slice(0, firstTableIndex);
|
||||
const restSource = firstTableIndex === -1 ? "" : content.slice(firstTableIndex);
|
||||
const cleanedRoot = removeRootTomlKeys(rootSource, ["model", "model_provider", "profile"]);
|
||||
const rootBlock = [
|
||||
managedRootStart,
|
||||
`model_provider = ${tomlString(values.providerId)}`,
|
||||
`model = ${tomlString(values.model)}`,
|
||||
managedRootEnd,
|
||||
""
|
||||
].join("\n");
|
||||
const providerBlock = [
|
||||
"",
|
||||
managedProviderStart,
|
||||
`[model_providers.${tomlKey(values.providerId)}]`,
|
||||
`name = ${tomlString(values.providerName)}`,
|
||||
`base_url = ${tomlString(values.baseUrl)}`,
|
||||
`experimental_bearer_token = ${tomlString(values.token)}`,
|
||||
'wire_api = "responses"',
|
||||
managedProviderEnd,
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
return `${rootBlock}${trimLeadingBlankLines(cleanedRoot)}${restSource}${providerBlock}`.replace(/\n{4,}/g, "\n\n\n");
|
||||
}
|
||||
|
||||
function maybeWriteSeparateCodexProfileFile(
|
||||
configFile: string,
|
||||
source: string,
|
||||
values: {
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
model: string;
|
||||
providerId: string;
|
||||
}
|
||||
): { changed: boolean; file: string } | undefined {
|
||||
if (values.configFormat !== "separate_profile_files") {
|
||||
return undefined;
|
||||
}
|
||||
const file = path.join(path.dirname(configFile), `${values.providerId}.config.toml`);
|
||||
const previous = existsSync(file)
|
||||
? readFileSync(file, "utf8")
|
||||
: legacyCodexProfileTableBody(source, values.providerId);
|
||||
const next = buildSeparateCodexProfileToml(previous, values);
|
||||
const writeResult = writeFileWithBackup(file, next);
|
||||
return {
|
||||
changed: writeResult.changed,
|
||||
file
|
||||
};
|
||||
}
|
||||
|
||||
function buildSeparateCodexProfileToml(
|
||||
source: string,
|
||||
values: {
|
||||
model: string;
|
||||
providerId: string;
|
||||
}
|
||||
): string {
|
||||
const firstTableIndex = firstTomlTableIndex(source);
|
||||
const rootSource = firstTableIndex === -1 ? source : source.slice(0, firstTableIndex);
|
||||
const restSource = firstTableIndex === -1 ? "" : source.slice(firstTableIndex);
|
||||
const cleanedRoot = removeRootTomlKeys(rootSource, ["model", "model_provider", "model_reasoning_effort"]);
|
||||
const rootBlock = [
|
||||
`model_provider = ${tomlString(values.providerId)}`,
|
||||
`model = ${tomlString(values.model)}`,
|
||||
`model_reasoning_effort = "xhigh"`,
|
||||
""
|
||||
].join("\n");
|
||||
return ensureTrailingNewline(`${rootBlock}${trimLeadingBlankLines(cleanedRoot)}${restSource}`.replace(/\n{4,}/g, "\n\n\n"));
|
||||
}
|
||||
|
||||
function writeCodexCliMiddleware(
|
||||
profile: ProfileConfig,
|
||||
values: {
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
configFile: string;
|
||||
model: string;
|
||||
providerId: string;
|
||||
}
|
||||
): { changed: boolean; file: string } {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename());
|
||||
const runtimeResult = writeFileWithBackup(runtimeFile, codexCliMiddlewareRuntimeScript());
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(runtimeFile, 0o755);
|
||||
}
|
||||
const file = path.join(binDir, codexMiddlewareFilename(profile, values.providerId));
|
||||
const content = process.platform === "win32"
|
||||
? codexMiddlewareCmdScript(profile, values, runtimeFile)
|
||||
: codexMiddlewareShellScript(profile, values, runtimeFile);
|
||||
const writeResult = writeFileWithBackup(file, content);
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(file, 0o755);
|
||||
}
|
||||
return {
|
||||
changed: writeResult.changed || runtimeResult.changed,
|
||||
file
|
||||
};
|
||||
}
|
||||
|
||||
function codexMiddlewareRuntimeFilename(): string {
|
||||
return "ccr-codex-cli-middleware.js";
|
||||
}
|
||||
|
||||
function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): string {
|
||||
const slug = sanitizeCodexProviderId(profile.id || profile.name || providerId) || "codex";
|
||||
return process.platform === "win32"
|
||||
? `ccr-codex-cli-stdio-${slug}.cmd`
|
||||
: `ccr-codex-cli-stdio-${slug}`;
|
||||
}
|
||||
|
||||
function codexMiddlewareShellScript(
|
||||
profile: ProfileConfig,
|
||||
values: {
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
configFile: string;
|
||||
model: string;
|
||||
providerId: string;
|
||||
},
|
||||
runtimeFile: string
|
||||
): string {
|
||||
const codexCli = profile.codexCliPath?.trim() || "codex";
|
||||
const codexHome = profile.codexHome?.trim() || path.dirname(values.configFile);
|
||||
const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode);
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
`export CODEX_HOME=${shellQuote(resolveUserPath(codexHome))}`,
|
||||
`export CCR_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`,
|
||||
`export CCR_CODEX_PROFILE=${shellQuote(values.providerId)}`,
|
||||
`export CCR_CODEX_MODEL=${shellQuote(values.model)}`,
|
||||
`export CCR_CODEX_MODEL_PROVIDER=${shellQuote(values.providerId)}`,
|
||||
`export CCR_CODEX_REMOTE_FRONTEND_MODE=${shellQuote(remoteFrontendMode)}`,
|
||||
`export CCR_CODEX_PROFILE_CONFIG_FORMAT=${shellQuote(values.configFormat)}`,
|
||||
`export CODEXL_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`,
|
||||
`export CODEXL_CODEX_PROFILE=${shellQuote(values.providerId)}`,
|
||||
`export CODEXL_CODEX_MODEL_PROVIDER=${shellQuote(values.providerId)}`,
|
||||
`export CODEXL_CODEX_WORKSPACE_NAME=${shellQuote(profile.name || values.providerId)}`,
|
||||
`export CODEXL_CODEX_CORE_MODE=${shellQuote(remoteFrontendMode)}`,
|
||||
`export CODEXL_CODEX_PROFILE_CONFIG_FORMAT=${shellQuote(values.configFormat)}`,
|
||||
"NODE_BIN=${CCR_NODE_BIN:-node}",
|
||||
`exec "$NODE_BIN" ${shellQuote(runtimeFile)} "$@"`,
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function codexMiddlewareCmdScript(
|
||||
profile: ProfileConfig,
|
||||
values: {
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
configFile: string;
|
||||
model: string;
|
||||
providerId: string;
|
||||
},
|
||||
runtimeFile: string
|
||||
): string {
|
||||
const codexCli = profile.codexCliPath?.trim() || "codex";
|
||||
const codexHome = profile.codexHome?.trim() || path.dirname(values.configFile);
|
||||
const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode);
|
||||
const providerId = values.providerId.replace(/"/g, '\\"');
|
||||
const workspaceName = (profile.name || values.providerId).replace(/"/g, '\\"');
|
||||
return [
|
||||
"@echo off",
|
||||
`set "CODEX_HOME=${resolveUserPath(codexHome).replace(/"/g, '\\"')}"`,
|
||||
`set "CCR_REAL_CODEX_CLI_PATH=${codexCli.replace(/"/g, '\\"')}"`,
|
||||
`set "CCR_CODEX_PROFILE=${providerId}"`,
|
||||
`set "CCR_CODEX_MODEL=${values.model.replace(/"/g, '\\"')}"`,
|
||||
`set "CCR_CODEX_MODEL_PROVIDER=${providerId}"`,
|
||||
`set "CCR_CODEX_REMOTE_FRONTEND_MODE=${remoteFrontendMode}"`,
|
||||
`set "CCR_CODEX_PROFILE_CONFIG_FORMAT=${values.configFormat}"`,
|
||||
`set "CODEXL_REAL_CODEX_CLI_PATH=${codexCli.replace(/"/g, '\\"')}"`,
|
||||
`set "CODEXL_CODEX_PROFILE=${providerId}"`,
|
||||
`set "CODEXL_CODEX_MODEL_PROVIDER=${providerId}"`,
|
||||
`set "CODEXL_CODEX_WORKSPACE_NAME=${workspaceName}"`,
|
||||
`set "CODEXL_CODEX_CORE_MODE=${remoteFrontendMode}"`,
|
||||
`set "CODEXL_CODEX_PROFILE_CONFIG_FORMAT=${values.configFormat}"`,
|
||||
"if not defined CCR_NODE_BIN set \"CCR_NODE_BIN=node\"",
|
||||
"if \"%~1\"==\"\" (",
|
||||
` "%CCR_NODE_BIN%" "${runtimeFile.replace(/"/g, '\\"')}"`,
|
||||
") else (",
|
||||
` "%CCR_NODE_BIN%" "${runtimeFile.replace(/"/g, '\\"')}" %*`,
|
||||
")",
|
||||
"exit /b %ERRORLEVEL%",
|
||||
""
|
||||
].join("\r\n");
|
||||
}
|
||||
|
||||
function removeRootTomlKeys(source: string, keys: string[]): string {
|
||||
const keyPattern = keys.map(escapeRegExp).join("|");
|
||||
const pattern = new RegExp(`^\\s*(?:${keyPattern})\\s*=.*(?:\\n|$)`, "gm");
|
||||
return source.replace(pattern, "");
|
||||
}
|
||||
|
||||
function removeCodexProviderTable(source: string, providerId: string): string {
|
||||
return removeTomlTable(source, "model_providers", providerId);
|
||||
}
|
||||
|
||||
function removeCodexProfileTable(source: string, providerId: string): string {
|
||||
return removeTomlTable(source, "profiles", providerId);
|
||||
}
|
||||
|
||||
function removeTomlTable(source: string, section: string, name: string): string {
|
||||
const lines = source.split(/(?<=\n)/);
|
||||
const headers = new Set([
|
||||
`[${section}.${name}]`,
|
||||
`[${section}.${tomlQuotedKey(name)}]`
|
||||
]);
|
||||
const kept: string[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!headers.has(line.trim())) {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
while (index < lines.length && !/^\s*\[/.test(lines[index])) {
|
||||
index += 1;
|
||||
}
|
||||
index -= 1;
|
||||
}
|
||||
return kept.join("");
|
||||
}
|
||||
|
||||
function legacyCodexProfileTableBody(source: string, providerId: string): string {
|
||||
const headers = new Set([
|
||||
`[profiles.${providerId}]`,
|
||||
`[profiles.${tomlQuotedKey(providerId)}]`
|
||||
]);
|
||||
const lines: string[] = [];
|
||||
let inTarget = false;
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (/^\s*\[/.test(trimmed)) {
|
||||
if (inTarget) {
|
||||
break;
|
||||
}
|
||||
inTarget = headers.has(trimmed);
|
||||
continue;
|
||||
}
|
||||
if (inTarget) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function removeManagedBlock(source: string, start: string, end: string): string {
|
||||
const pattern = new RegExp(`\\n?${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "g");
|
||||
return source.replace(pattern, "\n");
|
||||
}
|
||||
|
||||
function firstTomlTableIndex(source: string): number {
|
||||
const match = source.match(/^\s*\[/m);
|
||||
return match?.index ?? -1;
|
||||
}
|
||||
|
||||
function readJsonObject(file: string): Record<string, unknown> {
|
||||
if (!existsSync(file)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
||||
return isRecord(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeFileWithBackup(file: string, content: string): { backupFile?: string; changed: boolean } {
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
|
||||
if (previous === content) {
|
||||
return { changed: false };
|
||||
}
|
||||
const backupFile = previous === undefined ? undefined : backupFilePath(file);
|
||||
if (backupFile) {
|
||||
copyFileSync(file, backupFile);
|
||||
}
|
||||
writeFileSync(file, content, "utf8");
|
||||
return { backupFile, changed: true };
|
||||
}
|
||||
|
||||
function backupFilePath(file: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `${file}.ccr-backup-${timestamp}`;
|
||||
}
|
||||
|
||||
function disabledStatus(client: "claude-code" | "codex", file: string, message: string): ProfileClientApplyStatus {
|
||||
return {
|
||||
client,
|
||||
enabled: false,
|
||||
message,
|
||||
ok: true,
|
||||
path: resolveUserPath(file)
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayEndpoint(config: AppConfig): string {
|
||||
const host = config.gateway.host === "0.0.0.0" ? "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}`;
|
||||
}
|
||||
|
||||
function clientToken(config: AppConfig): string {
|
||||
const key = config.APIKEYS.find((item) => item.key.trim())?.key.trim() || config.APIKEY.trim();
|
||||
return key || fallbackClientToken;
|
||||
}
|
||||
|
||||
function defaultClientModel(config: AppConfig): string {
|
||||
const configuredDefault = normalizeClientModel(config.Router.default);
|
||||
if (configuredDefault) {
|
||||
return configuredDefault;
|
||||
}
|
||||
const preferred = config.Providers.find((provider) => provider.name === config.preferredProvider) ?? config.Providers[0];
|
||||
if (preferred?.name && preferred.models[0]) {
|
||||
return `${preferred.name}/${preferred.models[0]}`;
|
||||
}
|
||||
return "gpt-5-codex";
|
||||
}
|
||||
|
||||
function normalizeClientModel(value: string | undefined): string {
|
||||
return normalizeRouteSelector(value)?.trim() || "";
|
||||
}
|
||||
|
||||
function resolveUserPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (trimmed.startsWith("~/")) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
return path.resolve(trimmed || ".");
|
||||
}
|
||||
|
||||
function sanitizeCodexProviderId(value: string): string {
|
||||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeCodexConfigFormat(value: ProfileConfig["configFormat"]): "legacy" | "separate_profile_files" {
|
||||
return value === "separate_profile_files" ? "separate_profile_files" : "legacy";
|
||||
}
|
||||
|
||||
function normalizeCodexRemoteFrontendMode(value: ProfileConfig["remoteFrontendMode"]): "app" | "cli" | "claude-code" {
|
||||
return value === "cli" || value === "claude-code" ? value : "app";
|
||||
}
|
||||
|
||||
function tomlKey(value: string): string {
|
||||
return /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlQuotedKey(value);
|
||||
}
|
||||
|
||||
function tomlQuotedKey(value: string): string {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function tomlString(value: string): string {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`;
|
||||
}
|
||||
|
||||
function tomlStringContent(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function trimLeadingBlankLines(value: string): string {
|
||||
return value.replace(/^\s*\n/g, "");
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(value: string): string {
|
||||
return value.endsWith("\n") ? value : `${value}\n`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import type {
|
||||
GatewayProviderCapability,
|
||||
GatewayProviderProbeProtocolResult,
|
||||
GatewayProviderProbeRequest,
|
||||
GatewayProviderProbeResult,
|
||||
GatewayProviderProtocol
|
||||
} from "../shared/app";
|
||||
import {
|
||||
compactProviderUrl,
|
||||
parseProviderBaseUrl,
|
||||
providerBaseUrlForProtocol,
|
||||
type ParsedProviderBaseUrl
|
||||
} from "../shared/provider-url";
|
||||
|
||||
type ModelSource = NonNullable<GatewayProviderProbeResult["modelSource"]>;
|
||||
|
||||
type ParsedProviderUrl = ParsedProviderBaseUrl & {
|
||||
hints: GatewayProviderProtocol[];
|
||||
};
|
||||
|
||||
type FetchJsonResult = {
|
||||
payload?: unknown;
|
||||
status?: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ModelProbeResult = {
|
||||
baseUrl?: string;
|
||||
models: string[];
|
||||
source?: ModelSource;
|
||||
};
|
||||
|
||||
type ModelFetchResult = {
|
||||
baseUrl?: string;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
type ProtocolEndpoint = {
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
const protocolOrder: GatewayProviderProtocol[] = [
|
||||
"openai_responses",
|
||||
"openai_chat_completions",
|
||||
"anthropic_messages",
|
||||
"gemini_generate_content"
|
||||
];
|
||||
|
||||
const modelSourceOrder: ModelSource[] = ["openai", "anthropic", "gemini"];
|
||||
const probeTimeoutMs = 10000;
|
||||
|
||||
export async function probeGatewayProvider(request: GatewayProviderProbeRequest): Promise<GatewayProviderProbeResult> {
|
||||
const parsed = parseProviderUrl(request.baseUrl);
|
||||
const protocols = uniqueProtocols(request.protocols ?? []);
|
||||
const typedModels = uniqueStrings(request.models ?? []);
|
||||
const modelProbe = await probeModels(parsed, request.apiKey, protocols);
|
||||
const models = modelProbe.models.length > 0 ? modelProbe.models : typedModels;
|
||||
const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols);
|
||||
const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols);
|
||||
|
||||
return {
|
||||
capabilities: capabilitiesFromProtocolResults(protocolResults),
|
||||
detectedProtocol,
|
||||
modelSource: modelProbe.source,
|
||||
models: modelProbe.models,
|
||||
normalizedBaseUrl: detectedProtocol
|
||||
? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe)
|
||||
: parsed.normalizedInputBaseUrl,
|
||||
protocols: protocolResults
|
||||
};
|
||||
}
|
||||
|
||||
function capabilitiesFromProtocolResults(results: GatewayProviderProbeProtocolResult[]): GatewayProviderCapability[] {
|
||||
return results
|
||||
.filter((result) => result.supported && result.baseUrl)
|
||||
.map((result) => ({
|
||||
baseUrl: result.baseUrl as string,
|
||||
endpoint: result.endpoint,
|
||||
source: "detected" as const,
|
||||
type: result.protocol
|
||||
}));
|
||||
}
|
||||
|
||||
async function probeModels(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
): Promise<ModelProbeResult> {
|
||||
for (const source of orderedModelSources(parsed, allowedProtocols)) {
|
||||
const result = await fetchModelsForSource(parsed, source, apiKey);
|
||||
if (result.models.length > 0) {
|
||||
return {
|
||||
baseUrl: result.baseUrl,
|
||||
models: result.models,
|
||||
source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: []
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSource, apiKey: string | undefined): Promise<ModelFetchResult> {
|
||||
if (source === "openai") {
|
||||
for (const baseUrl of parsed.openaiBaseUrlCandidates) {
|
||||
const result = await requestJson(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
...openAiHeaders(apiKey)
|
||||
},
|
||||
method: "GET"
|
||||
});
|
||||
const models = parseModelIds(result.payload, "openai");
|
||||
if (models.length > 0) {
|
||||
return {
|
||||
baseUrl,
|
||||
models
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: []
|
||||
};
|
||||
}
|
||||
|
||||
if (source === "anthropic") {
|
||||
for (const baseUrl of parsed.anthropicBaseUrlCandidates) {
|
||||
const result = await requestJson(`${baseUrl}/v1/models`, {
|
||||
headers: {
|
||||
...anthropicHeaders(apiKey)
|
||||
},
|
||||
method: "GET"
|
||||
});
|
||||
const models = parseModelIds(result.payload, "anthropic");
|
||||
if (models.length > 0) {
|
||||
return {
|
||||
baseUrl,
|
||||
models
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: []
|
||||
};
|
||||
}
|
||||
|
||||
const result = await requestJson(withGeminiKey(`${parsed.geminiBaseUrl}/v1beta/models`, apiKey), {
|
||||
headers: {
|
||||
...geminiHeaders(apiKey)
|
||||
},
|
||||
method: "GET"
|
||||
});
|
||||
return {
|
||||
baseUrl: parsed.geminiBaseUrl,
|
||||
models: parseModelIds(result.payload, "gemini")
|
||||
};
|
||||
}
|
||||
|
||||
async function probeProtocols(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
models: string[],
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
): Promise<GatewayProviderProbeProtocolResult[]> {
|
||||
const results: GatewayProviderProbeProtocolResult[] = [];
|
||||
|
||||
for (const protocol of orderedProtocols(parsed, allowedProtocols)) {
|
||||
results.push(await probeProtocol(parsed, apiKey, models, protocol));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function probeProtocol(
|
||||
parsed: ParsedProviderUrl,
|
||||
apiKey: string | undefined,
|
||||
models: string[],
|
||||
protocol: GatewayProviderProtocol
|
||||
): Promise<GatewayProviderProbeProtocolResult> {
|
||||
const model = pickProbeModel(models, protocol);
|
||||
const endpoints = endpointsForProtocol(parsed, protocol, model);
|
||||
const endpoint = endpoints[0]?.endpoint ?? providerBaseUrlForProtocol(parsed, protocol);
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
endpoint,
|
||||
message: "Model required before protocol verification.",
|
||||
protocol,
|
||||
supported: false
|
||||
};
|
||||
}
|
||||
|
||||
let firstResult: GatewayProviderProbeProtocolResult | undefined;
|
||||
|
||||
for (const candidate of endpoints) {
|
||||
const result = await requestJson(candidate.endpoint, requestForProtocol(protocol, model, apiKey));
|
||||
const message = readResponseMessage(result);
|
||||
const supported = isProtocolSupported(result.status, message);
|
||||
const probeResult = {
|
||||
baseUrl: candidate.baseUrl,
|
||||
endpoint: candidate.endpoint,
|
||||
message,
|
||||
protocol,
|
||||
status: result.status,
|
||||
supported
|
||||
};
|
||||
|
||||
firstResult ??= probeResult;
|
||||
if (supported) {
|
||||
return probeResult;
|
||||
}
|
||||
}
|
||||
|
||||
return firstResult ?? {
|
||||
endpoint,
|
||||
message: "No endpoint candidates available.",
|
||||
protocol,
|
||||
supported: false
|
||||
};
|
||||
}
|
||||
|
||||
function requestForProtocol(protocol: GatewayProviderProtocol, model: string, apiKey: string | undefined): RequestInit {
|
||||
if (protocol === "openai_responses") {
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
input: "ping",
|
||||
max_output_tokens: 1,
|
||||
model,
|
||||
stream: false
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...openAiHeaders(apiKey)
|
||||
},
|
||||
method: "POST"
|
||||
};
|
||||
}
|
||||
|
||||
if (protocol === "openai_chat_completions") {
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
max_tokens: 1,
|
||||
messages: [{ content: "ping", role: "user" }],
|
||||
model,
|
||||
stream: false
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...openAiHeaders(apiKey)
|
||||
},
|
||||
method: "POST"
|
||||
};
|
||||
}
|
||||
|
||||
if (protocol === "anthropic_messages") {
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
max_tokens: 1,
|
||||
messages: [{ content: "ping", role: "user" }],
|
||||
model,
|
||||
stream: false
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...anthropicHeaders(apiKey)
|
||||
},
|
||||
method: "POST"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: "ping" }], role: "user" }],
|
||||
generationConfig: {
|
||||
maxOutputTokens: 1
|
||||
}
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...geminiHeaders(apiKey)
|
||||
},
|
||||
method: "POST"
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(url: string, init: RequestInit): Promise<FetchJsonResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), probeTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
return {
|
||||
payload: parseJson(text),
|
||||
status: response.status,
|
||||
text
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
text: formatError(error)
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProviderUrl(value: string): ParsedProviderUrl {
|
||||
const parsed = parseProviderBaseUrl(value);
|
||||
const url = new URL(parsed.normalizedInputBaseUrl);
|
||||
const hints = uniqueProtocols([...protocolHints(parsed.raw), ...protocolHints(url.hostname)]);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
hints
|
||||
};
|
||||
}
|
||||
|
||||
function endpointsForProtocol(
|
||||
parsed: ParsedProviderUrl,
|
||||
protocol: GatewayProviderProtocol,
|
||||
model: string | undefined
|
||||
): ProtocolEndpoint[] {
|
||||
if (protocol === "openai_responses") {
|
||||
return parsed.openaiBaseUrlCandidates.map((baseUrl) => ({
|
||||
baseUrl,
|
||||
endpoint: `${baseUrl}/responses`
|
||||
}));
|
||||
}
|
||||
|
||||
if (protocol === "openai_chat_completions") {
|
||||
return parsed.openaiBaseUrlCandidates.map((baseUrl) => ({
|
||||
baseUrl,
|
||||
endpoint: `${baseUrl}/chat/completions`
|
||||
}));
|
||||
}
|
||||
|
||||
if (protocol === "anthropic_messages") {
|
||||
return parsed.anthropicBaseUrlCandidates.map((baseUrl) => ({
|
||||
baseUrl,
|
||||
endpoint: `${baseUrl}/v1/messages`
|
||||
}));
|
||||
}
|
||||
|
||||
const encodedModel = encodeURIComponent(stripGeminiModelPrefix(model || "model"));
|
||||
return [
|
||||
{
|
||||
baseUrl: parsed.geminiBaseUrl,
|
||||
endpoint: `${parsed.geminiBaseUrl}/v1beta/models/${encodedModel}:generateContent`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function withGeminiKey(url: string, apiKey: string | undefined): string {
|
||||
if (!apiKey) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set("key", apiKey);
|
||||
return compactProviderUrl(parsed);
|
||||
}
|
||||
|
||||
function openAiHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
return apiKey
|
||||
? {
|
||||
authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
function anthropicHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
return {
|
||||
"anthropic-version": "2023-06-01",
|
||||
...(apiKey ? { "x-api-key": apiKey } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function geminiHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
return apiKey
|
||||
? {
|
||||
"x-goog-api-key": apiKey
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
function parseModelIds(payload: unknown, source: ModelSource): string[] {
|
||||
if (!isRecord(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.models) ? payload.models : [];
|
||||
const models = items
|
||||
.map((item) => readModelId(item, source))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return uniqueStrings(models);
|
||||
}
|
||||
|
||||
function readModelId(value: unknown, source: ModelSource): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return source === "gemini" ? stripGeminiModelPrefix(value) : value;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawId = readString(value.id) || readString(value.name) || readString(value.model);
|
||||
if (!rawId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (source === "gemini") {
|
||||
const methods = Array.isArray(value.supportedGenerationMethods)
|
||||
? value.supportedGenerationMethods.map((item) => String(item))
|
||||
: [];
|
||||
if (methods.length > 0 && !methods.includes("generateContent")) {
|
||||
return undefined;
|
||||
}
|
||||
return stripGeminiModelPrefix(rawId);
|
||||
}
|
||||
|
||||
return rawId;
|
||||
}
|
||||
|
||||
function stripGeminiModelPrefix(value: string): string {
|
||||
return value.replace(/^models\//i, "");
|
||||
}
|
||||
|
||||
function pickProbeModel(models: string[], protocol: GatewayProviderProtocol): string | undefined {
|
||||
const candidates = uniqueStrings(models);
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (protocol === "gemini_generate_content") {
|
||||
return candidates.find((model) => model.toLowerCase().includes("gemini")) ?? candidates[0];
|
||||
}
|
||||
|
||||
if (protocol === "anthropic_messages") {
|
||||
return candidates.find((model) => model.toLowerCase().includes("claude")) ?? candidates[0];
|
||||
}
|
||||
|
||||
return (
|
||||
candidates.find((model) => {
|
||||
const normalized = model.toLowerCase();
|
||||
return /gpt|o\d|deepseek|qwen|glm|kimi|llama|mistral|command|sonar|yi-|doubao/.test(normalized);
|
||||
}) ?? candidates[0]
|
||||
);
|
||||
}
|
||||
|
||||
function orderedProtocols(
|
||||
parsed: ParsedProviderUrl,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
): GatewayProviderProtocol[] {
|
||||
const ordered = uniqueProtocols([...parsed.hints, ...protocolOrder]);
|
||||
if (allowedProtocols.length === 0) {
|
||||
return ordered;
|
||||
}
|
||||
const allowed = new Set(allowedProtocols);
|
||||
return ordered.filter((protocol) => allowed.has(protocol));
|
||||
}
|
||||
|
||||
function orderedModelSources(
|
||||
parsed: ParsedProviderUrl,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
): ModelSource[] {
|
||||
const allowedSources = allowedProtocols.length > 0
|
||||
? new Set(allowedProtocols.map(protocolModelSource))
|
||||
: undefined;
|
||||
const hintedSources = parsed.hints
|
||||
.map(protocolModelSource)
|
||||
.filter((item): item is ModelSource => Boolean(item));
|
||||
const ordered = uniqueModelSources([...hintedSources, ...modelSourceOrder]);
|
||||
if (!allowedSources) {
|
||||
return ordered;
|
||||
}
|
||||
return ordered.filter((source) => allowedSources.has(source));
|
||||
}
|
||||
|
||||
function protocolModelSource(protocol: GatewayProviderProtocol): ModelSource {
|
||||
if (protocol === "anthropic_messages") {
|
||||
return "anthropic";
|
||||
}
|
||||
if (protocol === "gemini_generate_content") {
|
||||
return "gemini";
|
||||
}
|
||||
return "openai";
|
||||
}
|
||||
|
||||
function orderedProtocolFallback(allowedProtocols: GatewayProviderProtocol[] = []): GatewayProviderProtocol | undefined {
|
||||
if (allowedProtocols.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const allowed = new Set(allowedProtocols);
|
||||
return protocolOrder.find((protocol) => allowed.has(protocol)) ?? allowedProtocols[0];
|
||||
}
|
||||
|
||||
function protocolIsAllowed(protocol: GatewayProviderProtocol, allowedProtocols: GatewayProviderProtocol[]): boolean {
|
||||
return allowedProtocols.length === 0 || allowedProtocols.includes(protocol);
|
||||
}
|
||||
|
||||
function detectProtocol(
|
||||
parsed: ParsedProviderUrl,
|
||||
protocols: GatewayProviderProbeProtocolResult[],
|
||||
modelSource: ModelSource | undefined,
|
||||
allowedProtocols: GatewayProviderProtocol[] = []
|
||||
): GatewayProviderProtocol | undefined {
|
||||
const supported = protocols.find((item) => item.supported);
|
||||
if (supported) {
|
||||
return supported.protocol;
|
||||
}
|
||||
|
||||
const hinted = parsed.hints.find((protocol) => protocolIsAllowed(protocol, allowedProtocols));
|
||||
if (hinted) {
|
||||
return hinted;
|
||||
}
|
||||
|
||||
if (modelSource === "anthropic" && protocolIsAllowed("anthropic_messages", allowedProtocols)) {
|
||||
return "anthropic_messages";
|
||||
}
|
||||
|
||||
if (modelSource === "gemini" && protocolIsAllowed("gemini_generate_content", allowedProtocols)) {
|
||||
return "gemini_generate_content";
|
||||
}
|
||||
|
||||
if (modelSource === "openai") {
|
||||
const openAiProtocols = orderedProtocols(parsed, allowedProtocols).filter((protocol) =>
|
||||
protocol === "openai_responses" || protocol === "openai_chat_completions"
|
||||
);
|
||||
return openAiProtocols.find((protocol) => parsed.hints.includes(protocol)) ??
|
||||
openAiProtocols.find((protocol) => protocol === "openai_chat_completions") ??
|
||||
openAiProtocols[0];
|
||||
}
|
||||
|
||||
return orderedProtocolFallback(allowedProtocols);
|
||||
}
|
||||
|
||||
function resolveProbeBaseUrl(
|
||||
parsed: ParsedProviderUrl,
|
||||
protocol: GatewayProviderProtocol,
|
||||
protocols: GatewayProviderProbeProtocolResult[],
|
||||
modelProbe: ModelProbeResult
|
||||
): string {
|
||||
const supported = protocols.find((item) => item.protocol === protocol && item.supported && item.baseUrl);
|
||||
if (supported?.baseUrl) {
|
||||
return supported.baseUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
(protocol === "openai_responses" || protocol === "openai_chat_completions") &&
|
||||
modelProbe.source === "openai" &&
|
||||
modelProbe.baseUrl
|
||||
) {
|
||||
return modelProbe.baseUrl;
|
||||
}
|
||||
|
||||
return providerBaseUrlForProtocol(parsed, protocol);
|
||||
}
|
||||
|
||||
function protocolHints(value: string): GatewayProviderProtocol[] {
|
||||
const normalized = value.toLowerCase();
|
||||
const hints: GatewayProviderProtocol[] = [];
|
||||
|
||||
if (normalized.includes("chat/completions")) {
|
||||
hints.push("openai_chat_completions");
|
||||
}
|
||||
if (normalized.includes("responses")) {
|
||||
hints.push("openai_responses");
|
||||
}
|
||||
if (normalized.includes("api.openai.com") || normalized.includes("openai")) {
|
||||
hints.push("openai_responses");
|
||||
}
|
||||
if (normalized.includes("anthropic") || normalized.includes("/messages")) {
|
||||
hints.push("anthropic_messages");
|
||||
}
|
||||
if (normalized.includes("generativelanguage.googleapis.com") || normalized.includes("gemini") || normalized.includes("generatecontent")) {
|
||||
hints.push("gemini_generate_content");
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
function isProtocolSupported(status: number | undefined, message: string): boolean {
|
||||
if (status === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 400) {
|
||||
const normalized = message.toLowerCase();
|
||||
return /model|max_tokens|max output|messages|input|required/.test(normalized) && !/not found|unknown endpoint|unknown route|no route/.test(normalized);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function readResponseMessage(result: FetchJsonResult): string {
|
||||
if (result.status === undefined) {
|
||||
return result.text || "Request failed.";
|
||||
}
|
||||
|
||||
const payloadMessage = readPayloadMessage(result.payload);
|
||||
if (payloadMessage) {
|
||||
return `HTTP ${result.status}: ${payloadMessage}`;
|
||||
}
|
||||
|
||||
return `HTTP ${result.status}`;
|
||||
}
|
||||
|
||||
function readPayloadMessage(payload: unknown): string | undefined {
|
||||
if (!isRecord(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const directMessage = readString(payload.message);
|
||||
if (directMessage) {
|
||||
return directMessage;
|
||||
}
|
||||
|
||||
if (isRecord(payload.error)) {
|
||||
return readString(payload.error.message) || readString(payload.error.type) || readString(payload.error.code);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
if (!value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
result.push(trimmed);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function uniqueProtocols(values: GatewayProviderProtocol[]): GatewayProviderProtocol[] {
|
||||
return values.filter((value, index) => values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function uniqueModelSources(values: ModelSource[]): ModelSource[] {
|
||||
return values.filter((value, index) => values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import forge from "node-forge";
|
||||
import { CERTDIR, PROXY_CA_CERT_FILE, PROXY_CA_KEY_FILE } from "../constants";
|
||||
|
||||
const pki = forge.pki;
|
||||
|
||||
export type CertificateAuthority = {
|
||||
cert: forge.pki.Certificate;
|
||||
key: forge.pki.rsa.PrivateKey;
|
||||
};
|
||||
|
||||
export type PemPair = {
|
||||
cert: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type SubjectAltName = {
|
||||
ip?: string;
|
||||
type: 2 | 7;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export function ensureProxyCertificateAuthority(): void {
|
||||
mkdirSync(CERTDIR, { recursive: true });
|
||||
if (existsSync(PROXY_CA_CERT_FILE) && existsSync(PROXY_CA_KEY_FILE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = pki.rsa.generateKeyPair(2048);
|
||||
const cert = pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = createSerialNumber();
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 20);
|
||||
|
||||
const attrs = [
|
||||
{ name: "commonName", value: `Claude Code Router CA (${os.hostname()})` },
|
||||
{ name: "countryName", value: "US" },
|
||||
{ shortName: "ST", value: "California" },
|
||||
{ name: "localityName", value: "San Francisco" },
|
||||
{ name: "organizationName", value: "Claude Code Router" },
|
||||
{ shortName: "OU", value: "CCR MITM Proxy" }
|
||||
];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
cert.setExtensions([
|
||||
{
|
||||
cA: true,
|
||||
critical: true,
|
||||
name: "basicConstraints"
|
||||
},
|
||||
{
|
||||
critical: true,
|
||||
digitalSignature: true,
|
||||
keyCertSign: true,
|
||||
cRLSign: true,
|
||||
name: "keyUsage"
|
||||
},
|
||||
{
|
||||
name: "subjectKeyIdentifier"
|
||||
}
|
||||
]);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), "utf8");
|
||||
writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), "utf8");
|
||||
}
|
||||
|
||||
export function proxyCertificateAuthorityExists(): boolean {
|
||||
return existsSync(PROXY_CA_CERT_FILE) && existsSync(PROXY_CA_KEY_FILE);
|
||||
}
|
||||
|
||||
export function readProxyCertificateAuthority(): CertificateAuthority {
|
||||
ensureProxyCertificateAuthority();
|
||||
return {
|
||||
cert: pki.certificateFromPem(readFileSync(PROXY_CA_CERT_FILE, "utf8")),
|
||||
key: pki.privateKeyFromPem(readFileSync(PROXY_CA_KEY_FILE, "utf8")) as forge.pki.rsa.PrivateKey
|
||||
};
|
||||
}
|
||||
|
||||
export function proxyCertificateAuthorityKeyMatches(): boolean {
|
||||
if (!proxyCertificateAuthorityExists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const authority = readProxyCertificateAuthority();
|
||||
const publicKey = authority.cert.publicKey as forge.pki.rsa.PublicKey;
|
||||
return authority.key.n.equals(publicKey.n) && authority.key.e.equals(publicKey.e);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readProxyCertificateFingerprintSha256(): string | undefined {
|
||||
if (!existsSync(PROXY_CA_CERT_FILE)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return fingerprintPem(readFileSync(PROXY_CA_CERT_FILE, "utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function readProxyCertificateSerialNumber(): string | undefined {
|
||||
if (!existsSync(PROXY_CA_CERT_FILE)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const cert = pki.certificateFromPem(readFileSync(PROXY_CA_CERT_FILE, "utf8"));
|
||||
return cert.serialNumber;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCertificateForHost(hostname: string, authority: CertificateAuthority): PemPair {
|
||||
const keys = pki.rsa.generateKeyPair(2048);
|
||||
const cert = pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = createSerialNumber();
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 10);
|
||||
|
||||
const attrs = [
|
||||
{ name: "commonName", value: hostname },
|
||||
{ name: "countryName", value: "US" },
|
||||
{ shortName: "ST", value: "California" },
|
||||
{ name: "localityName", value: "San Francisco" }
|
||||
];
|
||||
|
||||
cert.setIssuer(authority.cert.subject.attributes);
|
||||
cert.setSubject(attrs);
|
||||
cert.setExtensions([
|
||||
{
|
||||
cA: false,
|
||||
critical: true,
|
||||
name: "basicConstraints"
|
||||
},
|
||||
{
|
||||
critical: true,
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
name: "keyUsage"
|
||||
},
|
||||
{
|
||||
altNames: [subjectAltName(hostname)],
|
||||
name: "subjectAltName"
|
||||
},
|
||||
{
|
||||
name: "extKeyUsage",
|
||||
serverAuth: true
|
||||
},
|
||||
{
|
||||
name: "subjectKeyIdentifier"
|
||||
},
|
||||
{
|
||||
keyIdentifier: authority.cert.generateSubjectKeyIdentifier().getBytes(),
|
||||
name: "authorityKeyIdentifier"
|
||||
}
|
||||
]);
|
||||
cert.sign(authority.key, forge.md.sha256.create());
|
||||
|
||||
return {
|
||||
cert: pki.certificateToPem(cert),
|
||||
key: pki.privateKeyToPem(keys.privateKey)
|
||||
};
|
||||
}
|
||||
|
||||
export function proxyCaCertFile(): string {
|
||||
return path.normalize(PROXY_CA_CERT_FILE);
|
||||
}
|
||||
|
||||
function createSerialNumber(): string {
|
||||
const bytes = randomBytes(16);
|
||||
bytes[0] &= 0x7f;
|
||||
if (bytes.every((byte) => byte === 0)) {
|
||||
bytes[15] = 1;
|
||||
}
|
||||
return bytes.toString("hex");
|
||||
}
|
||||
|
||||
function fingerprintPem(pem: string): string {
|
||||
const der = Buffer.from(
|
||||
pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
||||
.replace(/-----END CERTIFICATE-----/g, "")
|
||||
.replace(/\s+/g, ""),
|
||||
"base64"
|
||||
);
|
||||
return createHash("sha256")
|
||||
.update(der)
|
||||
.digest("hex")
|
||||
.match(/.{1,2}/g)!
|
||||
.join(":")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function subjectAltName(hostname: string): SubjectAltName {
|
||||
return net.isIP(hostname)
|
||||
? {
|
||||
ip: hostname,
|
||||
type: 7
|
||||
}
|
||||
: {
|
||||
type: 2,
|
||||
value: hostname
|
||||
};
|
||||
}
|
||||