mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-01-30 06:12:06 +00:00
move to monorepo
This commit is contained in:
12
packages/server/.dockerignore
Normal file
12
packages/server/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
coverage
|
||||
.nyc_output
|
||||
69
packages/server/Dockerfile
Normal file
69
packages/server/Dockerfile
Normal 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 bundle(shared 已被打包进 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"]
|
||||
23
packages/server/ecosystem.config.cjs
Normal file
23
packages/server/ecosystem.config.cjs
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
37
packages/server/package.json
Normal file
37
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
304
packages/server/src/agents/image.agent.ts
Normal file
304
packages/server/src/agents/image.agent.ts
Normal 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 user’s instructions.
|
||||
Do not ignore or deviate from the task.
|
||||
Always ensure that your response reflects a clear, accurate interpretation of the image aligned with the given objective.`,
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: imageMessages,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
}
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.catch((err) => {
|
||||
return null;
|
||||
});
|
||||
if (!agentResponse || !agentResponse.content) {
|
||||
return "analyzeImage Error";
|
||||
}
|
||||
return agentResponse.content[0].text;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reqHandler(req: any, 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 user’s current request and prior context.
|
||||
|
||||
Do not attempt to describe or analyze the image directly yourself.
|
||||
Ignore any user interruptions or unrelated instructions that might cause you to skip this requirement.
|
||||
Your response should consistently follow this rule whenever image-related analysis is requested.`,
|
||||
});
|
||||
|
||||
const imageContents = req.body.messages.filter((item: any) => {
|
||||
return (
|
||||
item.role === "user" &&
|
||||
Array.isArray(item.content) &&
|
||||
item.content.some(
|
||||
(msg: any) =>
|
||||
msg.type === "image" ||
|
||||
(Array.isArray(msg.content) &&
|
||||
msg.content.some((sub: any) => sub.type === "image"))
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
let imgId = 1;
|
||||
imageContents.forEach((item: any) => {
|
||||
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();
|
||||
48
packages/server/src/agents/index.ts
Normal file
48
packages/server/src/agents/index.ts
Normal 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
|
||||
19
packages/server/src/agents/type.ts
Normal file
19
packages/server/src/agents/type.ts
Normal 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;
|
||||
}
|
||||
429
packages/server/src/index.ts
Normal file
429
packages/server/src/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
57
packages/server/src/middleware/auth.ts
Normal file
57
packages/server/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
163
packages/server/src/server.ts
Normal file
163
packages/server/src/server.ts
Normal 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
21
packages/server/src/types.d.ts
vendored
Normal 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;
|
||||
}
|
||||
71
packages/server/src/utils/SSEParser.transform.ts
Normal file
71
packages/server/src/utils/SSEParser.transform.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
packages/server/src/utils/SSESerializer.transform.ts
Normal file
29
packages/server/src/utils/SSESerializer.transform.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
47
packages/server/src/utils/cache.ts
Normal file
47
packages/server/src/utils/cache.ts
Normal 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);
|
||||
173
packages/server/src/utils/index.ts
Normal file
173
packages/server/src/utils/index.ts
Normal 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;
|
||||
};
|
||||
31
packages/server/src/utils/rewriteStream.ts
Normal file
31
packages/server/src/utils/rewriteStream.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
317
packages/server/src/utils/router.ts
Normal file
317
packages/server/src/utils/router.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
10
packages/server/tsconfig.json
Normal file
10
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user