45 Commits

Author SHA1 Message Date
jinhui.li
cba0536c45 Refactor plugin 2025-06-23 06:05:58 +08:00
jinhui.li
dba8b1e6c8 update Sponsors 2025-06-20 22:25:56 +08:00
jinhui.li
6bdcf4ccc2 release v1.0.9 2025-06-20 22:17:45 +08:00
musi
add5cfb6c2 Merge pull request #37 from Evyatar108/patch-1
Fix (1) multi/parallel-tool invocation and (2) `API Error: A.map` permanently
2025-06-20 21:54:59 +08:00
Evyatar108
c984b57585 Fix multi/parallel-tool invocation 2025-06-20 00:16:47 -07:00
musi
258ef787c7 update features 2025-06-19 22:14:48 +08:00
musi
2c3f89cf53 Merge branch 'main' of github.com:musistudio/claude-code-router 2025-06-19 21:15:19 +08:00
musi
a67fce3991 update sponsors 2025-06-19 21:14:52 +08:00
jinhui.li
d3856c0cf9 Merge remote-tracking branch 'origin/main' 2025-06-19 12:07:42 +08:00
jinhui.li
2cad9e93b8 update README 2025-06-19 12:06:55 +08:00
musi
d6be620cec update sponsors 2025-06-18 21:19:38 +08:00
jinhui.li
57a7da14a3 add plugins 2025-06-18 12:20:07 +08:00
jinhui.li
84cb9a2009 release v1.0.8 2025-06-17 12:38:33 +08:00
jinhui.li
ac51db990c save request errors to a log file 2025-06-17 12:37:52 +08:00
jinhui.li
ae88d63c7c adjust /model command priority 2025-06-17 12:36:59 +08:00
jinhui.li
dd29cf895f fix API Error: A.map is not a function 2025-06-17 09:01:53 +08:00
musi
56ab2ee309 add Sponsors 2025-06-17 06:26:46 +08:00
musi
d0d164e8ea release v1.0.7 2025-06-17 06:12:59 +08:00
jinhui.li
ca1b9a5fba update README 2025-06-16 13:04:43 +08:00
jinhui.li
4482853222 update README 2025-06-16 13:03:20 +08:00
jinhui.li
4dc73a31eb add fisrt article 2025-06-16 13:02:15 +08:00
jinhui.li
329b5d9b9b fix API Error when using proxy 2025-06-16 12:43:44 +08:00
musi
0da9cf156d release v1.0.6 2025-06-16 06:16:07 +08:00
musi
d810e2f57e fix missing tiktoken_bg.wasm 2025-06-16 06:14:40 +08:00
musi
81514b0676 fix doc typo 2025-06-15 22:33:06 +08:00
musi
a3ec2c223d fix not working without router 2025-06-15 20:45:32 +08:00
jinhui.li
ee8b82947d release v1.0.4 2025-06-15 20:28:11 +08:00
jinhui.li
1aa6dbe51a add CLAUDE_PATH env variable 2025-06-15 20:26:35 +08:00
musi
80d9298b34 update README 2025-06-15 20:13:11 +08:00
jinhui.li
5e70bc70c0 Support multiple plugins 2025-06-15 16:58:11 +08:00
jinhui.li
9a89250d79 use the router to dispatch different models: background,longcontext and think 2025-06-14 19:48:29 +08:00
musi
7a5d712444 release v1.0.3 2025-06-13 06:23:45 +08:00
jinhui.li
84e76f24b0 fix the issue of multiple calude using one server by claude code 2025-06-12 09:58:05 +08:00
musi
c9059f146d fix miss api 2025-06-10 21:43:01 +08:00
jinhui.li
9cffebf081 add LICENSE 2025-06-10 16:46:14 +08:00
jinhui.li
111492b908 add screenshot 2025-06-10 13:30:18 +08:00
jinhui.li
edc8ecbcba add screenshot 2025-06-10 13:28:41 +08:00
jinhui.li
ea68b2ea55 fix typo 2025-06-10 13:19:02 +08:00
jinhui.li
3b0d7bac0c add doc 2025-06-10 13:15:36 +08:00
jinhui.li
6912572fbb Merge branch 'feature/cli'
# Conflicts:
#	.gitignore
#	index.mjs
2025-06-10 12:58:00 +08:00
jinhui.li
2cc91ada5c add cli 2025-06-10 12:55:25 +08:00
musi
aa3f72f390 Merge pull request #8 from sbtobb/feature-docker-config
Feature add docker config
2025-05-06 08:47:49 +08:00
TOBB
6e4022b6f1 config(gitignore): Update .gitignore to exclude common files 2025-05-05 23:38:05 +08:00
TOBB
30bf711a2a config(docker): Add Docker configuration files 2025-05-05 23:37:00 +08:00
TOBB
2ade113c2a refactor(index.mjs): change listen ip to 0/32
Make server listen on all network interfaces

Allow external connections by binding to 0.0.0.0 instead of localhost only
2025-05-05 23:36:06 +08:00
41 changed files with 2145 additions and 568 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

View File

@@ -1,31 +0,0 @@
## If you don't want to use multi-model routing
## set ENABLE_ROUTER to false, and define the following variables
## the model needs to support function calling
ENABLE_ROUTER=false
OPENAI_API_KEY=""
OPENAI_BASE_URL=""
OPENAI_MODEL=""
## If you want to use multi-model routing, set ENABLE_ROUTER to true
# ENABLE_ROUTER=true
## Define the model for the tool agent, the model needs to support function calling
# TOOL_AGENT_API_KEY=""
# TOOL_AGENT_BASE_URL=""
# TOOL_AGENT_MODEL=""
## Define the model for the coder agent
# CODER_AGENT_API_KEY=""
# CODER_AGENT_BASE_URL=""
# CODER_AGENT_MODEL=""
## Define the model for the thinker agent, using a model that supports reasoning will yield better results
# THINK_AGENT_API_KEY=""
# THINK_AGENT_BASE_URL=""
# THINK_AGENT_MODEL=""
## Define the model for the router agent, this model is the entry point for each request, it will consume a lot of tokens, please choose a small model to reduce costs
# ROUTER_AGENT_API_KEY=""
# ROUTER_AGENT_BASE_URL=""
# ROUTER_AGENT_MODEL=""

11
.npmignore Normal file
View File

@@ -0,0 +1,11 @@
src
node_modules
.claude
CLAUDE.md
screenshoots
.DS_Store
.vscode
.idea
.env
.blog
docs

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.You need use English to write text.
## Key Development Commands
- Build: `npm run build`
- Start: `npm start`
## Architecture
- Uses `express` for routing (see `src/server.ts`)
- Bundles with `esbuild` for CLI distribution
- Plugins are loaded from `$HOME/.claude-code-router/plugins`

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 musistudio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

284
README.md
View File

