28 Commits

Author SHA1 Message Date
musi
5cd21c570f Merge pull request #798 from SaseQ/main
Add ccr logo and badges (README.md edit)
2025-09-10 20:39:37 +08:00
SaseQ
c5e97709a5 Add ccr logo and badges (README.md edit) 2025-09-10 14:32:02 +02:00
musistudio
f7adb7b28e release v1.0.49 2025-09-09 22:43:01 +08:00
musistudio
7964fff175 release v1.0.48 2025-09-09 21:47:59 +08:00
musistudio
fe06b57032 fix llms version 2025-09-09 21:21:45 +08:00
musistudio
1b3a8f8803 fix logviewer 2025-09-09 21:06:19 +08:00
musistudio
cec8421dd9 change logviewer 2025-09-06 22:19:40 +08:00
musistudio
1a7e90df39 remove log util 2025-09-06 09:06:18 +08:00
musistudio
e5741ae470 add logger vireer 2025-09-05 21:36:21 +08:00
musistudio
0152af5db9 update sponsors 2025-09-04 22:07:40 +08:00
musistudio
e6b3e2a194 release v1.0.47 2025-09-04 22:04:31 +08:00
musistudio
f7058dcdb5 fix log file path 2025-09-04 22:04:03 +08:00
musistudio
e670302e9e optimize docker deployment 2025-09-03 09:58:09 +08:00
musistudio
5761e165fd release v1.0.46 2025-09-02 21:23:53 +08:00
musistudio
8c4fec4f5f fix stream handler error 2025-09-02 21:23:21 +08:00
musistudio
5d53571fe6 release v1.0.45 2025-09-02 19:55:04 +08:00
musistudio
35fc4505b2 release v1.0.44 2025-09-02 12:16:31 +08:00
musistudio
c7303775ad update document 2025-09-02 12:13:54 +08:00
musistudio
f7981b16cd Merge branch 'dev/agents' 2025-09-01 21:10:18 +08:00
musistudio
b54687c4d5 update sponsors 2025-09-01 21:09:52 +08:00
musi
0be4c3753f Merge pull request #691 from zoyopei/fix/ui-custom-router
fix(ui): add CUSTOM_ROUTER_PATH support in general settings
2025-09-01 20:53:07 +08:00
musi
668e855a2d Merge pull request #699 from geocine/numeric-input
Fix numeric input UX: allow complete deletion with smart defaults
2025-09-01 20:52:27 +08:00
musi
41108cea1d Merge pull request #706 from vitobotta/chutes-gllm
Add link to unofficial GLM 4.5 transformer for Chutes provider
2025-09-01 20:50:56 +08:00
musistudio
3b9e58a823 update readme 2025-08-26 22:14:40 +08:00
musistudio
615fe7629e update readme 2025-08-26 22:05:25 +08:00
Vito Botta
656a5f9a97 Add link to unofficial GLM 4.5 transformer for Chutes provider 2025-08-25 18:44:24 +03:00
Aivan Monceller
d2a0815cb7 enhance Input component to manage numeric values and improve onChange handling 2025-08-25 13:27:58 +08:00
zoyopei
7cc41d83cf fix(ui): add CUSTOM_ROUTER_PATH support in general settings
- Add CUSTOM_ROUTER_PATH field to UI configuration
  - Fix configuration preservation during save operations
2025-08-24 19:52:14 +08:00
29 changed files with 1554 additions and 297 deletions

7
Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:20-alpine
RUN npm install -g @musistudio/claude-code-router
EXPOSE 3456
CMD ["ccr", "start"]

View File

