move to monorepo

This commit is contained in:
musistudio
2025-12-25 15:11:32 +08:00
parent a7e20325db
commit 6a20b2021d
107 changed files with 5308 additions and 1118 deletions

View File

@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
*.md
.vscode
.idea
*.log
.env
.DS_Store
coverage
.nyc_output

View File

@@ -0,0 +1,69 @@
# ===========================
# 构建阶段
# ===========================
FROM node:20-alpine AS builder
WORKDIR /app
# 安装 pnpm
RUN npm install -g pnpm@latest && \
rm -rf /root/.npm
# 复制工作区配置文件
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
COPY scripts ./scripts
COPY packages/shared/package.json ./packages/shared/
COPY packages/server/package.json ./packages/server/
# 安装所有依赖(包括开发依赖)并清理
RUN pnpm install --frozen-lockfile && \
pnpm store prune
# 复制源代码并构建
COPY packages/shared ./packages/shared
COPY packages/server ./packages/server
# 构建所有包
WORKDIR /app/packages/shared
RUN pnpm build
WORKDIR /app/packages/server
RUN pnpm build && \
rm -rf node_modules/.cache
# ===========================
# 生产阶段(极简版 - 无 node_modules
# ===========================
FROM node:20-alpine AS production
# 只安装 PM2、curl 和 pm2-logrotate并删除不需要的 npm 和 corepack
RUN apk add --no-cache curl && \
npm install -g pm2 pm2-logrotate --no-scripts && \
pm2 install pm2-logrotate && \
pm2 set pm2-logrotate:max_size 100M && \
pm2 set pm2-logrotate:retain 5 && \
pm2 set pm2-logrotate:compress true && \
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'
WORKDIR /app
# 从构建阶段复制 server bundleshared 已被打包进 index.js无需单独复制
COPY --from=builder /app/packages/server/dist ./packages/server/dist
# 复制本地预先构建的 UI 产物到同一目录
COPY packages/ui/dist/. ./packages/server/dist/
# 复制 PM2 配置文件
COPY packages/server/ecosystem.config.cjs /app/
# 创建日志目录
RUN mkdir -p /root/.claude-code-router/logs
# 暴露端口
EXPOSE 3456
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://127.0.0.1:3456/health || exit 1
# 直接启动应用
CMD ["pm2-runtime", "start", "/app/ecosystem.config.cjs"]

View File

@@ -0,0 +1,23 @@
module.exports = {
apps: [
{
name: 'claude-code-router-server',
script: '/app/packages/server/dist/index.js',
cwd: '/app/packages/server',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
},
// 日志配置
error_file: '/root/.claude-code-router/logs/error.log',
out_file: '/root/.claude-code-router/logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
// 启用日志时间戳
time: true,
},
],
};

View File

@@ -0,0 +1,37 @@
{
"name": "@musistudio/claude-code-router-server",
"version": "1.0.73",
"description": "Server for Claude Code Router",
"main": "dist/index.js",
"scripts": {
"build": "node ../../scripts/build-server.js",
"dev": "ts-node src/index.ts"
},
"keywords": [
"claude",
"code",
"router",
"server"
],
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@musistudio/claude-code-router-shared": "workspace:*",
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.51",
"dotenv": "^16.4.7",
"json5": "^2.2.3",
"lru-cache": "^11.2.2",
"rotating-file-stream": "^3.2.7",
"shell-quote": "^1.8.3",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"fastify": "^5.4.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}
}

View File