@@ -1,60 +1,272 @@
# Claude Code Router
> This is a repository for testing routing Claude Code requests to different models.
> This is a tool for routing Claude Code requests to different models, and you can customize any request.
![demo.png](https://github.com/musistudio/claude-code-router/blob/main/screenshoots/demo.png)
## Implemented
- [x] Support writing custom plugins for rewriting prompts.
- [x] Support writing custom plugins for implementing routers.
![](screenshoots/claude-code.png)
## Usage
0. Install Claude Code
1. Install Claude Code
```shell
npm install -g @anthropic-ai/claude-code
```
1. Clone this repo and install dependencies
2. Install Claude Code Router
```shell
git clone https://github.com/musistudio/claude-code-router
cd claude-code-router && pnpm i
npm run build
npm install -g @musistudio/claude-code-router
```
2. Start claude-code-router server
3. Start Claude Code by claude-code-router
```shell
node dist/cli.js
ccr code
```
3. Set environment variable to start claude code
```shell
export DISABLE_PROMPT_CACHING=1
export ANTHROPIC_BASE_URL="http://127.0.0.1:3456"
export API_TIMEOUT_MS=600000
claude
```
## Plugin
The plugin allows users to rewrite Claude Code prompt and custom router. The plugin path is in `$HOME/.claude-code-router/plugins`. Currently, there are two demos available:
1. [custom router](https://github.com/musistudio/claude-code-router/blob/dev/custom-prompt/plugins/deepseek.js)
2. [rewrite prompt](https://github.com/musistudio/claude-code-router/blob/dev/custom-prompt/plugins/gemini.js)
You need to move them to the `$HOME/.claude-code-router/plugins` directory and configure 'usePlugin' in `$HOME/.claude-code-router/config.json`like this:
4. Configure routing[optional]
Set up your `~/.claude-code-router/config.json` file like this:
```json
{
"usePlugin": "gemini",
"LOG": true,
"OPENAI_API_KEY": "",
"OPENAI_BASE_URL": "",
"OPENAI_MODEL": ""
"OPENAI_API_KEY": "sk-xxx",
"OPENAI_BASE_URL": "https://api.deepseek.com",
"OPENAI_MODEL": "deepseek-chat",
"Providers": [
{
"name": "openrouter",
"api_base_url": "https://openrouter.ai/api/v1",
"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"
]
},
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com",
"api_key": "sk-xxx",
"models": ["deepseek-reasoner"]
},
{
"name": "ollama",
"api_base_url": "http://localhost:11434/v1",
"api_key": "ollama",
"models": ["qwen2.5-coder:latest"]
}
],
"Router": {
"background": "ollama,qwen2.5-coder:latest",
"think": "deepseek,deepseek-reasoner",
"longContext": "openrouter,google/gemini-2.5-pro-preview"
}
}
```
- `background`
This model will be used to handle some background tasks([background-token-usage](https://docs.anthropic.com/en/docs/claude-code/costs#background-token-usage)). Based on my tests, it doesnt require high intelligence. Im using the qwen-coder-2.5:7b model running locally on my MacBook Pro M1 (32GB) via Ollama.
If your computer cant run Ollama, you can also use some free models, such as qwen-coder-2.5:3b.
- `think`
This model will be used when enabling Claude Code to perform reasoning. However, reasoning budget control has not yet been implemented (since the DeepSeek-R1 model does not support it), so there is currently no difference between using UltraThink and Think modes.
It is worth noting that Plan Mode also use this model to achieve better planning results.
Note: The reasoning process via the official DeepSeek API may be very slow, so you may need to wait for an extended period of time.
- `longContext`
This model will be used when the context length exceeds 32K (this value may be modified in the future). You can route the request to a model that performs well with long contexts (Ive chosen google/gemini-2.5-pro-preview). This scenario has not been thoroughly tested yet, so if you encounter any issues, please submit an issue.
- model command
You can also switch models within Claude Code by using the `/model` command. The format is: `provider,model`, like this:
`/model openrouter,anthropic/claude-3.5-sonnet`
This will use the anthropic/claude-3.5-sonnet model provided by OpenRouter to handle all subsequent tasks.
## Features
- [x] Support change models
- [x] Github Actions
- [ ] More robust plugin support
- [ ] More detailed logs
## Plugins
You can modify or enhance Claude Codes functionality by installing plugins.
### Plugin Mechanism
Plugins are loaded from the `~/.claude-code-router/plugins/` directory. Each plugin is a JavaScript file that exports functions corresponding to specific "hooks" in the request lifecycle. The system overrides Node.js's module loading to allow plugins to import a special `claude-code-router` module, providing access to utilities like `streamOpenAIResponse`, `log`, and `createClient`.
### Plugin Hooks
Plugins can implement various hooks to modify behavior at different stages:
- `beforeRouter`: Executed before routing.
- `afterRouter`: Executed after routing.
- `beforeTransformRequest`: Executed before transforming the request.
- `afterTransformRequest`: Executed after transforming the request.
- `beforeTransformResponse`: Executed before transforming the response.
- `afterTransformResponse`: Executed after transforming the response.
### Enabling Plugins
To use a plugin:
1. Place your plugin's JavaScript file (e.g., `my-plugin.js`) in the `~/.claude-code-router/plugins/` directory.
2. Specify the plugin name (without the `.js` extension) in your `~/.claude-code-router/config.json` file using the `usePlugins` option:
```json
// ~/.claude-code-router/config.json
{
...,
"usePlugins": ["my-plugin", "another-plugin"],
// or use plugins for a specific provider
"Providers": [
{
"name": "gemini",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
"api_key": "xxx",
"models": ["gemini-2.5-flash"],
"usePlugins": ["gemini"]
}
]
}
```
### Available Plugins
Currently, the following plugins are available:
- **notebook-tools-filter**
This plugin filters out tool calls related to Jupyter notebooks (.ipynb files). You can use it if your work does not involve Jupyter.
- **gemini**
Add support for the Google Gemini API endpoint: `https://generativelanguage.googleapis.com/v1beta/openai/`.
- **toolcall-improvement**
If your LLM doesnt handle tool usage well (for example, always returning code as plain text instead of modifying files — such as with deepseek-v3), you can use this plugin.
This plugin simply adds the following system prompt. If you have a better prompt, you can modify it.
```markdown
## **Important Instruction:**
You must use tools as frequently and accurately as possible to help the user solve their problem.
Prioritize tool usage whenever it can enhance accuracy, efficiency, or the quality of the response.
```
## Github Actions
You just need to install `Claude Code Actions` in your repository according to the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions). For `ANTHROPIC_API_KEY`, you can use any string. Then, modify your `.github/workflows/claude.yaml` file to include claude-code-router, like this:
```yaml
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
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,
"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: "test"
```
You can modify the contents of `$HOME/.claude-code-router/config.json` as needed.
GitHub Actions support allows you to trigger Claude Code at specific times, which opens up some interesting possibilities.
For example, between 00:30 and 08:30 Beijing Time, using the official DeepSeek API:
- The cost of the `deepseek-v3` model is only 50% of the normal time.
- The `deepseek-r1` model is just 25% of the normal time.
So maybe in the future, Ill describe detailed tasks for Claude Code ahead of time and let it run during these discounted hours to reduce costs?
## Some tips:
Now you can use deepseek-v3 models directly without using any plugins.
If youre using the DeepSeek API provided by the official website, you might encounter an “exceeding context” error after several rounds of conversation (since the official API only supports a 64K context window). In this case, youll need to discard the previous context and start fresh. Alternatively, you can use ByteDances DeepSeek API, which offers a 128K context window and supports KV cache.
![](screenshoots/contexterror.jpg)
Note: claude code consumes a huge amount of tokens, but thanks to DeepSeeks low cost, you can use claude code at a fraction of Claudes price, and you dont need to subscribe to the Claude Max plan.
Some interesting points: Based on my testing, including a lot of context information can help narrow the performance gap between these LLM models. For instance, when I used Claude-4 in VSCode Copilot to handle a Flutter issue, it messed up the files in three rounds of conversation, and I had to roll everything back. However, when I used claude code with DeepSeek, after three or four rounds of conversation, I finally managed to complete my task—and the cost was less than 1 RMB!
## Some articles:
1. [Project Motivation and Principles](blog/en/project-motivation-and-how-it-works.md) ([中文版看这里](blog/zh/项目初衷及原理.md))
## Buy me a coffee
If you find this project helpful, you can choose to sponsor the author with a cup of coffee. Please provide your GitHub information so I can add you to the sponsor list below.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM)
<table>
<tr>
<td><img src="/blog/images/alipay.jpg" width="200" /></td>
<td><img src="/blog/images/wechat.jpg" width="200" /></td>
</tr>
</table>
## Sponsors
Thanks to the following sponsors:
@Simon Leischnig (If you see this, feel free to contact me and I can update it with your GitHub information)
[@duanshuaimin](https://github.com/duanshuaimin)
[@vrgitadmin](https://github.com/vrgitadmin)
@*o (可通过主页邮箱联系我修改 github 用户名)
@\*\*聪 (可通过主页邮箱联系我修改 github 用户名)
@*说 (可通过主页邮箱联系我修改 github 用户名)
@\*更 (可通过主页邮箱联系我修改 github 用户名)

View File

@@ -0,0 +1,103 @@
# 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 didnt need the full source code—just a way to intercept and reroute requests made by Claude Code to Anthropics 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 WebStorms `Format File` feature, you can reformat it for better readability:
![webstorm-formate-file](../images/webstorm-formate-file.png)
Now you can begin understanding Claude Codes 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:
![chrome-devtools](../images/chrome-inspect.png)
![chrome-devtools](../images/chrome-devtools.png)
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:
![search](../images/search.png)
So far, weve 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 APIcompatible 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 couldnt 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 doesnt 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 Codes 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 Anthropics regional restrictions, balancing `cost`, `performance`, and `customizability`. That said, the official `Max Plan` still offers the best experience if available.

BIN
blog/images/alipay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

BIN
blog/images/search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

BIN
blog/images/wechat.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -0,0 +1,96 @@
# 项目初衷及原理
早在 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`功能可以重新格式化,让代码变得稍微好看一点。就像这样:
![webstorm-formate-file](../images/webstorm-formate-file.png)
现在,你可以通过阅读部分代码来了解`Claude Code`的内容工具原理与提示词。你也可以在关键地方使用`console.log`来获得更多信息,当然,也可以使用`Chrome Devtools`来进行断点调试,使用以下命令启动`Claude Code`:
```bash
NODE_OPTIONS="--inspect-brk=9229" claude
```
该命令会以调试模式启动`Claude Code`,并将调试的端口设置为`9229`。这时候通过 Chrome 访问`chrome://inspect/`即可看到当前的`Claude Code`进程,点击`inspect`即可进行调试。
![chrome-devtools](../images/chrome-inspect.png)
![chrome-devtools](../images/chrome-devtools.png)
通过搜索关键字符`api.anthropic.com`很容易能找到`Claude Code`用来发请求的地方,根据上下文的查看,很容易发现这里的`baseURL`可以通过环境变量`ANTHROPIC_BASE_URL`进行覆盖,`apiKey`和`authToken`也同理。
![search](../images/search.png)
到目前为止,我们获得关键信息:
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体验最好。

8
config.json Normal file
View File

@@ -0,0 +1,8 @@
{
"usePlugin": "",
"LOG": true,
"OPENAI_API_KEY": "",
"OPENAI_BASE_URL": "",
"OPENAI_MODEL": "",
"modelProviders": {}
}

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "3.8"
services:
claude-code-reverse:
build: .
ports:
- "3456:3456"
environment:
- ENABLE_ROUTER=${ENABLE_ROUTER}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- OPENAI_MODEL=${OPENAI_MODEL}
restart: unless-stopped

12
dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm i
COPY . .
EXPOSE 3456
CMD ["node", "index.mjs"]

442
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,31 @@
{
"name": "claude-code-router",
"version": "1.0.0",
"name": "@musistudio/claude-code-router",
"version": "1.0.9",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": {
"claude-code-router": "./dist/cli.js"
"ccr": "./dist/cli.js"
},
"scripts": {
"start": "node dist/cli.js",
"build": "tsc && esbuild src/index.ts --bundle --platform=node --outfile=dist/cli.js"
"build": "esbuild src/cli.ts --bundle --platform=node --outfile=dist/cli.js && cp node_modules/tiktoken/tiktoken_bg.wasm dist/tiktoken_bg.wasm"
},
"keywords": ["claude", "code", "router", "llm", "anthropic"],
"keywords": [
"claude",
"code",
"router",
"llm",
"anthropic"
],
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-code": "^0.2.53",
"@anthropic-ai/sdk": "^0.39.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"https-proxy-agent": "^7.0.6",
"openai": "^4.85.4"
"lru-cache": "^11.1.0",
"openai": "^4.85.4",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/express": "^5.0.0",

View File

@@ -1,139 +0,0 @@
const {
log,
streamOpenAIResponse,
createClient,
} = require("claude-code-router");
const thinkRouter = {
name: "think",
description: `This agent is used solely for complex reasoning and thinking tasks. It should not be called for information retrieval or repetitive, frequent requests. Only use this agent for tasks that require deep analysis or problem-solving. If there is an existing result from the Thinker agent, do not call this agent again.你只负责深度思考以拆分任务,不需要进行任何的编码和调用工具。最后讲拆分的步骤按照顺序返回。比如\n1. xxx\n2. xxx\n3. xxx`,
run(args) {
const client = createClient({
apiKey: process.env.THINK_AGENT_API_KEY,
baseURL: process.env.THINK_AGENT_BASE_URL,
});
const messages = JSON.parse(JSON.stringify(args.messages));
messages.forEach((msg) => {
if (Array.isArray(msg.content)) {
msg.content = JSON.stringify(msg.content);
}
});
let startIdx = messages.findIndex((msg) => msg.role !== "system");
if (startIdx === -1) startIdx = messages.length;
for (let i = startIdx; i < messages.length; i++) {
const expectedRole = (i - startIdx) % 2 === 0 ? "user" : "assistant";
messages[i].role = expectedRole;
}
if (
messages.length > 0 &&
messages[messages.length - 1].role === "assistant"
) {
messages.push({
role: "user",
content:
"Please follow the instructions provided above to resolve the issue.",
});
}
delete args.tools;
return client.chat.completions.create({
...args,
messages,
model: process.env.THINK_AGENT_MODEL,
});
},
};
class Router {
constructor() {
this.routers = [thinkRouter];
this.client = createClient({
apiKey: process.env.ROUTER_AGENT_API_KEY,
baseURL: process.env.ROUTER_AGENT_BASE_URL,
});
}
async route(args) {
log(`Request Router: ${JSON.stringify(args, null, 2)}`);
const res = await this.client.chat.completions.create({
...args,
messages: [
...args.messages,
{
role: "system",
content: `## **Guidelines:**
- **Trigger the "think" mode when the user's request involves deep thinking, complex reasoning, or multi-step analysis.**
- **Criteria:**
- Involves multi-layered logical reasoning or causal analysis
- Requires establishing connections or pattern recognition between different pieces of information
- Involves cross-domain knowledge integration or weighing multiple possibilities
- Requires creative thinking or non-direct inference
### **Special Case:**
- **When the user sends "test", respond with "success" only.**
### **Format requirements:**
- When you need to trigger the "think" mode, return the following JSON format:
\`\`\`json
{
"use": "think"
}
\`\`\`
`,
},
],
model: process.env.ROUTER_AGENT_MODEL,
stream: false,
});
let result;
try {
const text = res.choices[0].message.content;
if (!text) {
throw new Error("No text");
}
result = JSON.parse(
text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)
);
} catch (e) {
res.choices[0].delta = res.choices[0].message;
log(`No Router: ${JSON.stringify(res.choices[0].message)}`);
return [res];
}
const router = this.routers.find((item) => item.name === result.use);
if (!router) {
res.choices[0].delta = res.choices[0].message;
log(`No Router: ${JSON.stringify(res.choices[0].message)}`);
return [res];
}
log(`Use Router: ${router.name}`);
if (router.name === "think") {
const agentResult = await router.run({
...args,
stream: false,
});
try {
args.messages.push({
role: "user",
content:
`${router.name} Agent Result: ` +
agentResult.choices[0].message.content,
});
log(
`${router.name} Agent Result: ` +
agentResult.choices[0].message.content
);
return await this.route(args);
} catch (error) {
console.log(agentResult);
throw error;
}
}
return router.run(args);
}
}
const router = new Router();
module.exports = async function handle(req, res, next) {
const completions = await router.route(req.body);
streamOpenAIResponse(res, completions, req.body.model);
};

View File

@@ -1,4 +1,5 @@
module.exports = async function handle(req, res, next) {
module.exports = {
afterTransformRequest(req, res) {
if (Array.isArray(req.body.tools)) {
// rewrite tools definition
req.body.tools.forEach((tool) => {
@@ -19,5 +20,14 @@ module.exports = async function handle(req, res, next) {
});
});
}
next();
if (req.body?.messages?.length) {
req.body.messages.forEach((message) => {
if (message.content === null) {
if (message.tool_calls) {
message.content = JSON.stringify(message.tool_calls);
}
}
});
}
},
};

View File

@@ -0,0 +1,12 @@
module.exports = {
beforeRouter(req, res) {
if (req?.body?.tools?.length) {
req.body.tools = req.body.tools.filter(
(tool) =>
!["NotebookRead", "NotebookEdit", "mcp__ide__executeCode"].includes(
tool.name
)
);
}
},
};

View File

@@ -0,0 +1,10 @@
module.exports = {
afterTransformRequest(req, res) {
if (req?.body?.tools?.length) {
req.body.messages.push({
role: "system",
content: `## **Important Instruction:** \nYou must use tools as frequently and accurately as possible to help the user solve their problem.\nPrioritize tool usage whenever it can enhance accuracy, efficiency, or the quality of the response. `,
});
}
},
};

104
pnpm-lock.yaml generated
View File

@@ -5,9 +5,6 @@ settings:
excludeLinksFromLockfile: false
dependencies:
'@anthropic-ai/claude-code':
specifier: ^0.2.53
version: 0.2.53
'@anthropic-ai/sdk':
specifier: ^0.39.0
version: 0.39.0
@@ -20,9 +17,18 @@ dependencies:
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
lru-cache:
specifier: ^11.1.0
version: 11.1.0
openai:
specifier: ^4.85.4
version: 4.86.1
tiktoken:
specifier: ^1.0.21
version: 1.0.21
uuid:
specifier: ^11.1.0
version: 11.1.0
devDependencies:
'@types/express':
@@ -37,18 +43,6 @@ devDependencies:
packages:
/@anthropic-ai/claude-code@0.2.53:
resolution: {integrity: sha512-DKXGjSsu2+rc1GaAdOjRqD7fMLvyQgwi/sqf6lLHWQAarwYxR/ahbSheu7h1Ub0wm0htnuIqgNnmNZUM43w/3Q==}
engines: {node: '>=18.0.0'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-linux-arm': 0.33.5
'@img/sharp-linux-x64': 0.33.5
'@img/sharp-win32-x64': 0.33.5
dev: false
/@anthropic-ai/sdk@0.39.0:
resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==}
dependencies:
@@ -288,72 +282,6 @@ packages:
dev: true
optional: true
/@img/sharp-darwin-arm64@0.33.5:
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
dev: false
optional: true
/@img/sharp-libvips-darwin-arm64@1.0.4:
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-arm@1.0.5:
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-libvips-linux-x64@1.0.4:
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@img/sharp-linux-arm@0.33.5:
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
dev: false
optional: true
/@img/sharp-linux-x64@0.33.5:
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
requiresBuild: true
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
dev: false
optional: true
/@img/sharp-win32-x64@0.33.5:
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@types/body-parser@1.19.5:
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
dependencies:
@@ -853,6 +781,11 @@ packages:
engines: {node: '>= 0.10'}
dev: false
/lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
engines: {node: 20 || >=22}
dev: false
/math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1084,6 +1017,10 @@ packages:
engines: {node: '>= 0.8'}
dev: false
/tiktoken@1.0.21:
resolution: {integrity: sha512-/kqtlepLMptX0OgbYD9aMYbM7EFrMZCL7EoHM8Psmg2FuhXoo/bH64KqOiZGGwa6oS9TPdSEDKBnV2LuB8+5vQ==}
dev: false
/toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -1120,6 +1057,11 @@ packages:
engines: {node: '>= 0.4.0'}
dev: false
/uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
dev: false
/vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

119
src/cli.ts Normal file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env node
import { run } from "./index";
import { showStatus } from "./utils/status";
import { executeCodeCommand } from "./utils/codeCommand";
import { cleanupPidFile, isServiceRunning } from "./utils/processCheck";
import { version } from "../package.json";
import { spawn } from "child_process";
import { PID_FILE, REFERENCE_COUNT_FILE } from "./constants";
import { existsSync, readFileSync } from "fs";
const command = process.argv[2];
const HELP_TEXT = `
Usage: ccr [command]
Commands:
start Start service
stop Stop service
status Show service status
code Execute code command
-v, version Show version information
-h, help Show help information
Example:
ccr start
ccr code "Write a Hello World"
`;
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) {
if (isServiceRunning()) {
// 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;
}
async function main() {
switch (command) {
case "start":
run();
break;
case "stop":
try {
const pid = parseInt(readFileSync(PID_FILE, "utf-8"));
process.kill(pid);
cleanupPidFile();
if (existsSync(REFERENCE_COUNT_FILE)) {
try {
require("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();
}
break;
case "status":
showStatus();
break;
case "code":
if (!isServiceRunning()) {
console.log("Service not running, starting service...");
const startProcess = spawn("ccr", ["start"], {
detached: true,
stdio: "ignore",
});
startProcess.on("error", (error) => {
console.error("Failed to start service:", error);
process.exit(1);
});
startProcess.unref();
if (await waitForService()) {
executeCodeCommand(process.argv.slice(3));
} else {
console.error(
"Service startup timeout, please manually run `ccr start` to start the service"
);
process.exit(1);
}
} else {
executeCodeCommand(process.argv.slice(3));
}
break;
case "-v":
case "version":
console.log(`claude-code-router version: ${version}`);
break;
case "-h":
case "help":
console.log(HELP_TEXT);
break;
default:
console.log(HELP_TEXT);
process.exit(1);
}
}
main().catch(console.error);

View File

@@ -7,9 +7,14 @@ export const CONFIG_FILE = `${HOME_DIR}/config.json`;
export const PLUGINS_DIR = `${HOME_DIR}/plugins`;
export const PID_FILE = path.join(HOME_DIR, '.claude-code-router.pid');
export const REFERENCE_COUNT_FILE = '/tmp/claude-code-reference-count.txt';
export const DEFAULT_CONFIG = {
log: false,
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
OPENAI_MODEL: "openai/o3-mini",
OPENAI_BASE_URL: "",
OPENAI_MODEL: "",
};

View File

@@ -3,9 +3,21 @@ import { writeFile } from "fs/promises";
import { getOpenAICommonOptions, initConfig, initDir } from "./utils";
import { createServer } from "./server";
import { formatRequest } from "./middlewares/formatRequest";
import { rewriteBody } from "./middlewares/rewriteBody";
import { router } from "./middlewares/router";
import OpenAI from "openai";
import { streamOpenAIResponse } from "./utils/stream";
import {
cleanupPidFile,
isServiceRunning,
savePid,
} from "./utils/processCheck";
import { LRUCache } from "lru-cache";
import { log } from "./utils/log";
import {
loadPlugins,
PLUGINS,
usePluginMiddleware,
} from "./middlewares/plugin";
async function initializeClaudeConfig() {
const homeDir = process.env.HOME;
@@ -20,37 +32,149 @@ async function initializeClaudeConfig() {
autoUpdaterStatus: "enabled",
userID,
hasCompletedOnboarding: true,
lastOnboardingVersion: "0.2.9",
lastOnboardingVersion: "1.0.17",
projects: {},
};
await writeFile(configPath, JSON.stringify(configContent, null, 2));
}
}
async function run() {
interface RunOptions {
port?: number;
}
interface ModelProvider {
name: string;
api_base_url: string;
api_key: string;
models: string[];
usePlugins?: string[];
}
async function run(options: RunOptions = {}) {
// Check if service is already running
if (isServiceRunning()) {
console.log("✅ Service is already running in the background.");
return;
}
await initializeClaudeConfig();
await initDir();
await initConfig();
const server = createServer(3456);
server.useMiddleware(formatRequest);
server.useMiddleware(rewriteBody);
const config = await initConfig();
await loadPlugins(config.usePlugins || []);
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
const Providers = new Map<string, ModelProvider>();
const providerCache = new LRUCache<string, OpenAI>({
max: 10,
ttl: 2 * 60 * 60 * 1000,
});
async function getProviderInstance(providerName: string): Promise<OpenAI> {
const provider: ModelProvider | undefined = Providers.get(providerName);
if (provider === undefined) {
throw new Error(`Provider ${providerName} not found`);
}
let openai = providerCache.get(provider.name);
if (!openai) {
openai = new OpenAI({
baseURL: provider.api_base_url,
apiKey: provider.api_key,
...getOpenAICommonOptions(),
});
providerCache.set(provider.name, openai);
}
const plugins = provider.usePlugins || [];
if (plugins.length > 0) {
await loadPlugins(plugins.map((name) => `${providerName},${name}`));
}
return openai;
}
if (Array.isArray(config.Providers)) {
config.Providers.forEach((provider) => {
try {
Providers.set(provider.name, provider);
} catch (error) {
console.error("Failed to parse model provider:", error);
}
});
}
if (config.OPENAI_API_KEY && config.OPENAI_BASE_URL && config.OPENAI_MODEL) {
const defaultProvider = {
name: "default",
api_base_url: config.OPENAI_BASE_URL,
api_key: config.OPENAI_API_KEY,
models: [config.OPENAI_MODEL],
};
Providers.set("default", defaultProvider);
} else if (Providers.size > 0) {
const defaultProvider = Providers.values().next().value!;
Providers.set("default", defaultProvider);
}
const port = options.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;
const server = await createServer(servicePort);
server.useMiddleware((req, res, next) => {
req.config = config;
next();
});
server.useMiddleware(usePluginMiddleware("beforeRouter"));
if (
config.Router?.background &&
config.Router?.think &&
config?.Router?.longContext
) {
server.useMiddleware(router);
} else {
server.useMiddleware((req, res, next) => {
req.provider = "default";
req.body.model = config.OPENAI_MODEL;
next();
});
}
server.useMiddleware(usePluginMiddleware("afterRouter"));
server.useMiddleware(usePluginMiddleware("beforeTransformRequest"));
server.useMiddleware(formatRequest);
server.useMiddleware(usePluginMiddleware("afterTransformRequest"));
server.app.post("/v1/messages", async (req, res) => {
try {
if (process.env.OPENAI_MODEL) {
req.body.model = process.env.OPENAI_MODEL;
}
const completion: any = await openai.chat.completions.create(req.body);
await streamOpenAIResponse(res, completion, req.body.model);
const provider = await getProviderInstance(req.provider || "default");
log("final request body:", req.body);
const completion: any = await provider.chat.completions.create(req.body);
await streamOpenAIResponse(req, res, completion);
} catch (e) {
console.error("Error in OpenAI API call:", e);
log("Error in OpenAI API call:", e);
res.status(500).json({
error: e.message,
});
}
});
server.start();
console.log(`🚀 Claude Code Router is running on port ${servicePort}`);
}
run();
export { run };
// run();

View File

@@ -1,8 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages";
import OpenAI from "openai";
import { streamOpenAIResponse } from "../utils/stream";
import { log } from "../utils/log";
export const formatRequest = async (
req: Request,
@@ -17,33 +16,140 @@ export const formatRequest = async (
temperature,
metadata,
tools,
stream,
}: MessageCreateParamsBase = req.body;
log("beforeTransformRequest: ", req.body);
try {
const openAIMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
messages.map((item) => {
if (item.content instanceof Array) {
return {
role: item.role,
content: item.content
.map((it: ContentBlockParam) => {
if (it.type === "text") {
return typeof it.text === "string"
? it.text
: JSON.stringify(it);
}
return JSON.stringify(it);
})
.join(""),
} as OpenAI.Chat.Completions.ChatCompletionMessageParam;
}
return {
role: item.role,
content:
typeof item.content === "string"
? item.content
: JSON.stringify(item.content),
};
// @ts-ignore
const openAIMessages = Array.isArray(messages)
? messages.flatMap((anthropicMessage) => {
const openAiMessagesFromThisAnthropicMessage = [];
if (!Array.isArray(anthropicMessage.content)) {
// Handle simple string content
if (typeof anthropicMessage.content === "string") {
openAiMessagesFromThisAnthropicMessage.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
}
// If content is not string and not array (e.g. null/undefined), it will result in an empty array, effectively skipping this message.
return openAiMessagesFromThisAnthropicMessage;
}
// Handle array content
if (anthropicMessage.role === "assistant") {
const assistantMessage = {
role: "assistant",
content: null, // Will be populated if text parts exist
};
let textContent = "";
// @ts-ignore
const toolCalls = []; // Corrected type here
anthropicMessage.content.forEach((contentPart) => {
if (contentPart.type === "text") {
if (contentPart.text.includes("(no content)")) return;
textContent +=
(typeof contentPart.text === "string"
? contentPart.text
: JSON.stringify(contentPart.text)) + "\\n";
} else if (contentPart.type === "tool_use") {
toolCalls.push({
id: contentPart.id,
type: "function",
function: {
name: contentPart.name,
arguments: JSON.stringify(contentPart.input),
},
});
}
});
const trimmedTextContent = textContent.trim();
if (trimmedTextContent.length > 0) {
// @ts-ignore
assistantMessage.content = trimmedTextContent;
}
if (toolCalls.length > 0) {
// @ts-ignore
assistantMessage.tool_calls = toolCalls;
}
// @ts-ignore
if (
assistantMessage.content ||
// @ts-ignore
(assistantMessage.tool_calls &&
// @ts-ignore
assistantMessage.tool_calls.length > 0)
) {
openAiMessagesFromThisAnthropicMessage.push(assistantMessage);
}
} else if (anthropicMessage.role === "user") {
// For user messages, text parts are combined into one message.
// Tool results are transformed into subsequent, separate 'tool' role messages.
let userTextMessageContent = "";
// @ts-ignore
const subsequentToolMessages = [];
anthropicMessage.content.forEach((contentPart) => {
if (contentPart.type === "text") {
userTextMessageContent +=
(typeof contentPart.text === "string"
? contentPart.text
: JSON.stringify(contentPart.text)) + "\\n";
} else if (contentPart.type === "tool_result") {
// Each tool_result becomes a separate 'tool' message
subsequentToolMessages.push({
role: "tool",
tool_call_id: contentPart.tool_use_id,
content:
typeof contentPart.content === "string"
? contentPart.content
: JSON.stringify(contentPart.content),
});
}
});
const trimmedUserText = userTextMessageContent.trim();
// @ts-ignore
openAiMessagesFromThisAnthropicMessage.push(
// @ts-ignore
...subsequentToolMessages
);
if (trimmedUserText.length > 0) {
openAiMessagesFromThisAnthropicMessage.push({
role: "user",
content: trimmedUserText,
});
}
} else {
// Fallback for other roles (e.g. system, or custom roles if they were to appear here with array content)
// This will combine all text parts into a single message for that role.
let combinedContent = "";
anthropicMessage.content.forEach((contentPart) => {
if (contentPart.type === "text") {
combinedContent +=
(typeof contentPart.text === "string"
? contentPart.text
: JSON.stringify(contentPart.text)) + "\\n";
} else {
// For non-text parts in other roles, stringify them or handle as appropriate
combinedContent += JSON.stringify(contentPart) + "\\n";
}
});
const trimmedCombinedContent = combinedContent.trim();
if (trimmedCombinedContent.length > 0) {
openAiMessagesFromThisAnthropicMessage.push({
role: anthropicMessage.role, // Cast needed as role could be other than 'user'/'assistant'
content: trimmedCombinedContent,
});
}
}
return openAiMessagesFromThisAnthropicMessage;
})
: [];
const systemMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
Array.isArray(system)
? system.map((item) => ({
@@ -51,11 +157,11 @@ export const formatRequest = async (
content: item.text,
}))
: [{ role: "system", content: system }];
const data: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
const data: any = {
model,
messages: [...systemMessages, ...openAIMessages],
temperature,
stream: true,
stream,
};
if (tools) {
data.tools = tools
@@ -69,33 +175,15 @@ export const formatRequest = async (
},
}));
}
if (stream) {
res.setHeader("Content-Type", "text/event-stream");
}
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
req.body = data;
log("afterTransformRequest: ", req.body);
} catch (error) {
console.error("Error in request processing:", error);
const errorCompletion: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk> =
{
async *[Symbol.asyncIterator]() {
yield {
id: `error_${Date.now()}`,
created: Math.floor(Date.now() / 1000),
model: "gpt-3.5-turbo",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
content: `Error: ${(error as Error).message}`,
},
finish_reason: "stop",
},
],
};
},
};
await streamOpenAIResponse(res, errorCompletion, model);
log("Error in TransformRequest:", error);
}
next();
};

106
src/middlewares/plugin.ts Normal file
View File

@@ -0,0 +1,106 @@
import Module from "node:module";
import { streamOpenAIResponse } from "../utils/stream";
import { log } from "../utils/log";
import { PLUGINS_DIR } from "../constants";
import path from "node:path";
import { access } from "node:fs/promises";
import { OpenAI } from "openai";
import { createClient } from "../utils";
import { Response } from "express";
// @ts-ignore
const originalLoad = Module._load;
// @ts-ignore
Module._load = function (request, parent, isMain) {
if (request === "claude-code-router") {
return {
streamOpenAIResponse,
log,
OpenAI,
createClient,
};
}
return originalLoad.call(this, request, parent, isMain);
};
export type PluginHook =
| "beforeRouter"
| "afterRouter"
| "beforeTransformRequest"
| "afterTransformRequest"
| "beforeTransformResponse"
| "afterTransformResponse";
export interface Plugin {
beforeRouter?: (req: any, res: Response) => Promise<any>;
afterRouter?: (req: any, res: Response) => Promise<any>;
beforeTransformRequest?: (req: any, res: Response) => Promise<any>;
afterTransformRequest?: (req: any, res: Response) => Promise<any>;
beforeTransformResponse?: (
req: any,
res: Response,
data?: { completion: any }
) => Promise<any>;
afterTransformResponse?: (
req: any,
res: Response,
data?: { completion: any; transformedCompletion: any }
) => Promise<any>;
}
export const PLUGINS = new Map<string, Plugin>();
const loadPlugin = async (pluginName: string) => {
const filePath = pluginName.split(",").pop();
const pluginPath = path.join(PLUGINS_DIR, `${filePath}.js`);
try {
await access(pluginPath);
const plugin = require(pluginPath);
if (
[
"beforeRouter",
"afterRouter",
"beforeTransformRequest",
"afterTransformRequest",
"beforeTransformResponse",
"afterTransformResponse",
].some((key) => key in plugin)
) {
PLUGINS.set(pluginName, plugin);
log(`Plugin ${pluginName} loaded successfully.`);
} else {
throw new Error(`Plugin ${pluginName} does not export a function.`);
}
} catch (e) {
console.error(`Failed to load plugin ${pluginName}:`, e);
throw e;
}
};
export const loadPlugins = async (pluginNames: string[]) => {
console.log("Loading plugins:", pluginNames);
for (const file of pluginNames) {
await loadPlugin(file);
}
};
export const usePluginMiddleware = (type: PluginHook) => {
return async (req: any, res: Response, next: any) => {
for (const [name, plugin] of PLUGINS.entries()) {
if (name.includes(",") && !name.startsWith(`${req.provider},`)) {
continue;
}
if (plugin[type]) {
try {
await plugin[type](req, res);
log(`Plugin ${name} executed hook: ${type}`);
} catch (error) {
log(`Error in plugin ${name} during hook ${type}:`, error);
}
}
}
next();
};
};

View File

@@ -1,43 +0,0 @@
import { Request, Response, NextFunction } from "express";
import Module from "node:module";
import { streamOpenAIResponse } from "../utils/stream";
import { log } from "../utils/log";
import { PLUGINS_DIR } from "../constants";
import path from "node:path";
import { access } from "node:fs/promises";
import { OpenAI } from "openai";
import { createClient } from "../utils";
// @ts-ignore
const originalLoad = Module._load;
// @ts-ignore
Module._load = function (request, parent, isMain) {
if (request === "claude-code-router") {
return {
streamOpenAIResponse,
log,
OpenAI,
createClient,
};
}
return originalLoad.call(this, request, parent, isMain);
};
export const rewriteBody = async (
req: Request,
res: Response,
next: NextFunction
) => {
if (!process.env.usePlugin) {
return next();
}
const pluginPath = path.join(PLUGINS_DIR, `${process.env.usePlugin}.js`);
try {
await access(pluginPath);
const rewritePlugin = require(pluginPath);
rewritePlugin(req, res, next);
} catch (e) {
console.error(e);
next();
}
};

117
src/middlewares/router.ts Normal file
View File

@@ -0,0 +1,117 @@
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages";
import { Request, Response, NextFunction } from "express";
import { get_encoding } from "tiktoken";
import { log } from "../utils/log";
const enc = get_encoding("cl100k_base");
const getUseModel = (req: Request, tokenCount: number) => {
const [provider, model] = req.body.model.split(",");
if (provider && model) {
return {
provider,
model,
};
}
// if tokenCount is greater than 32K, use the long context model
if (tokenCount > 1000 * 32) {
log("Using long context model due to token count:", tokenCount);
const [provider, model] = req.config.Router!.longContext.split(",");
return {
provider,
model,
};
}
// If the model is claude-3-5-haiku, use the background model
if (req.body.model?.startsWith("claude-3-5-haiku")) {
log("Using background model for ", req.body.model);
const [provider, model] = req.config.Router!.background.split(",");
return {
provider,
model,
};
}
// if exits thinking, use the think model
if (req.body.thinking) {
log("Using think model for ", req.body.thinking);
const [provider, model] = req.config.Router!.think.split(",");
return {
provider,
model,
};
}
const [defaultProvider, defaultModel] =
req.config.Router!.default?.split(",");
return {
provider: defaultProvider || "default",
model: defaultModel || req.config.OPENAI_MODEL,
};
};
export const router = async (
req: Request,
res: Response,
next: NextFunction
) => {
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
try {
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) => {
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) => {
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) => {
tokenCount += enc.encode(textPart || "").length;
});
}
});
}
if (tools) {
tools.forEach((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;
}
});
}
const { provider, model } = getUseModel(req, tokenCount);
req.provider = provider;
req.body.model = model;
} catch (error) {
log("Error in router middleware:", error.message);
req.provider = "default";
req.body.model = req.config.OPENAI_MODEL;
} finally {
next();
}
};