@@ -1,14 +1,23 @@
# Claude Code Router
![](blog/images/claude-code-router-img.png)
[![](https://img.shields.io/badge/%F0%9F%87%A8%F0%9F%87%B3-%E4%B8%AD%E6%96%87%E7%89%88-ff0000?style=flat)](README_zh.md)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.gg/rdftVMaUcS)
[![](https://img.shields.io/github/license/musistudio/claude-code-router)](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
<hr>
I am seeking funding support for this project to better sustain its development. If you have any ideas, feel free to reach out to me: [m@musiiot.top](mailto:m@musiiot.top)
[中文版](README_zh.md)
> 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. Its worth noting that iFlow limits each user to a concurrency of 1, which means youll need to route background requests to other models.
> If youd like a better experience, you can try [iFlow CLI](https://cli.iflow.cn).
![](blog/images/claude-code.png)
![](blog/images/roadmap.svg)
## ✨ Features
- **Model Routing**: Route requests to different models based on your needs (e.g., background tasks, thinking, long context).
@@ -315,6 +324,7 @@ Transformers allow you to modify the request and response payloads to ensure com
- `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).
@@ -345,8 +355,9 @@ The `Router` object defines which model to use for different scenarios:
- `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 CCRs 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:
- 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`
@@ -540,11 +551,11 @@ A huge thank you to all our sponsors for their generous support!
- @b\*g
- @\*亿
- @\*辉
- @JACK
- @JACK
- @\*光
- @W\*l
- [@kesku](https://github.com/kesku)
- @水\*丫
- [@biguncle](https://github.com/biguncle)
- @二吉吉
- @a\*g
- @\*林
@@ -557,5 +568,17 @@ A huge thank you to all our sponsors for their generous support!
- @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
- @\*政
- @\*铭
- @\*叶
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)

View File

@@ -1,11 +1,24 @@
# Claude Code Router
![](blog/images/claude-code-router-img.png)
[![](https://img.shields.io/badge/%F0%9F%87%AC%F0%9F%87%A7-English-000aff?style=flat)](README.md)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.gg/rdftVMaUcS)
[![](https://img.shields.io/github/license/musistudio/claude-code-router)](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
<hr>
我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto: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)。
![](blog/images/claude-code.png)
![](blog/images/roadmap.svg)
## ✨ 功能
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
@@ -318,6 +331,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
- `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。
- `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。
- `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。
- `image`(测试版): 用于处理图片类任务采用CCR内置的agent支持如果该模型不支持工具调用需要将`config.forceUseImageAgent`属性设置为`true`。
您还可以使用 `/model` 命令在 Claude Code 中动态切换模型:
`/model provider_name,model_name`
@@ -499,6 +513,7 @@ jobs:
- @*琢
- @*成
- @Z*o
- @\*琨
- [@congzhangzh](https://github.com/congzhangzh)
- @*_
- @Z\*m
@@ -513,7 +528,7 @@ jobs:
- @\*光
- @W\*l
- [@kesku](https://github.com/kesku)
- @水\*丫
- [@biguncle](https://github.com/biguncle)
- @二吉吉
- @a\*g
- @\*林
@@ -526,6 +541,18 @@ jobs:
- @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
- @\*政
- @\*铭
- @\*叶
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

67
blog/images/roadmap.svg Normal file
View File

@@ -0,0 +1,67 @@
<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 &amp; 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 &amp; 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>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,24 +0,0 @@
FROM node:20-alpine
WORKDIR /app
# Copy all files
COPY . .
# Install pnpm globally
RUN npm install -g pnpm
# Install dependencies
RUN pnpm install --frozen-lockfile
# Fix rollup optional dependencies issue
RUN cd ui && npm install
# Build the entire project including UI
RUN pnpm run build
# Expose port
EXPOSE 3456
# Start the router service
CMD ["node", "dist/cli.js", "start"]

View File

@@ -1,6 +1,6 @@
{
"name": "@musistudio/claude-code-router",
"version": "1.0.43",
"version": "1.0.49",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": {
"ccr": "./dist/cli.js"
@@ -20,11 +20,12 @@
"license": "MIT",
"dependencies": {
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.28",
"@musistudio/llms": "^1.0.32",
"dotenv": "^16.4.7",
"find-process": "^2.0.0",
"json5": "^2.2.3",
"openurl": "^1.1.1",
"pino-rotating-file-stream": "^0.0.2",
"rotating-file-stream": "^3.2.7",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},

111
pnpm-lock.yaml generated
View File

@@ -12,20 +12,23 @@ importers:
specifier: ^8.2.0
version: 8.2.0
'@musistudio/llms':
specifier: ^1.0.28
version: 1.0.28(ws@8.18.3)
specifier: ^1.0.32
version: 1.0.32(ws@8.18.3)
dotenv:
specifier: ^16.4.7
version: 16.6.1
find-process:
specifier: ^2.0.0
version: 2.0.0
json5:
specifier: ^2.2.3
version: 2.2.3
openurl:
specifier: ^1.1.1
version: 1.1.1
pino-rotating-file-stream:
specifier: ^0.0.2
version: 0.0.2
rotating-file-stream:
specifier: ^3.2.7
version: 3.2.7
tiktoken:
specifier: ^1.0.21
version: 1.0.22
@@ -241,11 +244,11 @@ packages:
'@fastify/static@8.2.0':
resolution: {integrity: sha512-PejC/DtT7p1yo3p+W7LiUtLMsV8fEvxAK15sozHy9t8kwo5r0uLYmhV/inURmGz1SkHZFz/8CNtHLPyhKcx4SQ==}
'@google/genai@1.14.0':
resolution: {integrity: sha512-jirYprAAJU1svjwSDVCzyVq+FrJpJd5CSxR/g2Ga/gZ0ZYZpcWjMS75KJl9y71K1mDN+tcx6s21CzCbB2R840g==}
'@google/genai@1.16.0':
resolution: {integrity: sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@modelcontextprotocol/sdk': ^1.11.0
'@modelcontextprotocol/sdk': ^1.11.4
peerDependenciesMeta:
'@modelcontextprotocol/sdk':
optional: true
@@ -266,8 +269,8 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@musistudio/llms@1.0.28':
resolution: {integrity: sha512-rHvcJTtrFsRC7ayxz7ZXVoC7lZUwLtAHubdouUj+LYkv35Hr8S6K3lpOMXKYyqcKCtMvxbpjvM9MiwjCaleGEA==}
'@musistudio/llms@1.0.32':
resolution: {integrity: sha512-i+dB7x4qxZ8oOM3TLijjJ2rwIOje6/ovyHdU8A5h6d2wcTKOd0JUpNixUgBO3dPJp2dYVXz0SSfhw7gzmt1Kkg==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@@ -338,6 +341,10 @@ packages:
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -345,6 +352,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -460,6 +471,10 @@ packages:
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
engines: {node: '>=20'}
find-process@2.0.0:
resolution: {integrity: sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg==}
hasBin: true
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -500,8 +515,8 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
google-auth-library@10.2.1:
resolution: {integrity: sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==}
google-auth-library@10.3.0:
resolution: {integrity: sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==}
engines: {node: '>=18'}
google-auth-library@9.15.1:
@@ -524,6 +539,10 @@ packages:
resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
engines: {node: '>=18'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -609,6 +628,10 @@ packages:
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
engines: {node: 20 || >=22}
@@ -672,8 +695,8 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openai@5.12.2:
resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==}
openai@5.16.0:
resolution: {integrity: sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw==}
hasBin: true
peerDependencies:
ws: ^8.18.0
@@ -716,9 +739,6 @@ packages:
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
pino-rotating-file-stream@0.0.2:
resolution: {integrity: sha512-knF+ReDBMQMB7gzBfuFpUmCrXpRen6YYh5Q9Ymmj//dDHeH4QEMwAV7VoGEEM+30s7VHqfbabazs9wxkMO2BIQ==}
pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
@@ -769,8 +789,8 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rotating-file-stream@3.2.6:
resolution: {integrity: sha512-r8yShzMWUvWXkRzbOXDM1fEaMpc3qo2PzK7bBH/0p0Nl/uz8Mud/Y+0XTQxe3kbSnDF7qBH2tSe83WDKA7o3ww==}
rotating-file-stream@3.2.7:
resolution: {integrity: sha512-SVquhBEVvRFY+nWLUc791Y0MIlyZrEClRZwZFLLRgJKldHyV1z4e2e/dp9LPqCS3AM//uq/c3PnOFgjqnm5P+A==}
engines: {node: '>=14.0'}
run-parallel@1.2.0:
@@ -868,6 +888,10 @@ packages:
resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==}
engines: {node: '>=0.10.0'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
@@ -901,8 +925,8 @@ packages:
undici-types@7.10.0:
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
undici@7.13.0:
resolution: {integrity: sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==}
undici@7.15.0:
resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==}
engines: {node: '>=20.18.1'}
uuid@11.1.0:
@@ -1084,7 +1108,7 @@ snapshots:
fastq: 1.19.1
glob: 11.0.3
'@google/genai@1.14.0':
'@google/genai@1.16.0':
dependencies:
google-auth-library: 9.15.1
ws: 8.18.3
@@ -1111,18 +1135,18 @@ snapshots:
'@lukeed/ms@2.0.2': {}
'@musistudio/llms@1.0.28(ws@8.18.3)':
'@musistudio/llms@1.0.32(ws@8.18.3)':
dependencies:
'@anthropic-ai/sdk': 0.54.0
'@fastify/cors': 11.1.0
'@google/genai': 1.14.0
'@google/genai': 1.16.0
dotenv: 16.6.1
fastify: 5.5.0
google-auth-library: 10.2.1
google-auth-library: 10.3.0
json5: 2.2.3
jsonrepair: 3.13.0
openai: 5.12.2(ws@8.18.3)
undici: 7.13.0
openai: 5.16.0(ws@8.18.3)
undici: 7.15.0
uuid: 11.1.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -1191,12 +1215,19 @@ snapshots:
buffer-equal-constant-time@1.0.1: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
commander@12.1.0: {}
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -1354,6 +1385,12 @@ snapshots:
fast-querystring: 1.1.2
safe-regex2: 5.0.0
find-process@2.0.0:
dependencies:
chalk: 4.1.2
commander: 12.1.0
loglevel: 1.9.2
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -1418,7 +1455,7 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 2.0.0
google-auth-library@10.2.1:
google-auth-library@10.3.0:
dependencies:
base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11
@@ -1461,6 +1498,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
has-flag@4.0.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -1541,6 +1580,8 @@ snapshots:
process-warning: 4.0.1
set-cookie-parser: 2.7.1
loglevel@1.9.2: {}
lru-cache@11.1.0: {}
merge2@1.4.1: {}
@@ -1586,7 +1627,7 @@ snapshots:
dependencies:
wrappy: 1.0.2
openai@5.12.2(ws@8.18.3):
openai@5.16.0(ws@8.18.3):
optionalDependencies:
ws: 8.18.3
@@ -1613,10 +1654,6 @@ snapshots:
dependencies:
split2: 4.2.0
pino-rotating-file-stream@0.0.2:
dependencies:
rotating-file-stream: 3.2.6
pino-std-serializers@7.0.0: {}
pino@9.9.0:
@@ -1666,7 +1703,7 @@ snapshots:
rfdc@1.4.1: {}
rotating-file-stream@3.2.6: {}
rotating-file-stream@3.2.7: {}
run-parallel@1.2.0:
dependencies:
@@ -1748,6 +1785,10 @@ snapshots:
strip-eof@1.0.0: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
thread-stream@3.1.0:
@@ -1770,7 +1811,7 @@ snapshots:
undici-types@7.10.0: {}
undici@7.13.0: {}
undici@7.15.0: {}
uuid@11.1.0: {}

View File

@@ -17,15 +17,8 @@ class ImageCache {
});
}
calculateHash(base64Image: string): string {
const hash = createHash('sha256');
hash.update(base64Image);
return hash.digest('hex');
}
storeImage(id: string, source: any): void {
if (this.hasImage(id)) return;
const base64Image = source.data
this.cache.set(id, {
source,
timestamp: Date.now(),
@@ -62,12 +55,10 @@ export class ImageAgent implements IAgent {
}
shouldHandle(req: any, config: any): boolean {
if (!config.Router.image) return false;
if (!config.Router.image || req.body.model === config.Router.image) return false;
const lastMessage = req.body.messages[req.body.messages.length - 1]
if (lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) {
if (config.Router.image) {
req.body.model = config.Router.image
}
if (!config.forceUseImageAgent && lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) {
req.body.model = config.Router.image
return false;
}
return req.body.messages.some((msg: any) => msg.role === 'user' && Array.isArray(msg.content) && msg.content.some((item: any) => item.type === 'image'))
@@ -111,6 +102,7 @@ export class ImageAgent implements IAgent {
"required": ["imageId", "task"]
},
handler: async (args, context) => {
console.log('args', JSON.stringify(args, null, 2))
const imageMessages = [];
let imageId;
@@ -129,7 +121,6 @@ export class ImageAgent implements IAgent {
delete args.imageId;
}
// Add text message with the response
if (Object.keys(args).length > 0) {
imageMessages.push({
type: "text",
@@ -148,7 +139,10 @@ export class ImageAgent implements IAgent {
model: context.config.Router.image,
system: [{
type: 'text',
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 users 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: [
{
@@ -161,6 +155,7 @@ export class ImageAgent implements IAgent {
}).then(res => res.json()).catch(err => {
return null;
});
console.log(agentResponse.content);
if (!agentResponse || !agentResponse.content) {
return 'analyzeImage Error';
}

View File

@@ -45,7 +45,8 @@ async function waitForService(
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (isServiceRunning()) {
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;
@@ -56,6 +57,7 @@ async function waitForService(
}
async function main() {
const isRunning = await isServiceRunning()
switch (command) {
case "start":
run();
@@ -95,7 +97,7 @@ async function main() {
inputData += chunk;
}
});
process.stdin.on("end", async () => {
try {
const input: StatusLineInput = JSON.parse(inputData);
@@ -108,7 +110,7 @@ async function main() {
});
break;
case "code":
if (!isServiceRunning()) {
if (!isRunning) {
console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {
@@ -153,7 +155,7 @@ async function main() {
break;
case "ui":
// Check if service is running
if (!isServiceRunning()) {
if (!isRunning) {
console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {

View File

@@ -12,9 +12,8 @@ import {
savePid,
} from "./utils/processCheck";
import { CONFIG_FILE } from "./constants";
import createWriteStream from "pino-rotating-file-stream";
import { createStream } from 'rotating-file-stream';
import { HOME_DIR } from "./constants";
import { configureLogging } from "./utils/log";
import { sessionUsageCache } from "./utils/cache";
import {SSEParserTransform} from "./utils/SSEParser.transform";
import {SSESerializerTransform} from "./utils/SSESerializer.transform";
@@ -22,7 +21,9 @@ 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()
async function initializeClaudeConfig() {
const homeDir = homedir();
@@ -50,7 +51,8 @@ interface RunOptions {
async function run(options: RunOptions = {}) {
// Check if service is already running
if (isServiceRunning()) {
const isRunning = await isServiceRunning()
if (isRunning) {
console.log("✅ Service is already running in the background.");
return;
}
@@ -61,8 +63,6 @@ async function run(options: RunOptions = {}) {
await cleanupLogFiles();
const config = await initConfig();
// Configure logging based on config
configureLogging(config);
let HOST = config.HOST || "127.0.0.1";
@@ -95,15 +95,29 @@ async function run(options: RunOptions = {}) {
: 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: createWriteStream({
stream: createStream(generator, {
path: HOME_DIR,
filename: config.LOGNAME || `./logs/ccr-${+new Date()}.log`,
maxFiles: 3,
interval: "1d",
compress: false,
maxSize: "50M"
}),
}
: false;
@@ -123,6 +137,15 @@ async function run(options: RunOptions = {}) {
},
logger: loggerConfig,
});
// Add global error handlers to prevent the service from crashing
process.on("uncaughtException", (err) => {
server.log.error("Uncaught exception:", err);
});
process.on("unhandledRejection", (reason, promise) => {
server.log.error("Unhandled rejection at:", promise, "reason:", reason);
});
// Add async preHandler hook for authentication
server.addHook("preHandler", async (req, reply) => {
return new Promise((resolve, reject) => {
@@ -148,6 +171,9 @@ async function run(options: RunOptions = {}) {
// 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,
@@ -162,13 +188,20 @@ async function run(options: RunOptions = {}) {
if (useAgents.length) {
req.agents = useAgents;
}
await router(req, reply, config);
await router(req, reply, {
config,
event
});
}
});
server.addHook("onSend", async (req, reply, payload) => {
server.addHook("onError", async (request, reply, error) => {
event.emit('onError', request, reply, error);
})
server.addHook("onSend", (req, reply, payload, done) => {
if (req.sessionId && req.url.startsWith("/v1/messages")) {
if (payload instanceof ReadableStream) {
if (req.agents) {
const abortController = new AbortController();
const eventStream = payload.pipeThrough(new SSEParserTransform())
let currentAgent: undefined | IAgent;
let currentToolIndex = -1
@@ -178,118 +211,171 @@ async function run(options: RunOptions = {}) {
const toolMessages: any[] = []
const assistantMessages: any[] = []
// 存储Anthropic格式的消息体区分文本和工具类型
return rewriteStream(eventStream, async (data, controller) => {
// 检测工具调用开始
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 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;
}
}
// 收集工具参数
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
});
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:${config.PORT}/v1/messages`, {
method: "POST",
headers: {
'x-api-key': config.APIKEY,
'content-type': 'application/json',
},
body: JSON.stringify(req.body),
})
if (!response.ok) {
// 工具调用完成处理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
});
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;
}
const stream = response.body!.pipeThrough(new SSEParserTransform())
const reader = stream.getReader()
while (true) {
const {value, done} = await reader.read();
if (done) {
break;
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:${config.PORT}/v1/messages`, {
method: "POST",
headers: {
'x-api-key': config.APIKEY,
'content-type': 'application/json',
},
body: JSON.stringify(req.body),
})
if (!response.ok) {
return undefined;
}
if (['message_start', 'message_stop'].includes(value.event)) {
continue
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;
}
}
controller.enqueue(value)
return undefined
}
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;
}
return data
}).pipeThrough(new SSESerializerTransform())
}).pipeThrough(new SSESerializerTransform()))
}
const [originalStream, clonedStream] = payload.tee();
const read = async (stream: ReadableStream) => {
const reader = stream.getReader();
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;
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 {}
}
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 originalStream
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)
}
}
}
return payload;
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.start();
}

View File

@@ -3,6 +3,8 @@ import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { checkForUpdates, performUpdate } from "./utils";
import { join } from "path";
import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { homedir } from "os";
export const createServer = (config: any): Server => {
const server = new Server(config);
@@ -63,16 +65,16 @@ export const createServer = (config: any): Server => {
server.app.get("/ui", async (_, reply) => {
return reply.redirect("/ui/");
});
// 版本检查端点
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,
return {
hasUpdate,
latestVersion: hasUpdate ? latestVersion : undefined,
changelog: hasUpdate ? changelog : undefined
};
@@ -81,7 +83,7 @@ export const createServer = (config: any): Server => {
reply.status(500).send({ error: "Failed to check for updates" });
}
});
// 执行更新端点
server.app.post("/api/update/perform", async (req, reply) => {
try {
@@ -91,10 +93,10 @@ export const createServer = (config: any): Server => {
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);
@@ -102,5 +104,92 @@ export const createServer = (config: any): Server => {
}
});
// 获取日志文件列表端点
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" });
}
});
return server;
};

View File

@@ -5,8 +5,9 @@ import { join } from 'path';
export async function closeService() {
const PID_FILE = join(HOME_DIR, '.claude-code-router.pid');
if (!isServiceRunning()) {
const isRunning = await isServiceRunning()
if (!isRunning) {
console.log("No service is currently running.");
return;
}

View File

@@ -11,11 +11,15 @@ import {join} from "path";
export async function executeCodeCommand(args: string[] = []) {
// Set environment variables
const config = await readConfigFile();
const port = config.PORT || 3456;
const env: Record<string, string> = {
...process.env,
ANTHROPIC_AUTH_TOKEN: config?.APIKEY || "test",
ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.PORT || 3456}`,
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
};
let settingsFlag: Record<string, any> | undefined;
@@ -65,7 +69,6 @@ export async function executeCodeCommand(args: string[] = []) {
const stdioConfig: StdioOptions = config.NON_INTERACTIVE_MODE
? ["pipe", "inherit", "inherit"] // Pipe stdin for non-interactive
: "inherit"; // Default inherited behavior
console.log(joinedArgs)
const claudeProcess = spawn(
claudePath + (joinedArgs ? ` ${joinedArgs}` : ""),
[],

View File

@@ -83,25 +83,38 @@ export const readConfigFile = async () => {
} catch (readError: any) {
if (readError.code === "ENOENT") {
// Config file doesn't exist, prompt user for initial setup
const name = await question("Enter Provider Name: ");
const APIKEY = await question("Enter Provider API KEY: ");
const baseUrl = await question("Enter Provider URL: ");
const model = await question("Enter MODEL Name: ");
const config = Object.assign({}, DEFAULT_CONFIG, {
Providers: [
{
name,
api_base_url: baseUrl,
api_key: APIKEY,
models: [model],
},
],
Router: {
default: `${name},${model}`,
},
});
await writeConfigFile(config);
return config;
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);
@@ -116,19 +129,19 @@ export const backupConfigFile = async () => {
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++) {
@@ -139,7 +152,7 @@ export const backupConfigFile = async () => {
} catch (cleanupError) {
console.warn("Failed to clean up old backups:", cleanupError);
}
return backupPath;
}
} catch (error) {

View File

@@ -1,45 +0,0 @@
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");
// Ensure log directory exists
if (!fs.existsSync(HOME_DIR)) {
fs.mkdirSync(HOME_DIR, { recursive: true });
}
// Global variable to store the logging configuration
let isLogEnabled: boolean | null = null;
let logLevel: string = "info";
// Function to configure logging
export function configureLogging(config: { LOG?: boolean; LOG_LEVEL?: string }) {
isLogEnabled = config.LOG !== false; // Default to true if not explicitly set to false
logLevel = config.LOG_LEVEL || "debug";
}
export function log(...args: any[]) {
// If logging configuration hasn't been set, default to enabled
if (isLogEnabled === null) {
isLogEnabled = true;
}
if (!isLogEnabled) {
return;
}
const timestamp = new Date().toISOString();
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");
}

View File

@@ -1,6 +1,16 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
import { readConfigFile } from '.';
import find from 'find-process';
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;
@@ -27,15 +37,14 @@ export function getReferenceCount(): number {
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
}
export function isServiceRunning(): boolean {
export async function isServiceRunning(): Promise<boolean> {
if (!existsSync(PID_FILE)) {
return false;
}
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
process.kill(pid, 0);
return true;
return await isProcessRunning(pid);
} catch (e) {
// Process not running, clean up pid file
cleanupPidFile();
@@ -62,7 +71,7 @@ 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;
@@ -73,10 +82,10 @@ export function getServicePid(): number | null {
export async function getServiceInfo() {
const pid = getServicePid();
const running = isServiceRunning();
const running = await isServiceRunning();
const config = await readConfigFile();
const port = config.PORT || 3456;
return {
running,
pid,

View File

@@ -4,8 +4,8 @@ import {
Tool,
} from "@anthropic-ai/sdk/resources/messages";
import { get_encoding } from "tiktoken";
import { log } from "./log";
import { sessionUsageCache, Usage } from "./cache";
import { readFile } from 'fs/promises'
const enc = get_encoding("cl100k_base");
@@ -94,11 +94,8 @@ const getUseModel = async (
(lastUsageThreshold || tokenCountThreshold) &&
config.Router.longContext
) {
log(
"Using long context model due to token count:",
tokenCount,
"threshold:",
longContextThreshold
req.log.info(
`Using long context model due to token count: ${tokenCount}, threshold: ${longContextThreshold}`
);
return config.Router.longContext;
}
@@ -122,12 +119,12 @@ const getUseModel = async (
req.body.model?.startsWith("claude-3-5-haiku") &&
config.Router.background
) {
log("Using background model for ", req.body.model);
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) {
log("Using think model for ", req.body.thinking);
req.log.info(`Using think model for ${req.body.thinking}`);
return config.Router.think;
}
if (
@@ -140,7 +137,8 @@ const getUseModel = async (
return config.Router!.default;
};
export const router = async (req: any, _res: any, config: any) => {
export const router = async (req: any, _res: any, context: any) => {
const { config, event } = context;
// Parse sessionId from metadata.user_id
if (req.body.metadata?.user_id) {
const parts = req.body.metadata.user_id.split("_session_");
@@ -150,6 +148,11 @@ export const router = async (req: any, _res: any, config: any) => {
}
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[],
@@ -162,9 +165,11 @@ export const router = async (req: any, _res: any, config: any) => {
try {
const customRouter = require(config.CUSTOM_ROUTER_PATH);
req.tokenCount = tokenCount; // Pass token count to custom router
model = await customRouter(req, config);
model = await customRouter(req, config, {
event
});
} catch (e: any) {
log("failed to load custom router", e.message);
req.log.error(`failed to load custom router: ${e.message}`);
}
}
if (!model) {
@@ -172,7 +177,7 @@ export const router = async (req: any, _res: any, config: any) => {
}
req.body.model = model;
} catch (error: any) {
log("Error in router middleware:", error.message);
req.log.error(`Error in router middleware: ${error.message}`);
req.body.model = config.Router!.default;
}
return;

View File

@@ -6,10 +6,11 @@ import { Transformers } from "@/components/Transformers";
import { Providers } from "@/components/Providers";
import { Router } from "@/components/Router";
import { JsonEditor } from "@/components/JsonEditor";
import { LogViewer } from "@/components/LogViewer";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp } from "lucide-react";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
import {
Popover,
PopoverContent,
@@ -32,6 +33,7 @@ function App() {
const { config, error } = useConfig();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false);
const [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
// 版本检查状态
@@ -276,6 +278,9 @@ function App() {
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
<FileJson className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
<FileText className="h-5 w-5" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
@@ -350,6 +355,11 @@ function App() {
onOpenChange={setIsJsonEditorOpen}
showToast={(message, type) => setToast({ message, type })}
/>
<LogViewer
open={isLogViewerOpen}
onOpenChange={setIsLogViewerOpen}
showToast={(message, type) => setToast({ message, type })}
/>
{/* 版本更新对话框 */}
<Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}>
<DialogContent className="max-w-2xl">

View File

@@ -95,15 +95,18 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
think: typeof data.Router.think === 'string' ? data.Router.think : '',
longContext: typeof data.Router.longContext === 'string' ? data.Router.longContext : '',
longContextThreshold: typeof data.Router.longContextThreshold === 'number' ? data.Router.longContextThreshold : 60000,
webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : ''
webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : '',
image: typeof data.Router.image === 'string' ? data.Router.image : ''
} : {
default: '',
background: '',
think: '',
longContext: '',
longContextThreshold: 60000,
webSearch: ''
}
webSearch: '',
image: ''
},
CUSTOM_ROUTER_PATH: typeof data.CUSTOM_ROUTER_PATH === 'string' ? data.CUSTOM_ROUTER_PATH : ''
};
setConfig(validConfig);
@@ -131,8 +134,10 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
think: '',
longContext: '',
longContextThreshold: 60000,
webSearch: ''
}
webSearch: '',
image: ''
},
CUSTOM_ROUTER_PATH: ''
});
setError(err as Error);
}

View File

@@ -0,0 +1,726 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { useTranslation } from 'react-i18next';
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers } from 'lucide-react';
interface LogViewerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
}
interface LogEntry {
timestamp: string;
level: 'info' | 'warn' | 'error' | 'debug';
message: string; // 现在这个字段直接包含原始JSON字符串
source?: string;
reqId?: string;
}
interface LogFile {
name: string;
path: string;
size: number;
lastModified: string;
}
interface GroupedLogs {
[reqId: string]: LogEntry[];
}
interface LogGroupSummary {
reqId: string;
logCount: number;
firstLog: string;
lastLog: string;
model?: string;
}
interface GroupedLogsResponse {
grouped: boolean;
groups: { [reqId: string]: LogEntry[] };
summary: {
totalRequests: number;
totalLogs: number;
requests: LogGroupSummary[];
};
}
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
const { t } = useTranslation();
const [logs, setLogs] = useState<string[]>([]);
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [groupByReqId, setGroupByReqId] = useState(false);
const [groupedLogs, setGroupedLogs] = useState<GroupedLogsResponse | null>(null);
const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
if (open) {
loadLogFiles();
}
}, [open]);
// 创建内联 Web Worker
const createInlineWorker = (): Worker => {
const workerCode = `
// 日志聚合Web Worker
self.onmessage = function(event) {
const { type, data } = event.data;
if (type === 'groupLogsByReqId') {
try {
const { logs } = data;
// 按reqId聚合日志
const groupedLogs = {};
logs.forEach((log, index) => {
log = JSON.parse(log);
let reqId = log.reqId || 'no-req-id';
if (!groupedLogs[reqId]) {
groupedLogs[reqId] = [];
}
groupedLogs[reqId].push(log);
});
// 按时间戳排序每个组的日志
Object.keys(groupedLogs).forEach(reqId => {
groupedLogs[reqId].sort((a, b) => a.time - b.time);
});
// 提取model信息
const extractModelInfo = (reqId) => {
const logGroup = groupedLogs[reqId];
for (const log of logGroup) {
try {
// 尝试从message字段解析JSON
if (log.type === 'request body' && log.data && log.data.model) {
return log.data.model;
}
} catch (e) {
// 解析失败,继续尝试下一条日志
}
}
return undefined;
};
// 生成摘要信息
const summary = {
totalRequests: Object.keys(groupedLogs).length,
totalLogs: logs.length,
requests: Object.keys(groupedLogs).map(reqId => ({
reqId,
logCount: groupedLogs[reqId].length,
firstLog: groupedLogs[reqId][0]?.time,
lastLog: groupedLogs[reqId][groupedLogs[reqId].length - 1]?.time,
model: extractModelInfo(reqId)
}))
};
const response = {
grouped: true,
groups: groupedLogs,
summary
};
// 发送结果回主线程
self.postMessage({
type: 'groupLogsResult',
data: response
});
} catch (error) {
// 发送错误回主线程
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
return new Worker(workerUrl);
};
// 初始化Web Worker
useEffect(() => {
if (typeof Worker !== 'undefined') {
try {
// 创建内联Web Worker
workerRef.current = createInlineWorker();
// 监听Worker消息
workerRef.current.onmessage = (event) => {
const { type, data, error } = event.data;
if (type === 'groupLogsResult') {
setGroupedLogs(data);
} else if (type === 'error') {
console.error('Worker error:', error);
if (showToast) {
showToast(t('log_viewer.worker_error') + ': ' + error, 'error');
}
}
};
// 监听Worker错误
workerRef.current.onerror = (error) => {
console.error('Worker error:', error);
if (showToast) {
showToast(t('log_viewer.worker_init_failed'), 'error');
}
};
} catch (error) {
console.error('Failed to create worker:', error);
if (showToast) {
showToast(t('log_viewer.worker_init_failed'), 'error');
}
}
}
// 清理Worker
return () => {
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
};
}, [showToast, t]);
useEffect(() => {
if (autoRefresh && open && selectedFile) {
refreshInterval.current = setInterval(() => {
loadLogs();
}, 5000); // Refresh every 5 seconds
} else if (refreshInterval.current) {
clearInterval(refreshInterval.current);
}
return () => {
if (refreshInterval.current) {
clearInterval(refreshInterval.current);
}
};
}, [autoRefresh, open, selectedFile]);
// Load logs when selected file changes
useEffect(() => {
if (selectedFile && open) {
setLogs([]); // Clear existing logs
loadLogs();
}
}, [selectedFile, open]);
// Handle open/close animations
useEffect(() => {
if (open) {
setIsVisible(true);
// Trigger the animation after a small delay to ensure the element is rendered
requestAnimationFrame(() => {
setIsAnimating(true);
});
} else {
setIsAnimating(false);
// Wait for the animation to complete before hiding
const timer = setTimeout(() => {
setIsVisible(false);
}, 300);
return () => clearTimeout(timer);
}
}, [open]);
const loadLogFiles = async () => {
try {
setIsLoading(true);
const response = await api.getLogFiles();
if (response && Array.isArray(response)) {
setLogFiles(response);
setSelectedFile(null);
setLogs([]);
} else {
setLogFiles([]);
if (showToast) {
showToast(t('log_viewer.no_log_files_available'), 'warning');
}
}
} catch (error) {
console.error('Failed to load log files:', error);
if (showToast) {
showToast(t('log_viewer.load_files_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsLoading(false);
}
};
const loadLogs = async () => {
if (!selectedFile) return;
try {
setIsLoading(true);
setGroupedLogs(null);
setSelectedReqId(null);
// 始终加载原始日志数据
const response = await api.getLogs(selectedFile.path);
if (response && Array.isArray(response)) {
// 现在接口返回的是原始日志字符串数组,直接存储
setLogs(response);
// 如果启用了分组使用Web Worker进行聚合需要转换为LogEntry格式供Worker使用
if (groupByReqId && workerRef.current) {
// const workerLogs: LogEntry[] = response.map((logLine, index) => ({
// timestamp: new Date().toISOString(),
// level: 'info',
// message: logLine,
// source: undefined,
// reqId: undefined
// }));
workerRef.current.postMessage({
type: 'groupLogsByReqId',
data: { logs: response }
});
} else {
setGroupedLogs(null);
}
} else {
setLogs([]);
setGroupedLogs(null);
if (showToast) {
showToast(t('log_viewer.no_logs_available'), 'warning');
}
}
} catch (error) {
console.error('Failed to load logs:', error);
if (showToast) {
showToast(t('log_viewer.load_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsLoading(false);
}
};
const clearLogs = async () => {
if (!selectedFile) return;
try {
await api.clearLogs(selectedFile.path);
setLogs([]);
if (showToast) {
showToast(t('log_viewer.logs_cleared'), 'success');
}
} catch (error) {
console.error('Failed to clear logs:', error);
if (showToast) {
showToast(t('log_viewer.clear_failed') + ': ' + (error as Error).message, 'error');
}
}
};
const selectFile = (file: LogFile) => {
setSelectedFile(file);
setAutoRefresh(false); // Reset auto refresh when changing files
};
const toggleGroupByReqId = () => {
const newValue = !groupByReqId;
setGroupByReqId(newValue);
if (newValue && selectedFile && logs.length > 0) {
// 启用聚合时如果已有日志则使用Worker进行聚合
if (workerRef.current) {
workerRef.current.postMessage({
type: 'groupLogsByReqId',
data: { logs }
});
}
} else if (!newValue) {
// 禁用聚合时,清除聚合结果
setGroupedLogs(null);
setSelectedReqId(null);
}
};
const selectReqId = (reqId: string) => {
setSelectedReqId(reqId);
};
const getDisplayLogs = () => {
if (groupByReqId && groupedLogs) {
if (selectedReqId && groupedLogs.groups[selectedReqId]) {
return groupedLogs.groups[selectedReqId];
}
// 当在分组模式但没有选中具体请求时,显示原始日志字符串数组
return logs.map(logLine => ({
timestamp: new Date().toISOString(),
level: 'info',
message: logLine,
source: undefined,
reqId: undefined
}));
}
// 当不在分组模式时,显示原始日志字符串数组
return logs.map(logLine => ({
timestamp: new Date().toISOString(),
level: 'info',
message: logLine,
source: undefined,
reqId: undefined
}));
};
const downloadLogs = () => {
if (!selectedFile || logs.length === 0) return;
// 直接下载原始日志字符串,每行一个日志
const logText = logs.join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedFile.name}-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (showToast) {
showToast(t('log_viewer.logs_downloaded'), 'success');
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// 面包屑导航项类型
interface BreadcrumbItem {
id: string;
label: string;
onClick: () => void;
}
// 获取面包屑导航项
const getBreadcrumbs = (): BreadcrumbItem[] => {
const breadcrumbs: BreadcrumbItem[] = [
{
id: 'root',
label: t('log_viewer.title'),
onClick: () => {
setSelectedFile(null);
setAutoRefresh(false);
setLogs([]);
setGroupedLogs(null);
setSelectedReqId(null);
setGroupByReqId(false);
}
}
];
if (selectedFile) {
breadcrumbs.push({
id: 'file',
label: selectedFile.name,
onClick: () => {
if (groupByReqId) {
// 如果在分组模式下,点击文件层级应该返回到分组列表
setSelectedReqId(null);
} else {
// 如果不在分组模式下,点击文件层级关闭分组功能
setSelectedReqId(null);
setGroupedLogs(null);
setGroupByReqId(false);
}
}
});
}
if (selectedReqId) {
breadcrumbs.push({
id: 'req',
label: `${t('log_viewer.request')} ${selectedReqId}`,
onClick: () => {
// 点击当前层级时不做任何操作
}
});
}
return breadcrumbs;
};
// 获取返回按钮的处理函数
const getBackAction = (): (() => void) | null => {
if (selectedReqId) {
return () => {
setSelectedReqId(null);
};
} else if (selectedFile) {
return () => {
setSelectedFile(null);
setAutoRefresh(false);
setLogs([]);
setGroupedLogs(null);
setSelectedReqId(null);
setGroupByReqId(false);
};
}
return null;
};
const formatLogsForEditor = () => {
// 如果在分组模式且选中了具体请求,显示该请求的日志
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
const requestLogs = groupedLogs.groups[selectedReqId];
console.log(requestLogs)
// 提取原始JSON字符串并每行一个
return requestLogs.map(log => JSON.stringify(log)).join('\n');
}
// 其他情况,直接显示原始日志字符串数组,每行一个
return logs.join('\n');
};
if (!isVisible && !open) {
return null;
}
return (
<>
{(isVisible || open) && (
<div
className={`fixed inset-0 z-50 transition-all duration-300 ease-out ${
isAnimating && open ? 'bg-black/50 opacity-100' : 'bg-black/0 opacity-0 pointer-events-none'
}`}
onClick={() => onOpenChange(false)}
/>
)}
<div
ref={containerRef}
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-white shadow-2xl transition-all duration-300 ease-out transform ${
isAnimating && open ? 'translate-y-0' : 'translate-y-full'
}`}
style={{
height: '100vh',
maxHeight: '100vh'
}}
>
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-2">
{getBackAction() && (
<Button
variant="ghost"
size="sm"
onClick={getBackAction()!}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('log_viewer.back')}
</Button>
)}
{/* 面包屑导航 */}
<nav className="flex items-center space-x-1 text-sm">
{getBreadcrumbs().map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.id}>
{index > 0 && (
<span className="text-gray-400 mx-1">/</span>
)}
{index === getBreadcrumbs().length - 1 ? (
<span className="text-gray-900 font-medium">
{breadcrumb.label}
</span>
) : (
<button
onClick={breadcrumb.onClick}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
{breadcrumb.label}
</button>
)}
</React.Fragment>
))}
</nav>
</div>
<div className="flex gap-2">
{selectedFile && (
<>
<Button
variant="ghost"
size="sm"
onClick={toggleGroupByReqId}
className={groupByReqId ? 'bg-blue-100 text-blue-700' : ''}
>
<Layers className="h-4 w-4 mr-2" />
{groupByReqId ? t('log_viewer.grouped_on') : t('log_viewer.group_by_req_id')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
className={autoRefresh ? 'bg-blue-100 text-blue-700' : ''}
>
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
{autoRefresh ? t('log_viewer.auto_refresh_on') : t('log_viewer.auto_refresh_off')}
</Button>
<Button
variant="outline"
size="sm"
onClick={downloadLogs}
disabled={logs.length === 0}
>
<Download className="h-4 w-4 mr-2" />
{t('log_viewer.download')}
</Button>
<Button
variant="outline"
size="sm"
onClick={clearLogs}
disabled={logs.length === 0}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('log_viewer.clear')}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4 mr-2" />
{t('log_viewer.close')}
</Button>
</div>
</div>
<div className="flex-1 min-h-0 bg-gray-50">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : selectedFile ? (
<>
{groupByReqId && groupedLogs && !selectedReqId ? (
// 显示日志组列表
<div className="flex flex-col h-full p-6">
<div className="mb-4 flex-shrink-0">
<h3 className="text-lg font-medium mb-2">{t('log_viewer.request_groups')}</h3>
<p className="text-sm text-gray-600">
{t('log_viewer.total_requests')}: {groupedLogs.summary.totalRequests} |
{t('log_viewer.total_logs')}: {groupedLogs.summary.totalLogs}
</p>
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-3">
{groupedLogs.summary.requests.map((request) => (
<div
key={request.reqId}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => selectReqId(request.reqId)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<File className="h-5 w-5 text-blue-600" />
<span className="font-medium text-sm">{request.reqId}</span>
{request.model && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
{request.model}
</span>
)}
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
{request.logCount} {t('log_viewer.logs')}
</span>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div>{t('log_viewer.first_log')}: {formatDate(request.firstLog)}</div>
<div>{t('log_viewer.last_log')}: {formatDate(request.lastLog)}</div>
</div>
</div>
))}
</div>
</div>
) : (
// 显示日志内容
<Editor
height="100%"
defaultLanguage="json"
value={formatLogsForEditor()}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
readOnly: true,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'all',
}}
/>
)}
</>
) : (
<div className="p-6">
<h3 className="text-lg font-medium mb-4">{t('log_viewer.select_file')}</h3>
{logFiles.length === 0 ? (
<div className="text-gray-500 text-center py-8">
<File className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>{t('log_viewer.no_log_files_available')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{logFiles.map((file) => (
<div
key={file.path}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => selectFile(file)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<File className="h-5 w-5 text-blue-600" />
<span className="font-medium text-sm">{file.name}</span>
</div>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div>{formatFileSize(file.size)}</div>
<div>{formatDate(file.lastModified)}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -30,7 +30,8 @@ export function Router() {
think: "",
longContext: "",
longContextThreshold: 60000,
webSearch: ""
webSearch: "",
image: ""
};
const handleRouterChange = (field: string, value: string | number) => {
@@ -40,6 +41,10 @@ export function Router() {
setConfig({ ...config, Router: newRouter });
};
const handleForceUseImageAgentChange = (value: boolean) => {
setConfig({ ...config, forceUseImageAgent: value });
};
// Handle case where config.Providers might be null or undefined
const providers = Array.isArray(config.Providers) ? config.Providers : [];
@@ -133,6 +138,33 @@ export function Router() {
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="flex-1">
<Label>{t("router.image")} (beta)</Label>
<Combobox
options={modelOptions}
value={routerConfig.image || ""}
onChange={(value) => handleRouterChange("image", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="w-48">
<Label htmlFor="forceUseImageAgent">{t("router.forceUseImageAgent")}</Label>
<select
id="forceUseImageAgent"
value={config.forceUseImageAgent ? "true" : "false"}
onChange={(e) => handleForceUseImageAgentChange(e.target.value === "true")}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="false">{t("common.no")}</option>
<option value="true">{t("common.yes")}</option>
</select>
</div>
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -212,6 +212,21 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
className="transition-all-ease focus:scale-[1.01]"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="custom-router-path"
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
>
{t("toplevel.custom_router_path")}
</Label>
<Input
id="custom-router-path"
value={config.CUSTOM_ROUTER_PATH || ""}
onChange={(e) => setConfig({ ...config, CUSTOM_ROUTER_PATH: e.target.value })}
placeholder={t("toplevel.custom_router_path_placeholder")}
className="transition-all-ease focus:scale-[1.01]"
/>
</div>
</div>
<DialogFooter className="p-4 pt-0">
<Button

View File

@@ -4,15 +4,72 @@ import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
const isNumeric = type === "number";
const [tempValue, setTempValue] = React.useState(props.value?.toString() || '');
React.useEffect(() => {
if (props.value !== undefined) {
setTempValue(props.value.toString());
}
}, [props.value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (isNumeric) {
// Only allow empty string or numbers for numeric input
if (newValue === '' || /^\d+$/.test(newValue)) {
setTempValue(newValue);
// Only call onChange if the value is not empty
if (props.onChange && newValue !== '') {
props.onChange(e);
}
}
} else {
setTempValue(newValue);
if (props.onChange) {
props.onChange(e);
}
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (isNumeric && tempValue === '') {
const defaultValue = props.placeholder || "1";
setTempValue(defaultValue);
// Create a synthetic event for the corrected value
if (props.onChange) {
const syntheticEvent = {
...e,
target: { ...e.target, value: defaultValue }
} as React.ChangeEvent<HTMLInputElement>;
props.onChange(syntheticEvent);
}
}
if (props.onBlur) {
props.onBlur(e);
}
};
// For numeric inputs, use text type and manage value internally
const inputType = isNumeric ? "text" : type;
const inputValue = isNumeric ? tempValue : props.value;
return (
<input
type={type}
{...props}
type={inputType}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}

View File

@@ -1,5 +1,21 @@
import type { Config, Provider, Transformer } from '@/types';
// 日志聚合响应类型
interface GroupedLogsResponse {
grouped: boolean;
groups: { [reqId: string]: Array<{ timestamp: string; level: string; message: string; source?: string; reqId?: string }> };
summary: {
totalRequests: number;
totalLogs: number;
requests: Array<{
reqId: string;
logCount: number;
firstLog: string;
lastLog: string;
}>;
};
}
// API Client Class for handling requests with baseUrl and apikey authentication
class ApiClient {
private baseUrl: string;
@@ -204,6 +220,21 @@ class ApiClient {
async performUpdate(): Promise<{ success: boolean; message: string }> {
return this.post<{ success: boolean; message: string }>('/api/update/perform', {});
}
// Get log files list
async getLogFiles(): Promise<Array<{ name: string; path: string; size: number; lastModified: string }>> {
return this.get<Array<{ name: string; path: string; size: number; lastModified: string }>>('/logs/files');
}
// Get logs from specific file
async getLogs(filePath: string): Promise<string[]> {
return this.get<string[]>(`/logs?file=${encodeURIComponent(filePath)}`);
}
// Clear logs from specific file
async clearLogs(filePath: string): Promise<void> {
return this.delete<void>(`/logs?file=${encodeURIComponent(filePath)}`);
}
}
// Create a default instance of the API client

View File

@@ -1,4 +1,8 @@
{
"common": {
"yes": "Yes",
"no": "No"
},
"app": {
"title": "Claude Code Router",
"save": "Save",
@@ -42,7 +46,9 @@
"port": "Port",
"apikey": "API Key",
"timeout": "API Timeout (ms)",
"proxy_url": "Proxy URL"
"proxy_url": "Proxy URL",
"custom_router_path": "Custom Router Script Path",
"custom_router_path_placeholder": "Enter absolute path to custom router script file"
},
"transformers": {
"title": "Custom Transformers",
@@ -105,6 +111,8 @@
"longContext": "Long Context",
"longContextThreshold": "Context Threshold",
"webSearch": "Web Search",
"image": "Image",
"forceUseImageAgent": "Force Use Image Agent",
"selectModel": "Select a model...",
"searchModel": "Search model...",
"noModelFound": "No model found."
@@ -185,5 +193,36 @@
"template_download_success": "Template downloaded successfully",
"template_download_success_desc": "Configuration template has been downloaded to your device",
"template_download_failed": "Failed to download template"
},
"log_viewer": {
"title": "Log Viewer",
"close": "Close",
"download": "Download",
"clear": "Clear",
"auto_refresh_on": "Auto Refresh On",
"auto_refresh_off": "Auto Refresh Off",
"load_failed": "Failed to load logs",
"no_logs_available": "No logs available",
"logs_cleared": "Logs cleared successfully",
"clear_failed": "Failed to clear logs",
"logs_downloaded": "Logs downloaded successfully",
"back_to_files": "Back to Files",
"select_file": "Select a log file to view",
"no_log_files_available": "No log files available",
"load_files_failed": "Failed to load log files",
"group_by_req_id": "Group by Request ID",
"grouped_on": "Grouped",
"request_groups": "Request Groups",
"total_requests": "Total Requests",
"total_logs": "Total Logs",
"request": "Request",
"logs": "logs",
"first_log": "First Log",
"last_log": "Last Log",
"back_to_all_logs": "Back to All Logs",
"worker_error": "Worker error",
"worker_init_failed": "Failed to initialize worker",
"grouping_not_supported": "Log grouping not supported by server",
"back": "Back"
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"yes": "是",
"no": "否"
},
"app": {
"title": "Claude Code Router",
"save": "保存",
@@ -42,7 +46,9 @@
"port": "端口",
"apikey": "API 密钥",
"timeout": "API 超时时间 (毫秒)",
"proxy_url": "代理地址"
"proxy_url": "代理地址",
"custom_router_path": "自定义路由脚本路径",
"custom_router_path_placeholder": "输入自定义路由脚本文件的绝对路径"
},
"transformers": {
"title": "自定义转换器",
@@ -105,6 +111,8 @@
"longContext": "长上下文",
"longContextThreshold": "上下文阈值",
"webSearch": "网络搜索",
"image": "图像",
"forceUseImageAgent": "强制使用图像代理",
"selectModel": "选择一个模型...",
"searchModel": "搜索模型...",
"noModelFound": "未找到模型."
@@ -185,5 +193,36 @@
"template_download_success": "模板下载成功",
"template_download_success_desc": "配置模板已下载到您的设备",
"template_download_failed": "模板下载失败"
},
"log_viewer": {
"title": "日志查看器",
"close": "关闭",
"download": "下载",
"clear": "清除",
"auto_refresh_on": "自动刷新开启",
"auto_refresh_off": "自动刷新关闭",
"load_failed": "加载日志失败",
"no_logs_available": "暂无日志",
"logs_cleared": "日志清除成功",
"clear_failed": "清除日志失败",
"logs_downloaded": "日志下载成功",
"back_to_files": "返回文件列表",
"select_file": "选择要查看的日志文件",
"no_log_files_available": "暂无日志文件",
"load_files_failed": "加载日志文件失败",
"group_by_req_id": "按请求ID分组",
"grouped_on": "已分组",
"request_groups": "请求组",
"total_requests": "总请求数",
"total_logs": "总日志数",
"request": "请求",
"logs": "条日志",
"first_log": "首条日志",
"last_log": "末条日志",
"back_to_all_logs": "返回所有日志",
"worker_error": "Worker错误",
"worker_init_failed": "Worker初始化失败",
"grouping_not_supported": "服务器不支持日志分组",
"back": "返回"
}
}

View File

@@ -18,6 +18,7 @@ export interface RouterConfig {
longContext: string;
longContextThreshold: number;
webSearch: string;
image: string;
custom?: any;
}
@@ -53,6 +54,7 @@ export interface Config {
Router: RouterConfig;
transformers: Transformer[];
StatusLine?: StatusLineConfig;
forceUseImageAgent?: boolean;
// Top-level settings
LOG: boolean;
LOG_LEVEL: string;
@@ -62,6 +64,7 @@ export interface Config {
APIKEY: string;
API_TIMEOUT_MS: string;
PROXY_URL: string;
CUSTOM_ROUTER_PATH?: string;
}
export type AccessLevel = 'restricted' | 'full';

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}