9 Commits

Author SHA1 Message Date
jinhui.li
3ef82991fb support json config 2025-07-03 17:19:26 +08:00
jinhui.li
f9ba3805a6 fix gemini auth error 2025-07-03 09:08:31 +08:00
jinhui.li
936f697110 update readme 2025-07-02 13:33:47 +08:00
jinhui.li
b07bbd7d8c release v1.0.11 2025-07-02 13:31:42 +08:00
jinhui.li
42f7d2da60 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
#	src/utils/index.ts
2025-07-02 13:31:10 +08:00
jinhui.li
30c764828a switch to llms 2025-07-02 13:24:37 +08:00
musi
802bde2d76 Merge pull request #67 from stonega/main 2025-06-25 17:43:47 +08:00
stone
b2db0307eb Add support for lowercase HTTPS_PROXY environment variable 2025-06-24 15:19:05 +08:00
musi
391cbd8334 Update readme 2025-06-23 22:19:05 +08:00
21 changed files with 1781 additions and 2970 deletions

View File

@@ -9,3 +9,6 @@ screenshoots
.env
.blog
docs
.log
blog
config.json

129
README.md
View File

@@ -29,13 +29,11 @@ ccr code
```json
{
"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",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-xxx",
"models": [
"google/gemini-2.5-pro-preview",
@@ -46,18 +44,48 @@ ccr code
},
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-reasoner"]
"models": ["deepseek-chat", "deepseek-reasoner"],
"transformer": {
"use": ["deepseek"],
"deepseek-chat": {
// Enhance tool usage for the deepseek-chat model using the ToolUse transformer.
"use": ["tooluse"]
}
}
},
{
"name": "ollama",
"api_base_url": "http://localhost:11434/v1",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama",
"models": ["qwen2.5-coder:latest"]
},
{
"name": "gemini",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
"api_key": "sk-xxx",
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
"transformer": {
"use": ["gemini"]
}
},
{
"name": "volcengine",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-v3-250324", "deepseek-r1-250528"],
"transformer": {
"use": ["deepseek"]
}
}
],
"Router": {
"default": "deepseek,deepseek-chat", // IMPORTANT OPENAI_MODEL has been deprecated
"background": "ollama,qwen2.5-coder:latest",
"think": "deepseek,deepseek-reasoner",
"longContext": "openrouter,google/gemini-2.5-pro-preview"
@@ -86,74 +114,9 @@ ccr code
- [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.
```
- [ ] Support image
- [ ] Support web search
## Github Actions
@@ -244,7 +207,8 @@ Some interesting points: Based on my testing, including a lot of context informa
## Some articles:
1. [Project Motivation and Principles](blog/en/project-motivation-and-how-it-works.md) ([中文版看这里](blog/zh/项目初衷及原理.md))
1. [Project Motivation and Principles](blog/en/project-motivation-and-how-it-works.md) ([项目初衷及原理](blog/zh/项目初衷及原理.md))
2. [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md) ([或许我们能在 Router 中做更多事情](blog/zh/或许我们能在Router中做更多事情.md))
## Buy me a coffee
@@ -261,12 +225,17 @@ If you find this project helpful, you can choose to sponsor the author with a cu
## Sponsors
Thanks to the following sponsors:
Thanks to the following sponsors for supporting the continued development of this project:
@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 用户名)
@\*o (可通过主页邮箱联系我修改 github 用户名)
[@ceilwoo](https://github.com/ceilwoo)
@\*说 (可通过主页邮箱联系我修改 github 用户名)
@\*更 (可通过主页邮箱联系我修改 github 用户名)
@K\*g (可通过主页邮箱联系我修改 github 用户名)
@R\*R (可通过主页邮箱联系我修改 github 用户名)
@[@bobleer](https://github.com/bobleer) (可通过主页邮箱联系我修改 github 用户名)
@\*苗 (可通过主页邮箱联系我修改 github 用户名)
@\*划 (可通过主页邮箱联系我修改 github 用户名)

View File

@@ -0,0 +1,105 @@
# Maybe We Can Do More with the Router
Since the release of `claude-code-router`, Ive received a lot of user feedback, and quite a few issues are still open. Most of them are related to support for different providers and the lack of tool usage from the deepseek model.
Originally, I created this project for personal use, mainly to access claude code at a lower cost. So, multi-provider support wasnt part of the initial design. But during troubleshooting, I discovered that even though most providers claim to be compatible with the OpenAI-style `/chat/completions` interface, there are many subtle differences. For example:
1. When Gemini's tool parameter type is string, the `format` field only supports `date` and `date-time`, and theres no tool call ID.
2. OpenRouter requires `cache_control` for caching.
3. The official DeepSeek API has a `max_output` of 8192, but Volcano Engines limit is even higher.
Aside from these, smaller providers often have quirks in their parameter handling. So I decided to create a new project, [musistudio/llms](https://github.com/musistudio/llms), to deal with these compatibility issues. It uses the OpenAI format as a base and introduces a generic Transformer interface for transforming both requests and responses.
Once a `Transformer` is implemented for each provider, it becomes possible to mix-and-match requests between them. For example, I implemented bidirectional conversion between Anthropic and OpenAI formats in `AnthropicTransformer`, which listens to the `/v1/messages` endpoint. Similarly, `GeminiTransformer` handles Gemini <-> OpenAI format conversions and listens to `/v1beta/models/:modelAndAction`.
When both requests and responses are transformed into a common format, they can interoperate seamlessly:
```
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
```
```
GeminiResponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
```
Using a middleware layer to smooth out differences may introduce some performance overhead, but the main goal here is to enable `claude-code-router` to support multiple providers.
As for the issue of DeepSeeks lackluster tool usage — I found that it stems from poor instruction adherence in long conversations. Initially, the model actively calls tools, but after several rounds, it starts responding with plain text instead. My first workaround was injecting a system prompt to remind the model to use tools proactively. But in long contexts, the model tends to forget this instruction.
After reading the DeepSeek documentation, I noticed it supports the `tool_choice` parameter, which can be set to `"required"` to force the model to use at least one tool. I tested this by enabling the parameter, and it significantly improved the models tool usage. We can remove the setting when it's no longer necessary. With the help of the `Transformer` interface in [musistudio/llms](https://github.com/musistudio/llms), we can modify the request before its sent and adjust the response after its received.
Inspired by the Plan Mode in `claude code`, I implemented a similar Tool Mode for DeepSeek:
```typescript
export class TooluseTransformer implements Transformer {
name = "tooluse";
transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
if (request.tools?.length) {
request.messages.push({
role: "system",
content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task.
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
});
request.tool_choice = "required";
request.tools.unshift({
type: "function",
function: {
name: "ExitTool",
description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
Examples:
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
2. Task: "Whats the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
parameters: {
type: "object",
properties: {
response: {
type: "string",
description:
"Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
},
},
required: ["response"],
},
},
});
}
return request;
}
async transformResponseOut(response: Response): Promise<Response> {
if (response.headers.get("Content-Type")?.includes("application/json")) {
const jsonResponse = await response.json();
if (
jsonResponse?.choices[0]?.message.tool_calls?.length &&
jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
"ExitTool"
) {
const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
jsonResponse.choices[0].message.content = toolArguments.response || "";
delete jsonResponse.choices[0].message.tool_calls;
}
// Handle non-streaming response if needed
return new Response(JSON.stringify(jsonResponse), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} else if (response.headers.get("Content-Type")?.includes("stream")) {
// ...
}
return response;
}
}
```
This transformer ensures the model calls at least one tool. If no tools are appropriate or the task is finished, it can exit using `ExitTool`. Since this relies on the `tool_choice` parameter, it only works with models that support it.
In practice, this approach noticeably improves tool usage for DeepSeek. The tradeoff is that sometimes the model may invoke irrelevant or unnecessary tools, which could increase latency and token usage.
This update is just a small experiment — adding an `“agent”` to the router. Maybe there are more interesting things we can explore from here.

View File

@@ -0,0 +1,95 @@
# 或许我们能在 Router 中做更多事情
自从`claude-code-router`发布以来,我收到了很多用户的反馈,至今还有不少的 issues 未处理。其中大多都是关于不同的供应商的支持和`deepseek`模型调用工具不积极的问题。
之前开发这个项目主要是为了我自己能以较低成本使用上`claude code`,所以一开始的设计并没有考虑到多供应商的情况。在实际的排查问题中,我发现尽管市面上所有的供应商几乎都宣称兼容`OpenAI`格式调用,即通过`/chat/compeletions`接口调用,但是其中的细节差异非常多。例如:
1. Gemini 的工具参数类型是 string 时,`format`参数只支持`date``date-time`,并且没有工具调用 ID。
2. OpenRouter 需要使用`cache_control`进行缓存。
3. DeepSeek 官方 API 的 `max_output` 为 8192而火山引擎的会更大。
除了这些问题之外,还有一些其他的小的供应商,他们或多或少参数都有点问题。于是,我打算开发一个新的项目[musistudio/llms](https://github.com/musistudio/llms)来处理这种不同服务商的兼容问题。该项目使用 OpenAI 格式为基础的通用格式,提供了一个`Transformer`接口,该接口用于处理转换请求和响应。当我们给不同的服务商都实现了`Transformer`后,我们可以实现不同服务商的混合调用。比如我在`AnthropicTransformer`中实现了`Anthropic`<->`OpenAI`格式的互相转换,并监听了`/v1/messages`端点,在`GeminiTransformer`中实现了`Gemini`<->`OpenAI`格式的互相转换,并监听了`/v1beta/models/:modelAndAction`端点,当他们的请求和响应都被转换成一个通用格式的时候,就可以实现他们的互相调用。
```
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
```
```
GeminiReseponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
```
虽然使用中间层抹平差异可能会带来一些性能问题,但是该项目最初的目的是为了让`claude-code-router`支持不同的供应商。
至于`deepseek`模型调用工具不积极的问题,我发现这是由于`deepseek`在长上下文中的指令遵循不佳导致的。现象就是刚开始模型会主动调用工具,但是在经过几轮对话后模型只会返回文本。一开始的解决方案是通过注入一个系统提示词告知模型需要积极去使用工具以解决用户的问题,但是后面测试发现在长上下文中模型会遗忘该指令。
查看`deepseek`文档后发现模型支持`tool_choice`参数,可以强制让模型最少调用 1 个工具,我尝试将该值设置为`required`,发现模型调用工具的积极性大大增加,现在我们只需要在合适的时候取消这个参数即可。借助[musistudio/llms](https://github.com/musistudio/llms)的`Transformer`可以让我们在发送请求前和收到响应后做点什么,所以我参考`claude code``Plan Mode`,实现了一个使用与`deepseek``Tool Mode`
```typescript
export class TooluseTransformer implements Transformer {
name = "tooluse";
transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
if (request.tools?.length) {
request.messages.push({
role: "system",
content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task.
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
});
request.tool_choice = "required";
request.tools.unshift({
type: "function",
function: {
name: "ExitTool",
description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
Examples:
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
2. Task: "Whats the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
parameters: {
type: "object",
properties: {
response: {
type: "string",
description:
"Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
},
},
required: ["response"],
},
},
});
}
return request;
}
async transformResponseOut(response: Response): Promise<Response> {
if (response.headers.get("Content-Type")?.includes("application/json")) {
const jsonResponse = await response.json();
if (
jsonResponse?.choices[0]?.message.tool_calls?.length &&
jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
"ExitTool"
) {
const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
jsonResponse.choices[0].message.content = toolArguments.response || "";
delete jsonResponse.choices[0].message.tool_calls;
}
// Handle non-streaming response if needed
return new Response(JSON.stringify(jsonResponse), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} else if (response.headers.get("Content-Type")?.includes("stream")) {
// ...
}
return response;
}
}
```
该工具将始终让模型至少调用一个工具,如果没有合适的工具或者任务已完成可以调用`ExitTool`来退出工具模式,因为是依靠`tool_choice`参数实现的,所以仅适用于支持该参数的模型。经过测试,该工具能显著增加`deepseek`的工具调用次数,弊端是可能会有跟任务无关或者没有必要的工具调用导致增加任务执行事件和消耗的 `token` 数。
这次更新仅仅是在 Router 中实现一个`agent`的一次小探索,或许还能做更多其他有趣的事也说不定...

1728
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@musistudio/claude-code-router",
"version": "1.0.9",
"version": "1.0.12",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": {
"ccr": "./dist/cli.js"
@@ -18,17 +18,12 @@
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@musistudio/llms": "^1.0.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.1.0",
"openai": "^4.85.4",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"esbuild": "^0.25.1",
"typescript": "^5.8.2"
},

View File

@@ -1,33 +0,0 @@
module.exports = {
afterTransformRequest(req, res) {
if (Array.isArray(req.body.tools)) {
// rewrite tools definition
req.body.tools.forEach((tool) => {
if (tool.function.name === "BatchTool") {
// HACK: Gemini does not support objects with empty properties
tool.function.parameters.properties.invocations.items.properties.input.type =
"number";
return;
}
Object.keys(tool.function.parameters.properties).forEach((key) => {
const prop = tool.function.parameters.properties[key];
if (
prop.type === "string" &&
!["enum", "date-time"].includes(prop.format)
) {
delete prop.format;
}
});
});
}
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

@@ -1,12 +0,0 @@
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

@@ -1,10 +0,0 @@
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. `,
});
}
},
};

1598
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ export const REFERENCE_COUNT_FILE = '/tmp/claude-code-reference-count.txt';
export const DEFAULT_CONFIG = {
log: false,
LOG: false,
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "",
OPENAI_MODEL: "",

View File

@@ -1,23 +1,16 @@
import { existsSync } from "fs";
import { writeFile } from "fs/promises";
import { getOpenAICommonOptions, initConfig, initDir } from "./utils";
import { homedir } from "os";
import { join } from "path";
import { initConfig, initDir } from "./utils";
import { createServer } from "./server";
import { formatRequest } from "./middlewares/formatRequest";
import { router } from "./middlewares/router";
import OpenAI from "openai";
import { streamOpenAIResponse } from "./utils/stream";
import { router } from "./utils/router";
import {
cleanupPidFile,
isServiceRunning,
savePid,
} from "./utils/processCheck";
import { LRUCache } from "lru-cache";
import { log } from "./utils/log";
import {
loadPlugins,
PLUGINS,
usePluginMiddleware,
} from "./middlewares/plugin";
import { CONFIG_FILE } from "./constants";
async function initializeClaudeConfig() {
const homeDir = process.env.HOME;
@@ -43,14 +36,6 @@ 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()) {
@@ -61,57 +46,7 @@ async function run(options: RunOptions = {}) {
await initializeClaudeConfig();
await initDir();
const config = await initConfig();
await loadPlugins(config.usePlugins || []);
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
@@ -134,46 +69,23 @@ async function run(options: RunOptions = {}) {
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 {
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) {
log("Error in OpenAI API call:", e);
res.status(500).json({
error: e.message,
});
}
const server = createServer({
jsonPath: CONFIG_FILE,
initialConfig: {
// ...config,
providers: config.Providers || config.providers,
PORT: servicePort,
LOG_FILE: join(
homedir(),
".claude-code-router",
"claude-code-router.log"
),
},
});
server.addHook("preHandler", async (req, reply) =>
router(req, reply, config)
);
server.start();
console.log(`🚀 Claude Code Router is running on port ${servicePort}`);
}
export { run };

View File

@@ -1,189 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages";
import OpenAI from "openai";
import { log } from "../utils/log";
export const formatRequest = async (
req: Request,
res: Response,
next: NextFunction
) => {
let {
model,
max_tokens,
messages,
system = [],
temperature,
metadata,
tools,
stream,
}: MessageCreateParamsBase = req.body;
log("beforeTransformRequest: ", req.body);
try {
// @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) => ({
role: "system",
content: item.text,
}))
: [{ role: "system", content: system }];
const data: any = {
model,
messages: [...systemMessages, ...openAIMessages],
temperature,
stream,
};
if (tools) {
data.tools = tools
.filter((tool) => !["StickerRequest"].includes(tool.name))
.map((item: any) => ({
type: "function",
function: {
name: item.name,
description: item.description,
parameters: item.input_schema,
},
}));
}
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) {
log("Error in TransformRequest:", error);
}
next();
};

View File

@@ -1,106 +0,0 @@
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,23 +1,6 @@
import express, { RequestHandler } from "express";
import Server from "@musistudio/llms";
interface Server {
app: express.Application;
useMiddleware: (middleware: RequestHandler) => void;
start: () => void;
}
export const createServer = async (port: number): Promise<Server> => {
const app = express();
app.use(express.json({ limit: "500mb" }));
return {
app,
useMiddleware: (middleware: RequestHandler) => {
app.use("/v1/messages", middleware);
},
start: () => {
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
},
};
export const createServer = (config: any): Server => {
const server = new Server(config);
return server;
};

View File

@@ -9,13 +9,6 @@ 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",
@@ -29,7 +22,7 @@ export async function executeCodeCommand(args: string[] = []) {
const claudeProcess = spawn(claudePath, args, {
env,
stdio: "inherit",
shell: true
shell: true,
});
claudeProcess.on("error", (error) => {

View File

@@ -1,5 +1,3 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import OpenAI, { ClientOptions } from "openai";
import fs from "node:fs/promises";
import readline from "node:readline";
import {
@@ -8,17 +6,6 @@ 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;
}
const ensureDir = async (dir_path: string) => {
try {
@@ -64,9 +51,17 @@ export const readConfigFile = async () => {
const baseUrl = await question("Enter OPENAI_BASE_URL: ");
const model = await question("Enter OPENAI_MODEL: ");
const config = Object.assign({}, DEFAULT_CONFIG, {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: baseUrl,
OPENAI_MODEL: model,
Providers: [
{
name: "openai",
api_base_url: baseUrl,
api_key: apiKey,
models: [model],
},
],
Router: {
default: `openai,${model}`,
},
});
await writeConfigFile(config);
return config;
@@ -83,15 +78,3 @@ export const initConfig = async () => {
Object.assign(process.env, config);
return config;
};
export const createClient = (options: ClientOptions) => {
const client = new OpenAI({
...options,
...getOpenAICommonOptions(),
});
return client;
};
export const sha256 = (data: string | Buffer): string => {
return crypto.createHash("sha256").update(data).digest("hex");
};

View File

@@ -11,7 +11,6 @@ if (!fs.existsSync(HOME_DIR)) {
export function log(...args: any[]) {
// Check if logging is enabled via environment variable
// console.log(...args); // Log to console for immediate feedback
const isLogEnabled = process.env.LOG === "true";
if (!isLogEnabled) {

View File

@@ -1,59 +1,32 @@
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages";
import { Request, Response, NextFunction } from "express";
import { get_encoding } from "tiktoken";
import { log } from "../utils/log";
import { log } from "./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,
};
const getUseModel = (req: any, tokenCount: number, config: any) => {
if (req.body.model.includes(",")) {
return req.body.model;
}
// if tokenCount is greater than 32K, use the long context model
if (tokenCount > 1000 * 32) {
// if tokenCount is greater than 60K, use the long context model
if (tokenCount > 1000 * 60) {
log("Using long context model due to token count:", tokenCount);
const [provider, model] = req.config.Router!.longContext.split(",");
return {
provider,
model,
};
return config.Router!.longContext;
}
// 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,
};
return config.Router!.background;
}
// 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,
};
return config.Router!.think;
}
const [defaultProvider, defaultModel] =
req.config.Router!.default?.split(",");
return {
provider: defaultProvider || "default",
model: defaultModel || req.config.OPENAI_MODEL,
};
return config.Router!.default;
};
export const router = async (
req: Request,
res: Response,
next: NextFunction
) => {
export const router = async (req: any, res: any, config: any) => {
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
try {
let tokenCount = 0;
@@ -104,14 +77,11 @@ export const router = async (
}
});
}
const { provider, model } = getUseModel(req, tokenCount);
req.provider = provider;
const model = getUseModel(req, tokenCount, config);
req.body.model = model;
} catch (error) {
} catch (error: any) {
log("Error in router middleware:", error.message);
req.provider = "default";
req.body.model = req.config.OPENAI_MODEL;
} finally {
next();
req.body.model = config.Router!.default;
}
return;
};

View File

@@ -15,7 +15,7 @@ export function showStatus() {
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');
console.log(' ccr stop # Stop the service');
} else {
console.log('❌ Status: Not Running');
console.log('');

View File

@@ -1,468 +0,0 @@
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;
id?: string;
name?: string;
input?: any;
text?: string;
}
interface MessageEvent {
type: string;
message?: {
id: string;
type: string;
role: string;
content: any[];
model: string;
stop_reason: string | null;
stop_sequence: string | null;
usage: {
input_tokens: number;
output_tokens: number;
};
};
delta?: {
stop_reason?: string;
stop_sequence?: string | null;
content?: ContentBlock[];
type?: string;
text?: string;
partial_json?: string;
};
index?: number;
content_block?: ContentBlock;
usage?: {
input_tokens: number;
output_tokens: number;
};
}
export async function streamOpenAIResponse(
req: Request,
res: Response,
_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[] = [];
// Send message_start event
const messageStart: MessageEvent = {
type: "message_start",
message: {
id: messageId,
type: "message",
role: "assistant",
content: [],
model: req.body.model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
};
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) {
// 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`
);
}
// 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: toolCallId, // Use the original ID if available
name: toolCall.function?.name,
input: {},
};
const toolBlockStart: MessageEvent = {
type: "content_block_start",
index: contentBlockIndex,
content_block: toolBlock,
};
currentContentBlocks.push(toolBlock);
write(
`event: content_block_start\ndata: ${JSON.stringify(
toolBlockStart
)}\n\n`
);
}
// 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,
},
};
// 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(newJson);
const blockIndex = currentContentBlocks.findIndex(
(block) => block.type === "tool_use" && block.id === toolCallId
);
if (blockIndex !== -1) {
currentContentBlocks[blockIndex].input = parsedJson;
}
} catch (e) {
log("JSON parsing error (continuing to accumulate):", e);
}
}
write(
`event: content_block_delta\ndata: ${JSON.stringify(
jsonDelta
)}\n\n`
);
}
}
} 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,
};
write(
`event: content_block_stop\ndata: ${JSON.stringify(
contentBlockStop
)}\n\n`
);
contentBlockIndex++;
isToolUse = false;
currentToolCallId = null;
toolUseJson = ""; // Reset for safety
}
// If text block not yet started, send content_block_start
if (!hasStartedTextBlock) {
const textBlock: ContentBlock = {
type: "text",
text: "",
};
const textBlockStart: MessageEvent = {
type: "content_block_start",
index: contentBlockIndex,
content_block: textBlock,
};
currentContentBlocks.push(textBlock);
write(
`event: content_block_start\ndata: ${JSON.stringify(
textBlockStart
)}\n\n`
);
hasStartedTextBlock = true;
}
// Send regular text content
const contentDelta: MessageEvent = {
type: "content_block_delta",
index: contentBlockIndex,
delta: {
type: "text_delta",
text: delta.content,
},
};
// Update content block text
if (currentContentBlocks[contentBlockIndex]) {
currentContentBlocks[contentBlockIndex].text += delta.content;
}
write(
`event: content_block_delta\ndata: ${JSON.stringify(
contentDelta
)}\n\n`
);
}
}
} catch (e: any) {
// If text block not yet started, send content_block_start
if (!hasStartedTextBlock) {
const textBlock: ContentBlock = {
type: "text",
text: "",
};
const textBlockStart: MessageEvent = {
type: "content_block_start",
index: contentBlockIndex,
content_block: textBlock,
};
currentContentBlocks.push(textBlock);
write(
`event: content_block_start\ndata: ${JSON.stringify(
textBlockStart
)}\n\n`
);
hasStartedTextBlock = true;
}
// Send regular text content
const contentDelta: MessageEvent = {
type: "content_block_delta",
index: contentBlockIndex,
delta: {
type: "text_delta",
text: JSON.stringify(e),
},
};
// Update content block text
if (currentContentBlocks[contentBlockIndex]) {
currentContentBlocks[contentBlockIndex].text += JSON.stringify(e);
}
write(
`event: content_block_delta\ndata: ${JSON.stringify(contentDelta)}\n\n`
);
}
// Close last content block if any is open
if (isToolUse || hasStartedTextBlock) {
const contentBlockStop: MessageEvent = {
type: "content_block_stop",
index: contentBlockIndex,
};
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 = {
type: "message_delta",
delta: {
stop_reason: isToolUse ? "tool_use" : "end_turn",
stop_sequence: null,
content: res.locals.transformedCompletion,
},
usage: { input_tokens: 100, output_tokens: 150 },
};
if (!isToolUse) {
log("body: ", req.body, "messageDelta: ", messageDelta);
}
write(`event: message_delta\ndata: ${JSON.stringify(messageDelta)}\n\n`);
// Send message_stop event
const messageStop: MessageEvent = {
type: "message_stop",
};
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;
}