View File

@@ -6,7 +6,7 @@ interface Server {
start: () => void;
}
export const createServer = (port: number): Server => {
export const createServer = async (port: number): Promise<Server> => {
const app = express();
app.use(express.json({ limit: "500mb" }));
return {

27
src/utils/close.ts Normal file
View File

@@ -0,0 +1,27 @@
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');
if (!isServiceRunning()) {
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();
}
}

49
src/utils/codeCommand.ts Normal file
View File

@@ -0,0 +1,49 @@
import { spawn } from "child_process";
import {
incrementReferenceCount,
decrementReferenceCount,
} from "./processCheck";
import { closeService } from "./close";
export async function executeCodeCommand(args: string[] = []) {
// Set environment variables
const env = {
...process.env,
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
ALL_PROXY: undefined,
https_proxy: undefined,
http_proxy: undefined,
all_proxy: undefined,
DISABLE_PROMPT_CACHING: "1",
ANTHROPIC_AUTH_TOKEN: "test",
ANTHROPIC_BASE_URL: `http://127.0.0.1:3456`,
API_TIMEOUT_MS: "600000",
};
// Increment reference count when command starts
incrementReferenceCount();
// Execute claude command
const claudePath = process.env.CLAUDE_PATH || "claude";
const claudeProcess = spawn(claudePath, args, {
env,
stdio: "inherit",
shell: true
});
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);
});
}