@@ -0,0 +1,304 @@
import { IAgent, ITool } from "./type";
import { createHash } from "crypto";
import * as LRU from "lru-cache";
interface ImageCacheEntry {
source: any;
timestamp: number;
}
class ImageCache {
private cache: any;
constructor(maxSize = 100) {
const CacheClass: any = (LRU as any).LRUCache || (LRU as any);
this.cache = new CacheClass({
max: maxSize,
ttl: 5 * 60 * 1000, // 5 minutes
});
}
storeImage(id: string, source: any): void {
if (this.hasImage(id)) return;
this.cache.set(id, {
source,
timestamp: Date.now(),
});
}
getImage(id: string): any {
const entry = this.cache.get(id);
return entry ? entry.source : null;
}
hasImage(hash: string): boolean {
return this.cache.has(hash);
}
clear(): void {
this.cache.clear();
}
size(): number {
return this.cache.size;
}
}
const imageCache = new ImageCache();
export class ImageAgent implements IAgent {
name = "image";
tools: Map<string, ITool>;
constructor() {
this.tools = new Map<string, ITool>();
this.appendTools();
}
shouldHandle(req: any, config: any): boolean {
if (!config.Router.image || req.body.model === config.Router.image)
return false;
const lastMessage = req.body.messages[req.body.messages.length - 1];
if (
!config.forceUseImageAgent &&
lastMessage.role === "user" &&
Array.isArray(lastMessage.content) &&
lastMessage.content.find(
(item: any) =>
item.type === "image" ||
(Array.isArray(item?.content) &&
item.content.some((sub: any) => sub.type === "image"))
)
) {
req.body.model = config.Router.image;
const images: any[] = [];
lastMessage.content
.filter((item: any) => item.type === "tool_result")
.forEach((item: any) => {
if (Array.isArray(item.content)) {
item.content.forEach((element: any) => {
if (element.type === "image") {
images.push(element);
}
});
item.content = "read image successfully";
}
});
lastMessage.content.push(...images);
return false;
}
return req.body.messages.some(
(msg: any) =>
msg.role === "user" &&
Array.isArray(msg.content) &&
msg.content.some(
(item: any) =>
item.type === "image" ||
(Array.isArray(item?.content) &&
item.content.some((sub: any) => sub.type === "image"))
)
);
}
appendTools() {
this.tools.set("analyzeImage", {
name: "analyzeImage",
description:
"Analyse image or images by ID and extract information such as OCR text, objects, layout, colors, or safety signals.",
input_schema: {
type: "object",
properties: {
imageId: {
type: "array",
description: "an array of IDs to analyse",
items: {
type: "string",
},
},
task: {
type: "string",
description:
"Details of task to perform on the image.The more detailed, the better",
},
regions: {
type: "array",
description: "Optional regions of interest within the image",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Optional label for the region",
},
x: { type: "number", description: "X coordinate" },
y: { type: "number", description: "Y coordinate" },
w: { type: "number", description: "Width of the region" },
h: { type: "number", description: "Height of the region" },
units: {
type: "string",
enum: ["px", "pct"],
description: "Units for coordinates and size",
},
},
required: ["x", "y", "w", "h", "units"],
},
},
},
required: ["imageId", "task"],
},
handler: async (args, context) => {
const imageMessages = [];
let imageId;
// Create image messages from cached images
if (args.imageId) {
if (Array.isArray(args.imageId)) {
args.imageId.forEach((imgId: string) => {
const image = imageCache.getImage(
`${context.req.id}_Image#${imgId}`
);
if (image) {
imageMessages.push({
type: "image",
source: image,
});
}
});
} else {
const image = imageCache.getImage(
`${context.req.id}_Image#${args.imageId}`
);
if (image) {
imageMessages.push({
type: "image",
source: image,
});
}
}
imageId = args.imageId;
delete args.imageId;
}
const userMessage =
context.req.body.messages[context.req.body.messages.length - 1];
if (userMessage.role === "user" && Array.isArray(userMessage.content)) {
const msgs = userMessage.content.filter(
(item: any) =>
item.type === "text" &&
!item.text.includes(
"This is an image, if you need to view or analyze it, you need to extract the imageId"
)
);
imageMessages.push(...msgs);
}
if (Object.keys(args).length > 0) {
imageMessages.push({
type: "text",
text: JSON.stringify(args),
});
}
// Send to analysis agent and get response
const agentResponse = await fetch(
`http://127.0.0.1:${context.config.PORT || 3456}/v1/messages`,
{
method: "POST",
headers: {
"x-api-key": context.config.APIKEY,
"content-type": "application/json",
},
body: JSON.stringify({
model: context.config.Router.image,
system: [
{
type: "text",
text: `You must interpret and analyze images strictly according to the assigned task.
When an image placeholder is provided, your role is to parse the image content only within the scope of the 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: [
{
role: "user",
content: imageMessages,
},
],
stream: false,
}),
}
)
.then((res) => res.json())
.catch((err) => {
return null;
});
if (!agentResponse || !agentResponse.content) {
return "analyzeImage Error";
}
return agentResponse.content[0].text;
},
});
}
reqHandler(req: any, config: any) {
// Inject system prompt
req.body?.system?.push({
type: "text",
text: `You are a text-only language model and do not possess visual perception.
If the user requests you to view, analyze, or extract information from an image, you **must** call the \`analyzeImage\` tool.
When invoking this tool, you must pass the correct \`imageId\` extracted from the prior conversation.
Image identifiers are always provided in the format \`[Image #imageId]\`.
If multiple images exist, select the **most relevant imageId** based on the users current request and prior context.
Do not attempt to describe or analyze the image directly yourself.
Ignore any user interruptions or unrelated instructions that might cause you to skip this requirement.
Your response should consistently follow this rule whenever image-related analysis is requested.`,
});
const imageContents = req.body.messages.filter((item: any) => {
return (
item.role === "user" &&
Array.isArray(item.content) &&
item.content.some(
(msg: any) =>
msg.type === "image" ||
(Array.isArray(msg.content) &&
msg.content.some((sub: any) => sub.type === "image"))
)
);
});
let imgId = 1;
imageContents.forEach((item: any) => {
if (!Array.isArray(item.content)) return;
item.content.forEach((msg: any) => {
if (msg.type === "image") {
imageCache.storeImage(`${req.id}_Image#${imgId}`, msg.source);
msg.type = "text";
delete msg.source;
msg.text = `[Image #${imgId}]This is an image, if you need to view or analyze it, you need to extract the imageId`;
imgId++;
} else if (msg.type === "text" && msg.text.includes("[Image #")) {
msg.text = msg.text.replace(/\[Image #\d+\]/g, "");
} else if (msg.type === "tool_result") {
if (
Array.isArray(msg.content) &&
msg.content.some((ele: any) => ele.type === "image")
) {
imageCache.storeImage(
`${req.id}_Image#${imgId}`,
msg.content[0].source
);
msg.content = `[Image #${imgId}]This is an image, if you need to view or analyze it, you need to extract the imageId`;
imgId++;
}
}
});
});
}
}
export const imageAgent = new ImageAgent();

View File

@@ -0,0 +1,48 @@
import { imageAgent } from './image.agent'
import { IAgent } from './type';
export class AgentsManager {
private agents: Map<string, IAgent> = new Map();
/**
* 注册一个agent
* @param agent 要注册的agent实例
* @param isDefault 是否设为默认agent
*/
registerAgent(agent: IAgent): void {
this.agents.set(agent.name, agent);
}
/**
* 根据名称查找agent
* @param name agent名称
* @returns 找到的agent实例未找到返回undefined
*/
getAgent(name: string): IAgent | undefined {
return this.agents.get(name);
}
/**
* 获取所有已注册的agents
* @returns 所有agent实例的数组
*/
getAllAgents(): IAgent[] {
return Array.from(this.agents.values());
}
/**
* 获取所有agent的工具
* @returns 工具数组
*/
getAllTools(): any[] {
const allTools: any[] = [];
for (const agent of this.agents.values()) {
allTools.push(...agent.tools.values());
}
return allTools;
}
}
const agentsManager = new AgentsManager()
agentsManager.registerAgent(imageAgent)
export default agentsManager

View File

@@ -0,0 +1,19 @@
export interface ITool {
name: string;
description: string;
input_schema: any;
handler: (args: any, context: any) => Promise<string>;
}
export interface IAgent {
name: string;
tools: Map<string, ITool>;
shouldHandle: (req: any, config: any) => boolean;
reqHandler: (req: any, config: any) => void;
resHandler?: (payload: any, config: any) => void;
}

View File

@@ -0,0 +1,429 @@
import { existsSync, writeFileSync, unlinkSync } from "fs";
import { writeFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { initConfig, initDir } from "./utils";
import { createServer } from "./server";
import { router } from "./utils/router";
import { apiKeyAuth } from "./middleware/auth";
import { PID_FILE, CONFIG_FILE, HOME_DIR } from "@musistudio/claude-code-router-shared";
import { createStream } from 'rotating-file-stream';
import { sessionUsageCache } from "./utils/cache";
import {SSEParserTransform} from "./utils/SSEParser.transform";
import {SSESerializerTransform} from "./utils/SSESerializer.transform";
import {rewriteStream} from "./utils/rewriteStream";
import JSON5 from "json5";
import { IAgent } from "./agents/type";
import agentsManager from "./agents";
import { EventEmitter } from "node:events";
const event = new EventEmitter()
async function initializeClaudeConfig() {
const homeDir = homedir();
const configPath = join(homeDir, ".claude.json");
if (!existsSync(configPath)) {
const userID = Array.from(
{ length: 64 },
() => Math.random().toString(16)[2]
).join("");
const configContent = {
numStartups: 184,
autoUpdaterStatus: "enabled",
userID,
hasCompletedOnboarding: true,
lastOnboardingVersion: "1.0.17",
projects: {},
};
await writeFile(configPath, JSON.stringify(configContent, null, 2));
}
}
interface RunOptions {
port?: number;
logger?: any;
}
async function run(options: RunOptions = {}) {
// Check if service is already running
const isRunning = existsSync(PID_FILE);
if (isRunning) {
console.log("✅ Service is already running in the background.");
return;
}
await initializeClaudeConfig();
await initDir();
const config = await initConfig();
// Check if Providers is configured
const providers = config.Providers || config.providers || [];
const hasProviders = providers && providers.length > 0;
let HOST = config.HOST || "127.0.0.1";
if (hasProviders) {
// When providers are configured, require both HOST and APIKEY
if (!config.HOST || !config.APIKEY) {
console.error("❌ Both HOST and APIKEY must be configured when Providers are set.");
console.error(" Please add HOST and APIKEY to your config file.");
process.exit(1);
}
HOST = config.HOST;
} else {
// When no providers are configured, listen on 0.0.0.0 without authentication
HOST = "0.0.0.0";
console.log(" No providers configured. Listening on 0.0.0.0 without authentication.");
}
const port = config.PORT || 3456;
// Save the PID of the background process
writeFileSync(PID_FILE, process.pid.toString());
// Handle SIGINT (Ctrl+C) to clean up PID file
process.on("SIGINT", () => {
console.log("Received SIGINT, cleaning up...");
if (existsSync(PID_FILE)) {
try {
unlinkSync(PID_FILE);
} catch (e) {
// Ignore cleanup errors
}
}
process.exit(0);
});
// Handle SIGTERM to clean up PID file
process.on("SIGTERM", () => {
if (existsSync(PID_FILE)) {
try {
const fs = require('fs');
fs.unlinkSync(PID_FILE);
} catch (e) {
// Ignore cleanup errors
}
}
process.exit(0);
});
// Use port from environment variable if set (for background process)
const servicePort = process.env.SERVICE_PORT
? parseInt(process.env.SERVICE_PORT)
: port;
// Configure logger based on config settings or external options
const pad = (num: number) => (num > 9 ? "" : "0") + num;
const generator = (time: number | Date | undefined, index: number | undefined) => {
let date: Date;
if (!time) {
date = new Date();
} else if (typeof time === 'number') {
date = new Date(time);
} else {
date = time;
}
const month = date.getFullYear() + "" + pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hour = pad(date.getHours());
const minute = pad(date.getMinutes());
return `./logs/ccr-${month}${day}${hour}${minute}${pad(date.getSeconds())}${index ? `_${index}` : ''}.log`;
};
let loggerConfig: any;
// 如果外部传入了 logger 配置,使用外部的
if (options.logger !== undefined) {
loggerConfig = options.logger;
} else {
// 如果没有传入,并且 config.LOG !== false则启用 logger
if (config.LOG !== false) {
// 将 config.LOG 设为 true如果它还未设置
if (config.LOG === undefined) {
config.LOG = true;
}
loggerConfig = {
level: config.LOG_LEVEL || "debug",
stream: createStream(generator, {
path: HOME_DIR,
maxFiles: 3,
interval: "1d",
compress: false,
maxSize: "50M"
}),
};
} else {
loggerConfig = false;
}
}
const server = createServer({
jsonPath: CONFIG_FILE,
initialConfig: {
// ...config,
providers: config.Providers || config.providers,
HOST: HOST,
PORT: servicePort,
LOG_FILE: join(
homedir(),
".claude-code-router",
"claude-code-router.log"
),
},
logger: loggerConfig,
});
// Add global error handlers to prevent the service from crashing
process.on("uncaughtException", (err) => {
server.logger.error("Uncaught exception:", err);
});
process.on("unhandledRejection", (reason, promise) => {
server.logger.error("Unhandled rejection at:", promise, "reason:", reason);
});
// Add async preHandler hook for authentication
server.addHook("preHandler", async (req: any, reply: any) => {
return new Promise<void>((resolve, reject) => {
const done = (err?: Error) => {
if (err) reject(err);
else resolve();
};
// Call the async auth function
apiKeyAuth(config)(req, reply, done).catch(reject);
});
});
server.addHook("preHandler", async (req: any, reply: any) => {
if (req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
const useAgents = []
for (const agent of agentsManager.getAllAgents()) {
if (agent.shouldHandle(req, config)) {
// 设置agent标识
useAgents.push(agent.name)
// change request body
agent.reqHandler(req, config);
// append agent tools
if (agent.tools.size) {
if (!req.body?.tools?.length) {
req.body.tools = []
}
req.body.tools.unshift(...Array.from(agent.tools.values()).map(item => {
return {
name: item.name,
description: item.description,
input_schema: item.input_schema
}
}))
}
}
}
if (useAgents.length) {
req.agents = useAgents;
}
await router(req, reply, {
config,
event
});
}
});
server.addHook("onError", async (request: any, reply: any, error: any) => {
event.emit('onError', request, reply, error);
})
server.addHook("onSend", (req: any, reply: any, payload: any, done: any) => {
if (req.sessionId && req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
if (payload instanceof ReadableStream) {
if (req.agents) {
const abortController = new AbortController();
const eventStream = payload.pipeThrough(new SSEParserTransform())
let currentAgent: undefined | IAgent;
let currentToolIndex = -1
let currentToolName = ''
let currentToolArgs = ''
let currentToolId = ''
const toolMessages: any[] = []
const assistantMessages: any[] = []
// 存储Anthropic格式的消息体区分文本和工具类型
return done(null, rewriteStream(eventStream, async (data, controller) => {
try {
// 检测工具调用开始
if (data.event === 'content_block_start' && data?.data?.content_block?.name) {
const agent = req.agents.find((name: string) => agentsManager.getAgent(name)?.tools.get(data.data.content_block.name))
if (agent) {
currentAgent = agentsManager.getAgent(agent)
currentToolIndex = data.data.index
currentToolName = data.data.content_block.name
currentToolId = data.data.content_block.id
return undefined;
}
}
// 收集工具参数
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data?.delta?.type === 'input_json_delta') {
currentToolArgs += data.data?.delta?.partial_json;
return undefined;
}
// 工具调用完成处理agent调用
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data.type === 'content_block_stop') {
try {
const args = JSON5.parse(currentToolArgs);
assistantMessages.push({
type: "tool_use",
id: currentToolId,
name: currentToolName,
input: args
})
const toolResult = await currentAgent?.tools.get(currentToolName)?.handler(args, {
req,
config
});
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 || 3456}/v1/messages`, {
method: "POST",
headers: {
'x-api-key': config.APIKEY,
'content-type': 'application/json',
},
body: JSON.stringify(req.body),
})
if (!response.ok) {
return undefined;
}
const stream = response.body!.pipeThrough(new SSEParserTransform() as any)
const reader = stream.getReader()
while (true) {
try {
const {value, done} = await reader.read();
if (done) {
break;
}
const eventData = value as any;
if (['message_start', 'message_stop'].includes(eventData.event)) {
continue
}
// 检查流是否仍然可写
if (!controller.desiredSize) {
break;
}
controller.enqueue(eventData)
}catch (readError: any) {
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
abortController.abort(); // 中止所有相关操作
break;
}
throw readError;
}
}
return undefined
}
return data
}catch (error: any) {
console.error('Unexpected error in stream processing:', error);
// 处理流提前关闭的错误
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
abortController.abort();
return undefined;
}
// 其他错误仍然抛出
throw error;
}
}).pipeThrough(new SSESerializerTransform()))
}
const [originalStream, clonedStream] = payload.tee();
const read = async (stream: ReadableStream) => {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Process the value if needed
const dataStr = new TextDecoder().decode(value);
if (!dataStr.startsWith("event: message_delta")) {
continue;
}
const str = dataStr.slice(27);
try {
const message = JSON.parse(str);
sessionUsageCache.put(req.sessionId, message.usage);
} catch {}
}
} catch (readError: any) {
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.error('Background read stream closed prematurely');
} else {
console.error('Error in background stream reading:', readError);
}
} finally {
reader.releaseLock();
}
}
read(clonedStream);
return done(null, originalStream)
}
sessionUsageCache.put(req.sessionId, payload.usage);
if (typeof payload ==='object') {
if (payload.error) {
return done(payload.error, null)
} else {
return done(payload, null)
}
}
}
if (typeof payload ==='object' && payload.error) {
return done(payload.error, null)
}
done(null, payload)
});
server.addHook("onSend", async (req: any, reply: any, payload: any) => {
event.emit('onSend', req, reply, payload);
return payload;
})
server.start();
}
export { run };
// 如果是直接运行此文件,则启动服务
if (require.main === module) {
run().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
}

View File

@@ -0,0 +1,57 @@
import { FastifyRequest, FastifyReply } from "fastify";
export const apiKeyAuth =
(config: any) =>
async (req: FastifyRequest, reply: FastifyReply, done: () => void) => {
// Public endpoints that don't require authentication
if (["/", "/health"].includes(req.url) || req.url.startsWith("/ui")) {
return done();
}
// Check if Providers is empty or not configured
const providers = config.Providers || config.providers || [];
if (!providers || providers.length === 0) {
// No providers configured, skip authentication
return done();
}
const apiKey = config.APIKEY;
if (!apiKey) {
// If no API key is set, enable CORS for local
const allowedOrigins = [
`http://127.0.0.1:${config.PORT || 3456}`,
`http://localhost:${config.PORT || 3456}`,
];
if (req.headers.origin && !allowedOrigins.includes(req.headers.origin)) {
reply.status(403).send("CORS not allowed for this origin");
return;
} else {
reply.header('Access-Control-Allow-Origin', `http://127.0.0.1:${config.PORT || 3456}`);
reply.header('Access-Control-Allow-Origin', `http://localhost:${config.PORT || 3456}`);
}
return done();
}
const authHeaderValue =
req.headers.authorization || req.headers["x-api-key"];
const authKey: string = Array.isArray(authHeaderValue)
? authHeaderValue[0]
: authHeaderValue || "";
if (!authKey) {
reply.status(401).send("APIKEY is missing");
return;
}
let token = "";
if (authKey.startsWith("Bearer")) {
token = authKey.split(" ")[1];
} else {
token = authKey;
}
if (token !== apiKey) {
reply.status(401).send("Invalid API key");
return;
}
done();
};

View File

@@ -0,0 +1,163 @@
import Server from "@musistudio/llms";
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { join } from "path";
import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { homedir } from "os";
import {calculateTokenCount} from "./utils/router";
export const createServer = (config: any): any => {
const server = new Server(config);
server.app.post("/v1/messages/count_tokens", async (req: any, reply: any) => {
const {messages, tools, system} = req.body;
const tokenCount = calculateTokenCount(messages, system, tools);
return { "input_tokens": tokenCount }
});
// Add endpoint to read config.json with access control
server.app.get("/api/config", async (req: any, reply: any) => {
return await readConfigFile();
});
server.app.get("/api/transformers", async (req: any, reply: any) => {
const transformers =
(server.app as any)._server!.transformerService.getAllTransformers();
const transformerList = Array.from(transformers.entries()).map(
([name, transformer]: any) => ({
name,
endpoint: transformer.endPoint || null,
})
);
return { transformers: transformerList };
});
// Add endpoint to save config.json with access control
server.app.post("/api/config", async (req: any, reply: any) => {
const newConfig = req.body;
// Backup existing config file if it exists
const backupPath = await backupConfigFile();
if (backupPath) {
console.log(`Backed up existing configuration file to ${backupPath}`);
}
await writeConfigFile(newConfig);
return { success: true, message: "Config saved successfully" };
});
// Add endpoint to restart the service with access control
server.app.post("/api/restart", async (req: any, reply: any) => {
reply.send({ success: true, message: "Service restart initiated" });
// Restart the service after a short delay to allow response to be sent
setTimeout(() => {
const { spawn } = require("child_process");
spawn(process.execPath, [process.argv[1], "restart"], {
detached: true,
stdio: "ignore",
});
}, 1000);
});
// Register static file serving with caching
server.app.register(fastifyStatic, {
root: join(__dirname, "..", "dist"),
prefix: "/ui/",
maxAge: "1h",
});
// Redirect /ui to /ui/ for proper static file serving
server.app.get("/ui", async (_: any, reply: any) => {
return reply.redirect("/ui/");
});
// 获取日志文件列表端点
server.app.get("/api/logs/files", async (req: any, reply: any) => {
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: any, reply: any) => {
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: any, reply: any) => {
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;
};

21
packages/server/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module "@musistudio/llms" {
import { FastifyInstance } from "fastify";
export interface ServerConfig {
jsonPath?: string;
initialConfig?: any;
logger?: any;
}
export interface Server {
app: FastifyInstance;
logger: any;
start(): Promise<void>;
}
const Server: {
new (config: ServerConfig): Server;
};
export default Server;
}

View File

@@ -0,0 +1,71 @@
export class SSEParserTransform extends TransformStream<string, any> {
private buffer = '';
private currentEvent: Record<string, any> = {};
constructor() {
super({
transform: (chunk: string, controller) => {
this.buffer += chunk;
const lines = this.buffer.split('\n');
// 保留最后一行(可能不完整)
this.buffer = lines.pop() || '';
for (const line of lines) {
const event = this.processLine(line);
if (event) {
controller.enqueue(event);
}
}
},
flush: (controller) => {
// 处理缓冲区中剩余的内容
if (this.buffer.trim()) {
const events: any[] = [];
this.processLine(this.buffer.trim(), events);
events.forEach(event => controller.enqueue(event));
}
// 推送最后一个事件(如果有)
if (Object.keys(this.currentEvent).length > 0) {
controller.enqueue(this.currentEvent);
}
}
});
}
private processLine(line: string, events?: any[]): any | null {
if (!line.trim()) {
if (Object.keys(this.currentEvent).length > 0) {
const event = { ...this.currentEvent };
this.currentEvent = {};
if (events) {
events.push(event);
return null;
}
return event;
}
return null;
}
if (line.startsWith('event:')) {
this.currentEvent.event = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const data = line.slice(5).trim();
if (data === '[DONE]') {
this.currentEvent.data = { type: 'done' };
} else {
try {
this.currentEvent.data = JSON.parse(data);
} catch (e) {
this.currentEvent.data = { raw: data, error: 'JSON parse failed' };
}
}
} else if (line.startsWith('id:')) {
this.currentEvent.id = line.slice(3).trim();
} else if (line.startsWith('retry:')) {
this.currentEvent.retry = parseInt(line.slice(6).trim());
}
return null;
}
}

View File

@@ -0,0 +1,29 @@
export class SSESerializerTransform extends TransformStream<any, string> {
constructor() {
super({
transform: (event, controller) => {
let output = '';
if (event.event) {
output += `event: ${event.event}\n`;
}
if (event.id) {
output += `id: ${event.id}\n`;
}
if (event.retry) {
output += `retry: ${event.retry}\n`;
}
if (event.data) {
if (event.data.type === 'done') {
output += 'data: [DONE]\n';
} else {
output += `data: ${JSON.stringify(event.data)}\n`;
}
}
output += '\n';
controller.enqueue(output);
}
});
}
}

View File

@@ -0,0 +1,47 @@
// LRU cache for session usage
export interface Usage {
input_tokens: number;
output_tokens: number;
}
class LRUCache<K, V> {
private capacity: number;
private cache: Map<K, V>;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map<K, V>();
}
get(key: K): V | undefined {
if (!this.cache.has(key)) {
return undefined;
}
const value = this.cache.get(key) as V;
// Move to end to mark as recently used
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key: K, value: V): void {
if (this.cache.has(key)) {
// If key exists, delete it to update its position
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// If cache is full, delete the least recently used item
const leastRecentlyUsedKey = this.cache.keys().next().value;
if (leastRecentlyUsedKey !== undefined) {
this.cache.delete(leastRecentlyUsedKey);
}
}
this.cache.set(key, value);
}
values(): V[] {
return Array.from(this.cache.values());
}
}
export const sessionUsageCache = new LRUCache<string, Usage>(100);

View File

@@ -0,0 +1,173 @@
import fs from "node:fs/promises";
import readline from "node:readline";
import JSON5 from "json5";
import path from "node:path";
import {
CONFIG_FILE,
DEFAULT_CONFIG,
HOME_DIR,
PLUGINS_DIR,
} from "@musistudio/claude-code-router-shared";
// Function to interpolate environment variables in config values
const interpolateEnvVars = (obj: any): any => {
if (typeof obj === "string") {
// Replace $VAR_NAME or ${VAR_NAME} with environment variable values
return obj.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => {
const varName = braced || unbraced;
return process.env[varName] || match; // Keep original if env var doesn't exist
});
} else if (Array.isArray(obj)) {
return obj.map(interpolateEnvVars);
} else if (obj !== null && typeof obj === "object") {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = interpolateEnvVars(value);
}
return result;
}
return obj;
};
const ensureDir = async (dir_path: string) => {
try {
await fs.access(dir_path);
} catch {
await fs.mkdir(dir_path, { recursive: true });
}
};
export const initDir = async () => {
await ensureDir(HOME_DIR);
await ensureDir(PLUGINS_DIR);
await ensureDir(path.join(HOME_DIR, "logs"));
};
const createReadline = () => {
return readline.createInterface({
input: process.stdin,
output: process.stdout,
});
};
const question = (query: string): Promise<string> => {
return new Promise((resolve) => {
const rl = createReadline();
rl.question(query, (answer) => {
rl.close();
resolve(answer);
});
});
};
const confirm = async (query: string): Promise<boolean> => {
const answer = await question(query);
return answer.toLowerCase() !== "n";
};
export const readConfigFile = async () => {
try {
const config = await fs.readFile(CONFIG_FILE, "utf-8");
try {
// Try to parse with JSON5 first (which also supports standard JSON)
const parsedConfig = JSON5.parse(config);
// Interpolate environment variables in the parsed config
return interpolateEnvVars(parsedConfig);
} catch (parseError) {
console.error(`Failed to parse config file at ${CONFIG_FILE}`);
console.error("Error details:", (parseError as Error).message);
console.error("Please check your config file syntax.");
process.exit(1);
}
} catch (readError: any) {
if (readError.code === "ENOENT") {
// Config file doesn't exist, prompt user for initial setup
try {
// Initialize directories
await initDir();
// Backup existing config file if it exists
const backupPath = await backupConfigFile();
if (backupPath) {
console.log(
`Backed up existing configuration file to ${backupPath}`
);
}
const config = {
PORT: 3456,
Providers: [],
Router: {},
}
// Create a minimal default config file
await writeConfigFile(config);
console.log(
"Created minimal default configuration file at ~/.claude-code-router/config.json"
);
console.log(
"Please edit this file with your actual configuration."
);
return config
} catch (error: any) {
console.error(
"Failed to create default configuration:",
error.message
);
process.exit(1);
}
} else {
console.error(`Failed to read config file at ${CONFIG_FILE}`);
console.error("Error details:", readError.message);
process.exit(1);
}
}
};
export const backupConfigFile = async () => {
try {
if (await fs.access(CONFIG_FILE).then(() => true).catch(() => false)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${CONFIG_FILE}.${timestamp}.bak`;
await fs.copyFile(CONFIG_FILE, backupPath);
// Clean up old backups, keeping only the 3 most recent
try {
const configDir = path.dirname(CONFIG_FILE);
const configFileName = path.basename(CONFIG_FILE);
const files = await fs.readdir(configDir);
// Find all backup files for this config
const backupFiles = files
.filter(file => file.startsWith(configFileName) && file.endsWith('.bak'))
.sort()
.reverse(); // Sort in descending order (newest first)
// Delete all but the 3 most recent backups
if (backupFiles.length > 3) {
for (let i = 3; i < backupFiles.length; i++) {
const oldBackupPath = path.join(configDir, backupFiles[i]);
await fs.unlink(oldBackupPath);
}
}
} catch (cleanupError) {
console.warn("Failed to clean up old backups:", cleanupError);
}
return backupPath;
}
} catch (error) {
console.error("Failed to backup config file:", error);
}
return null;
};
export const writeConfigFile = async (config: any) => {
await ensureDir(HOME_DIR);
const configWithComment = `${JSON.stringify(config, null, 2)}`;
await fs.writeFile(CONFIG_FILE, configWithComment);
};
export const initConfig = async () => {
const config = await readConfigFile();
Object.assign(process.env, config);
return config;
};

View File

@@ -0,0 +1,31 @@
/**rewriteStream
* 读取源readablestream返回一个新的readablestream由processor对源数据进行处理后将返回的新值推送到新的stream如果没有返回值则不推送
* @param stream
* @param processor
*/
export const rewriteStream = (stream: ReadableStream, processor: (data: any, controller: ReadableStreamController<any>) => Promise<any>): ReadableStream => {
const reader = stream.getReader()
return new ReadableStream({
async start(controller) {
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.close()
break
}
const processed = await processor(value, controller)
if (processed !== undefined) {
controller.enqueue(processed)
}
}
} catch (error) {
controller.error(error)
} finally {
reader.releaseLock()
}
}
})
}

View File

@@ -0,0 +1,317 @@
import { get_encoding } from "tiktoken";
import { sessionUsageCache, Usage } from "./cache";
import { readFile, access } from "fs/promises";
import { opendir, stat } from "fs/promises";
import { join } from "path";
import { CLAUDE_PROJECTS_DIR, HOME_DIR } from "@musistudio/claude-code-router-shared";
import { LRUCache } from "lru-cache";
// Types from @anthropic-ai/sdk
interface Tool {
name: string;
description?: string;
input_schema: object;
}
interface ContentBlockParam {
type: string;
[key: string]: any;
}
interface MessageParam {
role: string;
content: string | ContentBlockParam[];
}
interface MessageCreateParamsBase {
messages?: MessageParam[];
system?: string | any[];
tools?: Tool[];
[key: string]: any;
}
const enc = get_encoding("cl100k_base");
export const calculateTokenCount = (
messages: MessageParam[],
system: any,
tools: Tool[]
) => {
let tokenCount = 0;
if (Array.isArray(messages)) {
messages.forEach((message) => {
if (typeof message.content === "string") {
tokenCount += enc.encode(message.content).length;
} else if (Array.isArray(message.content)) {
message.content.forEach((contentPart: any) => {
if (contentPart.type === "text") {
tokenCount += enc.encode(contentPart.text).length;
} else if (contentPart.type === "tool_use") {
tokenCount += enc.encode(JSON.stringify(contentPart.input)).length;
} else if (contentPart.type === "tool_result") {
tokenCount += enc.encode(
typeof contentPart.content === "string"
? contentPart.content
: JSON.stringify(contentPart.content)
).length;
}
});
}
});
}
if (typeof system === "string") {
tokenCount += enc.encode(system).length;
} else if (Array.isArray(system)) {
system.forEach((item: any) => {
if (item.type !== "text") return;
if (typeof item.text === "string") {
tokenCount += enc.encode(item.text).length;
} else if (Array.isArray(item.text)) {
item.text.forEach((textPart: any) => {
tokenCount += enc.encode(textPart || "").length;
});
}
});
}
if (tools) {
tools.forEach((tool: Tool) => {
if (tool.description) {
tokenCount += enc.encode(tool.name + tool.description).length;
}
if (tool.input_schema) {
tokenCount += enc.encode(JSON.stringify(tool.input_schema)).length;
}
});
}
return tokenCount;
};
const readConfigFile = async (filePath: string) => {
try {
await access(filePath);
const content = await readFile(filePath, "utf8");
return JSON.parse(content);
} catch (error) {
return null; // 文件不存在或读取失败时返回null
}
};
const getProjectSpecificRouter = async (req: any) => {
// 检查是否有项目特定的配置
if (req.sessionId) {
const project = await searchProjectBySession(req.sessionId);
if (project) {
const projectConfigPath = join(HOME_DIR, project, "config.json");
const sessionConfigPath = join(
HOME_DIR,
project,
`${req.sessionId}.json`
);
// 首先尝试读取sessionConfig文件
const sessionConfig = await readConfigFile(sessionConfigPath);
if (sessionConfig && sessionConfig.Router) {
return sessionConfig.Router;
}
const projectConfig = await readConfigFile(projectConfigPath);
if (projectConfig && projectConfig.Router) {
return projectConfig.Router;
}
}
}
return undefined; // 返回undefined表示使用原始配置
};
const getUseModel = async (
req: any,
tokenCount: number,
config: any,
lastUsage?: Usage | undefined
) => {
const projectSpecificRouter = await getProjectSpecificRouter(req);
const Router = projectSpecificRouter || config.Router;
if (req.body.model.includes(",")) {
const [provider, model] = req.body.model.split(",");
const finalProvider = config.Providers.find(
(p: any) => p.name.toLowerCase() === provider
);
const finalModel = finalProvider?.models?.find(
(m: any) => m.toLowerCase() === model
);
if (finalProvider && finalModel) {
return `${finalProvider.name},${finalModel}`;
}
return req.body.model;
}
// if tokenCount is greater than the configured threshold, use the long context model
const longContextThreshold = Router.longContextThreshold || 60000;
const lastUsageThreshold =
lastUsage &&
lastUsage.input_tokens > longContextThreshold &&
tokenCount > 20000;
const tokenCountThreshold = tokenCount > longContextThreshold;
if ((lastUsageThreshold || tokenCountThreshold) && Router.longContext) {
req.log.info(
`Using long context model due to token count: ${tokenCount}, threshold: ${longContextThreshold}`
);
return Router.longContext;
}
if (
req.body?.system?.length > 1 &&
req.body?.system[1]?.text?.startsWith("<CCR-SUBAGENT-MODEL>")
) {
const model = req.body?.system[1].text.match(
/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s
);
if (model) {
req.body.system[1].text = req.body.system[1].text.replace(
`<CCR-SUBAGENT-MODEL>${model[1]}</CCR-SUBAGENT-MODEL>`,
""
);
return model[1];
}
}
// Use the background model for any Claude Haiku variant
if (
req.body.model?.includes("claude") &&
req.body.model?.includes("haiku") &&
config.Router.background
) {
req.log.info(`Using background model for ${req.body.model}`);
return config.Router.background;
}
// The priority of websearch must be higher than thinking.
if (
Array.isArray(req.body.tools) &&
req.body.tools.some((tool: any) => tool.type?.startsWith("web_search")) &&
Router.webSearch
) {
return Router.webSearch;
}
// if exits thinking, use the think model
if (req.body.thinking && Router.think) {
req.log.info(`Using think model for ${req.body.thinking}`);
return Router.think;
}
return Router!.default;
};
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_");
if (parts.length > 1) {
req.sessionId = parts[1];
}
}
const lastMessageUsage = sessionUsageCache.get(req.sessionId);
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
if (
config.REWRITE_SYSTEM_PROMPT &&
system.length > 1 &&
system[1]?.text?.includes("<env>")
) {
const prompt = await readFile(config.REWRITE_SYSTEM_PROMPT, "utf-8");
system[1].text = `${prompt}<env>${system[1].text.split("<env>").pop()}`;
}
try {
const tokenCount = calculateTokenCount(
messages as MessageParam[],
system,
tools as Tool[]
);
let model;
if (config.CUSTOM_ROUTER_PATH) {
try {
const customRouter = require(config.CUSTOM_ROUTER_PATH);
req.tokenCount = tokenCount; // Pass token count to custom router
model = await customRouter(req, config, {
event,
});
} catch (e: any) {
req.log.error(`failed to load custom router: ${e.message}`);
}
}
if (!model) {
model = await getUseModel(req, tokenCount, config, lastMessageUsage);
}
req.body.model = model;
} catch (error: any) {
req.log.error(`Error in router middleware: ${error.message}`);
req.body.model = config.Router!.default;
}
return;
};
// 内存缓存存储sessionId到项目名称的映射
// null值表示之前已查找过但未找到项目
// 使用LRU缓存限制最大1000个条目
const sessionProjectCache = new LRUCache<string, string>({
max: 1000,
});
export const searchProjectBySession = async (
sessionId: string
): Promise<string | null> => {
// 首先检查缓存
if (sessionProjectCache.has(sessionId)) {
const result = sessionProjectCache.get(sessionId);
if (!result || result === '') {
return null;
}
return result;
}
try {
const dir = await opendir(CLAUDE_PROJECTS_DIR);
const folderNames: string[] = [];
// 收集所有文件夹名称
for await (const dirent of dir) {
if (dirent.isDirectory()) {
folderNames.push(dirent.name);
}
}
// 并发检查每个项目文件夹中是否存在sessionId.jsonl文件
const checkPromises = folderNames.map(async (folderName) => {
const sessionFilePath = join(
CLAUDE_PROJECTS_DIR,
folderName,
`${sessionId}.jsonl`
);
try {
const fileStat = await stat(sessionFilePath);
return fileStat.isFile() ? folderName : null;
} catch {
// 文件不存在,继续检查下一个
return null;
}
});
const results = await Promise.all(checkPromises);
// 返回第一个存在的项目目录名称
for (const result of results) {
if (result) {
// 缓存找到的结果
sessionProjectCache.set(sessionId, result);
return result;
}
}
// 缓存未找到的结果null值表示之前已查找过但未找到项目
sessionProjectCache.set(sessionId, '');
return null; // 没有找到匹配的项目
} catch (error) {
console.error("Error searching for project by session:", error);
// 出错时也缓存null结果避免重复出错
sessionProjectCache.set(sessionId, '');
return null;
}
};

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}