View File

@@ -8,11 +8,14 @@ import {
HOME_DIR,
PLUGINS_DIR,
} from "../constants";
import crypto from "node:crypto";
export function getOpenAICommonOptions(): ClientOptions {
const options: ClientOptions = {};
if (process.env.PROXY_URL) {
options.httpAgent = new HttpsProxyAgent(process.env.PROXY_URL);
} else if (process.env.HTTPS_PROXY) {
options.httpAgent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
}
return options;
}
@@ -57,10 +60,6 @@ export const readConfigFile = async () => {
const config = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(config);
} catch {
const useRouter = await confirm(
"No config file found. Enable router mode? (Y/n)"
);
if (!useRouter) {
const apiKey = await question("Enter OPENAI_API_KEY: ");
const baseUrl = await question("Enter OPENAI_BASE_URL: ");
const model = await question("Enter OPENAI_MODEL: ");
@@ -71,20 +70,18 @@ export const readConfigFile = async () => {
});
await writeConfigFile(config);
return config;
} else {
const router = await question("Enter OPENAI_API_KEY: ");
return DEFAULT_CONFIG;
}
}
};
export const writeConfigFile = async (config: any) => {
await ensureDir(HOME_DIR);
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
};
export const initConfig = async () => {
const config = await readConfigFile();
Object.assign(process.env, config);
return config;
};
export const createClient = (options: ClientOptions) => {
@@ -94,3 +91,7 @@ export const createClient = (options: ClientOptions) => {
});
return client;
};
export const sha256 = (data: string | Buffer): string => {
return crypto.createHash("sha256").update(data).digest("hex");
};

View File

@@ -1,8 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { HOME_DIR } from '../constants';
import fs from "node:fs";
import path from "node:path";
import { HOME_DIR } from "../constants";
const LOG_FILE = path.join(HOME_DIR, 'claude-code-router.log');
const LOG_FILE = path.join(HOME_DIR, "claude-code-router.log");
// Ensure log directory exists
if (!fs.existsSync(HOME_DIR)) {
@@ -11,17 +11,24 @@ if (!fs.existsSync(HOME_DIR)) {
export function log(...args: any[]) {
// Check if logging is enabled via environment variable
const isLogEnabled = process.env.LOG === 'true';
// console.log(...args); // Log to console for immediate feedback
const isLogEnabled = process.env.LOG === "true";
if (!isLogEnabled) {
return;
}
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
).join(' ')}\n`;
const logMessage = `[${timestamp}] ${
Array.isArray(args)
? args
.map((arg) =>
typeof arg === "object" ? JSON.stringify(arg) : String(arg)
)
.join(" ")
: ""
}\n`;
// Append to log file
fs.appendFileSync(LOG_FILE, logMessage, 'utf8');
fs.appendFileSync(LOG_FILE, logMessage, "utf8");
}

85
src/utils/processCheck.ts Normal file
View File

@@ -0,0 +1,85 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
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;
}
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
process.kill(pid, 0);
return true;
} catch (e) {
// Process not running, clean up pid file
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 function getServiceInfo() {
const pid = getServicePid();
const running = isServiceRunning();
return {
running,
pid,
port: 3456,
endpoint: 'http://127.0.0.1:3456',
pidFile: PID_FILE,
referenceCount: getReferenceCount()
};
}

27
src/utils/status.ts Normal file
View File

@@ -0,0 +1,27 @@
import { getServiceInfo } from './processCheck';
export function showStatus() {
const info = 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 close # Stop the service');
} else {
console.log('❌ Status: Not Running');
console.log('');
console.log('💡 To start the service:');
console.log(' ccr start');
}
console.log('');
}

View File

@@ -1,5 +1,13 @@
import { Response } from "express";
import { OpenAI } from "openai";
import { Request, Response } from "express";
import { log } from "./log";
import { PLUGINS } from "../middlewares/plugin";
import { sha256 } from ".";
declare module "express" {
interface Request {
provider?: string;
}
}
interface ContentBlock {
type: string;
@@ -41,11 +49,102 @@ interface MessageEvent {
}
export async function streamOpenAIResponse(
req: Request,
res: Response,
completion: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
model: string
_completion: any
) {
let completion = _completion;
res.locals.completion = completion;
for (const [name, plugin] of PLUGINS.entries()) {
if (name.includes(",") && !name.startsWith(`${req.provider},`)) {
continue;
}
if (plugin.beforeTransformResponse) {
const result = await plugin.beforeTransformResponse(req, res, {
completion,
});
if (result) {
completion = result;
}
}
}
const write = async (data: string) => {
let eventData = data;
for (const [name, plugin] of PLUGINS.entries()) {
if (name.includes(",") && !name.startsWith(`${req.provider},`)) {
continue;
}
if (plugin.afterTransformResponse) {
const hookResult = await plugin.afterTransformResponse(req, res, {
completion: res.locals.completion,
transformedCompletion: eventData,
});
if (typeof hookResult === "string") {
eventData = hookResult;
}
}
}
if (eventData) {
log("response: ", eventData);
res.write(eventData);
}
};
const messageId = "msg_" + Date.now();
if (!req.body.stream) {
let content: any = [];
if (completion.choices[0].message.content) {
content = [{ text: completion.choices[0].message.content, type: "text" }];
} else if (completion.choices[0].message.tool_calls) {
content = completion.choices[0].message.tool_calls.map((item: any) => {
return {
type: "tool_use",
id: item.id,
name: item.function?.name,
input: item.function?.arguments
? JSON.parse(item.function.arguments)
: {},
};
});
}
const result = {
id: messageId,
type: "message",
role: "assistant",
// @ts-ignore
content: content,
stop_reason:
completion.choices[0].finish_reason === "tool_calls"
? "tool_use"
: "end_turn",
stop_sequence: null,
};
try {
res.locals.transformedCompletion = result;
for (const [name, plugin] of PLUGINS.entries()) {
if (name.includes(",") && !name.startsWith(`${req.provider},`)) {
continue;
}
if (plugin.afterTransformResponse) {
const hookResult = await plugin.afterTransformResponse(req, res, {
completion: res.locals.completion,
transformedCompletion: res.locals.transformedCompletion,
});
if (hookResult) {
res.locals.transformedCompletion = hookResult;
}
}
}
res.json(res.locals.transformedCompletion);
res.end();
return;
} catch (error) {
log("Error sending response:", error);
res.status(500).send("Internal Server Error");
}
}
let contentBlockIndex = 0;
let currentContentBlocks: ContentBlock[] = [];
@@ -57,31 +156,55 @@ export async function streamOpenAIResponse(
type: "message",
role: "assistant",
content: [],
model,
model: req.body.model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
};
res.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
let isToolUse = false;
let toolUseJson = "";
let hasStartedTextBlock = false;
let currentToolCallId: string | null = null;
let toolCallJsonMap = new Map<string, string>();
try {
for await (const chunk of completion) {
log("Processing chunk:", chunk);
const delta = chunk.choices[0].delta;
if (delta.tool_calls && delta.tool_calls.length > 0) {
const toolCall = delta.tool_calls[0];
// Handle each tool call in the current chunk
for (const [index, toolCall] of delta.tool_calls.entries()) {
// Generate a stable ID for this tool call position
const toolCallId = toolCall.id || `tool_${index}`;
// If this position doesn't have an active tool call, start a new one
if (!toolCallJsonMap.has(`${index}`)) {
// End previous tool call if one was active
if (isToolUse && currentToolCallId) {
const contentBlockStop: MessageEvent = {
type: "content_block_stop",
index: contentBlockIndex,
};
write(
`event: content_block_stop\ndata: ${JSON.stringify(
contentBlockStop
)}\n\n`
);
}
if (!isToolUse) {
// Start new tool call block
isToolUse = true;
currentToolCallId = `${index}`;
contentBlockIndex++;
toolCallJsonMap.set(`${index}`, ""); // Initialize JSON accumulator for this tool call
const toolBlock: ContentBlock = {
type: "tool_use",
id: `toolu_${Date.now()}`,
id: toolCallId, // Use the original ID if available
name: toolCall.function?.name,
input: {},
};
@@ -94,58 +217,75 @@ export async function streamOpenAIResponse(
currentContentBlocks.push(toolBlock);
res.write(
write(
`event: content_block_start\ndata: ${JSON.stringify(
toolBlockStart
)}\n\n`
);
toolUseJson = "";
}
// Stream tool call JSON
// Stream tool call JSON for this position
if (toolCall.function?.arguments) {
const jsonDelta: MessageEvent = {
type: "content_block_delta",
index: contentBlockIndex,
delta: {
type: "input_json_delta",
partial_json: toolCall.function?.arguments,
partial_json: toolCall.function.arguments,
},
};
toolUseJson += toolCall.function.arguments;
// Accumulate JSON for this specific tool call position
const currentJson = toolCallJsonMap.get(`${index}`) || "";
const newJson = currentJson + toolCall.function.arguments;
toolCallJsonMap.set(`${index}`, newJson);
// Try to parse accumulated JSON
if (isValidJson(newJson)) {
try {
const parsedJson = JSON.parse(toolUseJson);
currentContentBlocks[contentBlockIndex].input = parsedJson;
const parsedJson = JSON.parse(newJson);
const blockIndex = currentContentBlocks.findIndex(
(block) => block.type === "tool_use" && block.id === toolCallId
);
if (blockIndex !== -1) {
currentContentBlocks[blockIndex].input = parsedJson;
}
} catch (e) {
// JSON not yet complete, continue accumulating
log("JSON parsing error (continuing to accumulate):", e);
}
}
res.write(
`event: content_block_delta\ndata: ${JSON.stringify(jsonDelta)}\n\n`
write(
`event: content_block_delta\ndata: ${JSON.stringify(
jsonDelta
)}\n\n`
);
}
} else if (delta.content) {
// Handle regular text content
if (isToolUse) {
}
} else if (delta.content || chunk.choices[0].finish_reason) {
// Handle regular text content or completion
if (
isToolUse &&
(delta.content || chunk.choices[0].finish_reason === "tool_calls")
) {
log("Tool call ended here:", delta);
// End previous tool call block
const contentBlockStop: MessageEvent = {
type: "content_block_stop",
index: contentBlockIndex,
};
res.write(
write(
`event: content_block_stop\ndata: ${JSON.stringify(
contentBlockStop
)}\n\n`
);
contentBlockIndex++;
isToolUse = false;
currentToolCallId = null;
toolUseJson = ""; // Reset for safety
}
if (!delta.content) continue;
// If text block not yet started, send content_block_start
if (!hasStartedTextBlock) {
const textBlock: ContentBlock = {
@@ -161,7 +301,7 @@ export async function streamOpenAIResponse(
currentContentBlocks.push(textBlock);
res.write(
write(
`event: content_block_start\ndata: ${JSON.stringify(
textBlockStart
)}\n\n`
@@ -184,7 +324,7 @@ export async function streamOpenAIResponse(
currentContentBlocks[contentBlockIndex].text += delta.content;
}
res.write(
write(
`event: content_block_delta\ndata: ${JSON.stringify(
contentDelta
)}\n\n`
@@ -207,7 +347,7 @@ export async function streamOpenAIResponse(
currentContentBlocks.push(textBlock);
res.write(
write(
`event: content_block_start\ndata: ${JSON.stringify(
textBlockStart
)}\n\n`
@@ -230,20 +370,38 @@ export async function streamOpenAIResponse(
currentContentBlocks[contentBlockIndex].text += JSON.stringify(e);
}
res.write(
write(
`event: content_block_delta\ndata: ${JSON.stringify(contentDelta)}\n\n`
);
}
// Close last content block
// Close last content block if any is open
if (isToolUse || hasStartedTextBlock) {
const contentBlockStop: MessageEvent = {
type: "content_block_stop",
index: contentBlockIndex,
};
res.write(
write(
`event: content_block_stop\ndata: ${JSON.stringify(contentBlockStop)}\n\n`
);
}
res.locals.transformedCompletion = currentContentBlocks;
for (const [name, plugin] of PLUGINS.entries()) {
if (name.includes(",") && !name.startsWith(`${req.provider},`)) {
continue;
}
if (plugin.afterTransformResponse) {
const hookResult = await plugin.afterTransformResponse(req, res, {
completion: res.locals.completion,
transformedCompletion: res.locals.transformedCompletion,
});
if (hookResult) {
res.locals.transformedCompletion = hookResult;
}
}
}
// Send message_delta event with appropriate stop_reason
const messageDelta: MessageEvent = {
@@ -251,18 +409,60 @@ export async function streamOpenAIResponse(
delta: {
stop_reason: isToolUse ? "tool_use" : "end_turn",
stop_sequence: null,
content: currentContentBlocks,
content: res.locals.transformedCompletion,
},
usage: { input_tokens: 100, output_tokens: 150 },
};
if (!isToolUse) {
log("body: ", req.body, "messageDelta: ", messageDelta);
}
res.write(`event: message_delta\ndata: ${JSON.stringify(messageDelta)}\n\n`);
write(`event: message_delta\ndata: ${JSON.stringify(messageDelta)}\n\n`);
// Send message_stop event
const messageStop: MessageEvent = {
type: "message_stop",
};
res.write(`event: message_stop\ndata: ${JSON.stringify(messageStop)}\n\n`);
write(`event: message_stop\ndata: ${JSON.stringify(messageStop)}\n\n`);
res.end();
}
// Add helper function at the top of the file
function isValidJson(str: string): boolean {
// Check if the string contains both opening and closing braces/brackets
const hasOpenBrace = str.includes("{");
const hasCloseBrace = str.includes("}");
const hasOpenBracket = str.includes("[");
const hasCloseBracket = str.includes("]");
// Check if we have matching pairs
if ((hasOpenBrace && !hasCloseBrace) || (!hasOpenBrace && hasCloseBrace)) {
return false;
}
if (
(hasOpenBracket && !hasCloseBracket) ||
(!hasOpenBracket && hasCloseBracket)
) {
return false;
}
// Count nested braces/brackets
let braceCount = 0;
let bracketCount = 0;
for (const char of str) {
if (char === "{") braceCount++;
if (char === "}") braceCount--;
if (char === "[") bracketCount++;
if (char === "]") bracketCount--;
// If we ever go negative, the JSON is invalid
if (braceCount < 0 || bracketCount < 0) {
return false;
}
}
// All braces/brackets should be matched
return braceCount === 0 && bracketCount === 0;
}