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

@@ -2,12 +2,16 @@
"name": "@musistudio/claude-code-router", "name": "@musistudio/claude-code-router",
"version": "1.0.73", "version": "1.0.73",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider", "description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": { "private": true,
"ccr": "dist/cli.js"
},
"scripts": { "scripts": {
"build": "node scripts/build.js", "build": "node scripts/build.js",
"release": "npm run build && npm publish" "build:cli": "pnpm --filter @musistudio/claude-code-router-cli build",
"build:server": "pnpm --filter @musistudio/claude-code-router-server build",
"build:ui": "pnpm --filter @musistudio/claude-code-router-ui build",
"release": "pnpm build && pnpm publish -r",
"dev:cli": "pnpm --filter @musistudio/claude-code-router-cli dev",
"dev:server": "pnpm --filter @musistudio/claude-code-router-server dev",
"dev:ui": "pnpm --filter @musistudio/claude-code-router-ui dev"
}, },
"keywords": [ "keywords": [
"claude", "claude",
@@ -18,33 +22,14 @@
], ],
"author": "musistudio", "author": "musistudio",
"license": "MIT", "license": "MIT",
"dependencies": {
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.51",
"@inquirer/prompts": "^5.0.0",
"dotenv": "^16.4.7",
"find-process": "^2.0.0",
"json5": "^2.2.3",
"lru-cache": "^11.2.2",
"minimist": "^1.2.8",
"openurl": "^1.1.1",
"rotating-file-stream": "^3.2.7",
"shell-quote": "^1.8.3",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.15", "@types/node": "^24.0.15",
"esbuild": "^0.25.1", "esbuild": "^0.25.1",
"fastify": "^5.4.0",
"shx": "^0.4.0", "shx": "^0.4.0",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"publishConfig": { "engines": {
"ignore": [ "node": ">=18.0.0",
"!build/", "pnpm": ">=8.0.0"
"src/",
"screenshots/"
]
} }
} }

34
packages/cli/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@musistudio/claude-code-router-cli",
"version": "1.0.73",
"description": "CLI for Claude Code Router",
"bin": {
"ccr": "dist/cli.js"
},
"scripts": {
"build": "node ../../scripts/build-cli.js",
"dev": "ts-node src/cli.ts"
},
"keywords": [
"claude",
"code",
"router",
"cli"
],
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@musistudio/claude-code-router-shared": "workspace:*",
"@inquirer/prompts": "^5.0.0",
"@musistudio/claude-code-router-server": "workspace:*",
"find-process": "^2.0.0",
"minimist": "^1.2.8",
"openurl": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}
}

View File

@@ -1,8 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
import { run } from "./index"; // @ts-ignore - server package is built separately
import { run } from "@musistudio/claude-code-router-server";
// @ts-ignore - server package is built separately
import { parseStatusLineData, type StatusLineInput } from "@musistudio/claude-code-router-server";
import { showStatus } from "./utils/status"; import { showStatus } from "./utils/status";
import { executeCodeCommand } from "./utils/codeCommand"; import { executeCodeCommand } from "./utils/codeCommand";
import { parseStatusLineData, type StatusLineInput } from "./utils/statusline";
import { import {
cleanupPidFile, cleanupPidFile,
isServiceRunning, isServiceRunning,
@@ -12,7 +14,7 @@ import { runModelSelector } from "./utils/modelSelector"; // ADD THIS LINE
import { activateCommand } from "./utils/activateCommand"; import { activateCommand } from "./utils/activateCommand";
import { version } from "../package.json"; import { version } from "../package.json";
import { spawn, exec } from "child_process"; import { spawn, exec } from "child_process";
import { PID_FILE, REFERENCE_COUNT_FILE } from "./constants"; import { PID_FILE, REFERENCE_COUNT_FILE } from "@musistudio/claude-code-router-shared";
import fs, { existsSync, readFileSync } from "fs"; import fs, { existsSync, readFileSync } from "fs";
import { join } from "path"; import { join } from "path";

82
packages/cli/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,82 @@
declare module 'shell-quote' {
export function quote(args: string[]): string;
export function parse(cmd: string): string[];
}
declare module 'minimist' {
interface Options {
string?: string[];
boolean?: string | string[];
alias?: Record<string, string | string[]>;
default?: Record<string, any>;
stopEarly?: boolean;
'--'?: boolean;
unknown?: (arg: string) => boolean;
}
interface ParsedArgs {
_: string[];
[key: string]: any;
}
function minimist(args?: string[], opts?: Options): ParsedArgs;
export = minimist;
}
declare module '@inquirer/prompts' {
export function select<T>(config: {
message: string;
choices: Array<{ name: string; value: T; description?: string }>;
pageSize?: number;
}): Promise<T>;
export function input(config: {
message: string;
default?: string;
validate?: (value: string) => boolean | string | Promise<boolean | string>;
}): Promise<string>;
export function confirm(config: {
message: string;
default?: boolean;
}): Promise<boolean>;
}
declare module 'find-process' {
export default function find(
type: 'pid' | 'name' | 'port',
value: string | number
): Promise<Array<{ pid: number; name: string; ppid?: number; cmd?: string }>>;
}
declare module 'json5' {
export function parse(text: string): any;
export function stringify(value: any, replacer?: any, space?: string | number): string;
}
declare namespace NodeJS {
interface ProcessEnv {
CI?: string;
FORCE_COLOR?: string;
NODE_NO_READLINE?: string;
TERM?: string;
ANTHROPIC_SMALL_FAST_MODEL?: string;
}
}
interface ClaudeSettingsFlag {
env: {
ANTHROPIC_AUTH_TOKEN?: any;
ANTHROPIC_API_KEY: string;
ANTHROPIC_BASE_URL: string;
NO_PROXY: string;
DISABLE_TELEMETRY: string;
DISABLE_COST_WARNINGS: string;
API_TIMEOUT_MS: string;
CLAUDE_CODE_USE_BEDROCK?: undefined;
[key: string]: any;
};
statusLine?: {
type: string;
command: string;
padding: number;
};
}

View File

@@ -1,6 +1,7 @@
import { spawn, type StdioOptions } from "child_process"; import { spawn, type StdioOptions } from "child_process";
import { readConfigFile } from "."; import { readConfigFile } from ".";
import { closeService } from "./close"; // @ts-ignore - server package is built separately
import { closeService } from "@musistudio/claude-code-router-server";
import { import {
decrementReferenceCount, decrementReferenceCount,
incrementReferenceCount, incrementReferenceCount,
@@ -14,8 +15,8 @@ export async function executeCodeCommand(args: string[] = []) {
// Set environment variables using shared function // Set environment variables using shared function
const config = await readConfigFile(); const config = await readConfigFile();
const env = await createEnvVariables(); const env = await createEnvVariables();
const settingsFlag = { const settingsFlag: ClaudeSettingsFlag = {
env env: env as ClaudeSettingsFlag['env']
}; };
if (config?.StatusLine?.enabled) { if (config?.StatusLine?.enabled) {
settingsFlag.statusLine = { settingsFlag.statusLine = {

View File

@@ -4,7 +4,7 @@ import { readConfigFile } from ".";
* Get environment variables for Agent SDK/Claude Code integration * Get environment variables for Agent SDK/Claude Code integration
* This function is shared between `ccr env` and `ccr code` commands * This function is shared between `ccr env` and `ccr code` commands
*/ */
export const createEnvVariables = async () => { export const createEnvVariables = async (): Promise<Record<string, string | undefined>> => {
const config = await readConfigFile(); const config = await readConfigFile();
const port = config.PORT || 3456; const port = config.PORT || 3456;
const apiKey = config.APIKEY || "test"; const apiKey = config.APIKEY || "test";

View File

@@ -7,8 +7,9 @@ import {
DEFAULT_CONFIG, DEFAULT_CONFIG,
HOME_DIR, HOME_DIR,
PLUGINS_DIR, PLUGINS_DIR,
} from "../constants"; } from "@musistudio/claude-code-router-shared";
import { cleanupLogFiles } from "./logCleanup"; // @ts-ignore - server package is built separately
import { cleanupLogFiles } from "@musistudio/claude-code-router-server";
// Function to interpolate environment variables in config values // Function to interpolate environment variables in config values
const interpolateEnvVars = (obj: any): any => { const interpolateEnvVars = (obj: any): any => {

View File

@@ -1,5 +1,5 @@
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants'; import { PID_FILE, REFERENCE_COUNT_FILE } from '@musistudio/claude-code-router-shared';
import { readConfigFile } from '.'; import { readConfigFile } from '.';
import find from 'find-process'; import find from 'find-process';
import { execSync } from 'child_process'; // 引入 execSync 来执行命令行 import { execSync } from 'child_process'; // 引入 execSync 来执行命令行

View File

@@ -0,0 +1,813 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execSync } from "child_process";
import { CONFIG_FILE } from "@musistudio/claude-code-router-shared";
import JSON5 from "json5";
export interface StatusLineModuleConfig {
type: string;
icon?: string;
text: string;
color?: string;
background?: string;
scriptPath?: string; // 用于script类型的模块指定要执行的Node.js脚本文件路径
}
export interface StatusLineThemeConfig {
modules: StatusLineModuleConfig[];
}
export interface StatusLineInput {
hook_event_name: string;
session_id: string;
transcript_path: string;
cwd: string;
model: {
id: string;
display_name: string;
};
workspace: {
current_dir: string;
project_dir: string;
};
}
export interface AssistantMessage {
type: "assistant";
message: {
model: string;
usage: {
input_tokens: number;
output_tokens: number;
};
};
}
// ANSIColor代码
const COLORS: Record<string, string> = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
// 标准颜色
black: "\x1b[30m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
// 亮色
bright_black: "\x1b[90m",
bright_red: "\x1b[91m",
bright_green: "\x1b[92m",
bright_yellow: "\x1b[93m",
bright_blue: "\x1b[94m",
bright_magenta: "\x1b[95m",
bright_cyan: "\x1b[96m",
bright_white: "\x1b[97m",
// 背景颜色
bg_black: "\x1b[40m",
bg_red: "\x1b[41m",
bg_green: "\x1b[42m",
bg_yellow: "\x1b[43m",
bg_blue: "\x1b[44m",
bg_magenta: "\x1b[45m",
bg_cyan: "\x1b[46m",
bg_white: "\x1b[47m",
// 亮背景色
bg_bright_black: "\x1b[100m",
bg_bright_red: "\x1b[101m",
bg_bright_green: "\x1b[102m",
bg_bright_yellow: "\x1b[103m",
bg_bright_blue: "\x1b[104m",
bg_bright_magenta: "\x1b[105m",
bg_bright_cyan: "\x1b[106m",
bg_bright_white: "\x1b[107m",
};
// 使用TrueColor(24位色)支持十六进制颜色
const TRUE_COLOR_PREFIX = "\x1b[38;2;";
const TRUE_COLOR_BG_PREFIX = "\x1b[48;2;";
// 将十六进制颜色转为RGB格式
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
// 移除#和空格
hex = hex.replace(/^#/, '').trim();
// 处理简写形式 (#RGB -> #RRGGBB)
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (hex.length !== 6) {
return null;
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// 验证RGB值是否有效
if (isNaN(r) || isNaN(g) || isNaN(b) || r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
return null;
}
return { r, g, b };
}
// 获取颜色代码
function getColorCode(colorName: string): string {
// 检查是否是十六进制颜色
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
const rgb = hexToRgb(colorName);
if (rgb) {
return `${TRUE_COLOR_PREFIX}${rgb.r};${rgb.g};${rgb.b}m`;
}
}
// 默认返回空字符串
return "";
}
// 变量替换函数,支持{{var}}格式的变量替换
function replaceVariables(text: string, variables: Record<string, string>): string {
return text.replace(/\{\{(\w+)\}\}/g, (_match, varName) => {
return variables[varName] || "";
});
}
// 执行脚本并获取输出
async function executeScript(scriptPath: string, variables: Record<string, string>): Promise<string> {
try {
// 检查文件是否存在
await fs.access(scriptPath);
// 使用require动态加载脚本模块
const scriptModule = require(scriptPath);
// 如果导出的是函数,则调用它并传入变量
if (typeof scriptModule === 'function') {
const result = scriptModule(variables);
// 如果返回的是Promise则等待它完成
if (result instanceof Promise) {
return await result;
}
return result;
}
// 如果导出的是default函数则调用它
if (scriptModule.default && typeof scriptModule.default === 'function') {
const result = scriptModule.default(variables);
// 如果返回的是Promise则等待它完成
if (result instanceof Promise) {
return await result;
}
return result;
}
// 如果导出的是字符串,则直接返回
if (typeof scriptModule === 'string') {
return scriptModule;
}
// 如果导出的是default字符串则返回它
if (scriptModule.default && typeof scriptModule.default === 'string') {
return scriptModule.default;
}
// 默认情况下返回空字符串
return "";
} catch (error) {
console.error(`执行脚本 ${scriptPath} 时出错:`, error);
return "";
}
}
// 默认主题配置 - 使用Nerd Fonts图标和美观配色
const DEFAULT_THEME: StatusLineThemeConfig = {
modules: [
{
type: "workDir",
icon: "󰉋", // nf-md-folder_outline
text: "{{workDirName}}",
color: "bright_blue"
},
{
type: "gitBranch",
icon: "", // nf-dev-git_branch
text: "{{gitBranch}}",
color: "bright_magenta"
},
{
type: "model",
icon: "󰚩", // nf-md-robot_outline
text: "{{model}}",
color: "bright_cyan"
},
{
type: "usage",
icon: "↑", // 上箭头
text: "{{inputTokens}}",
color: "bright_green"
},
{
type: "usage",
icon: "↓", // 下箭头
text: "{{outputTokens}}",
color: "bright_yellow"
}
]
};
// Powerline风格主题配置
const POWERLINE_THEME: StatusLineThemeConfig = {
modules: [
{
type: "workDir",
icon: "󰉋", // nf-md-folder_outline
text: "{{workDirName}}",
color: "white",
background: "bg_bright_blue"
},
{
type: "gitBranch",
icon: "", // nf-dev-git_branch
text: "{{gitBranch}}",
color: "white",
background: "bg_bright_magenta"
},
{
type: "model",
icon: "󰚩", // nf-md-robot_outline
text: "{{model}}",
color: "white",
background: "bg_bright_cyan"
},
{
type: "usage",
icon: "↑", // 上箭头
text: "{{inputTokens}}",
color: "white",
background: "bg_bright_green"
},
{
type: "usage",
icon: "↓", // 下箭头
text: "{{outputTokens}}",
color: "white",
background: "bg_bright_yellow"
}
]
};
// 简单文本主题配置 - 用于图标无法显示时的fallback
const SIMPLE_THEME: StatusLineThemeConfig = {
modules: [
{
type: "workDir",
icon: "",
text: "{{workDirName}}",
color: "bright_blue"
},
{
type: "gitBranch",
icon: "",
text: "{{gitBranch}}",
color: "bright_magenta"
},
{
type: "model",
icon: "",
text: "{{model}}",
color: "bright_cyan"
},
{
type: "usage",
icon: "↑",
text: "{{inputTokens}}",
color: "bright_green"
},
{
type: "usage",
icon: "↓",
text: "{{outputTokens}}",
color: "bright_yellow"
}
]
};
// 格式化usage信息如果大于1000则使用k单位
function formatUsage(input_tokens: number, output_tokens: number): string {
if (input_tokens > 1000 || output_tokens > 1000) {
const inputFormatted = input_tokens > 1000 ? `${(input_tokens / 1000).toFixed(1)}k` : `${input_tokens}`;
const outputFormatted = output_tokens > 1000 ? `${(output_tokens / 1000).toFixed(1)}k` : `${output_tokens}`;
return `${inputFormatted} ${outputFormatted}`;
}
return `${input_tokens} ${output_tokens}`;
}
// 读取用户主目录的主题配置
async function getProjectThemeConfig(): Promise<{ theme: StatusLineThemeConfig | null, style: string }> {
try {
// 只使用主目录的固定配置文件
const configPath = CONFIG_FILE;
// 检查配置文件是否存在
try {
await fs.access(configPath);
} catch {
return { theme: null, style: 'default' };
}
const configContent = await fs.readFile(configPath, "utf-8");
const config = JSON5.parse(configContent);
// 检查是否有StatusLine配置
if (config.StatusLine) {
// 获取当前使用的风格默认为default
const currentStyle = config.StatusLine.currentStyle || 'default';
// 检查是否有对应风格的配置
if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) {
return { theme: config.StatusLine[currentStyle], style: currentStyle };
}
}
} catch (error) {
// 如果读取失败返回null
// console.error("Failed to read theme config:", error);
}
return { theme: null, style: 'default' };
}
// 检查是否应该使用简单主题fallback方案
// 当环境变量 USE_SIMPLE_ICONS 被设置时或者当检测到可能不支持Nerd Fonts的终端时
function shouldUseSimpleTheme(): boolean {
// 检查环境变量
if (process.env.USE_SIMPLE_ICONS === 'true') {
return true;
}
// 检查终端类型(一些常见的不支持复杂图标的终端)
const term = process.env.TERM || '';
const unsupportedTerms = ['dumb', 'unknown'];
if (unsupportedTerms.includes(term)) {
return true;
}
// 默认情况下假设终端支持Nerd Fonts
return false;
}
// 检查Nerd Fonts图标是否能正确显示
// 通过检查终端字体信息或使用试探性方法
function canDisplayNerdFonts(): boolean {
// 如果环境变量明确指定使用简单图标则不能显示Nerd Fonts
if (process.env.USE_SIMPLE_ICONS === 'true') {
return false;
}
// 检查一些常见的支持Nerd Fonts的终端环境变量
const fontEnvVars = ['NERD_FONT', 'NERDFONT', 'FONT'];
for (const envVar of fontEnvVars) {
const value = process.env[envVar];
if (value && (value.includes('Nerd') || value.includes('nerd'))) {
return true;
}
}
// 检查终端类型
const termProgram = process.env.TERM_PROGRAM || '';
const supportedTerminals = ['iTerm.app', 'vscode', 'Hyper', 'kitty', 'alacritty'];
if (supportedTerminals.includes(termProgram)) {
return true;
}
// 检查COLORTERM环境变量
const colorTerm = process.env.COLORTERM || '';
if (colorTerm.includes('truecolor') || colorTerm.includes('24bit')) {
return true;
}
// 默认情况下假设可以显示Nerd Fonts但允许用户通过环境变量覆盖
return process.env.USE_SIMPLE_ICONS !== 'true';
}
// 检查特定Unicode字符是否能正确显示
// 这是一个简单的试探性检查
function canDisplayUnicodeCharacter(char: string): boolean {
// 对于Nerd Fonts图标我们假设支持UTF-8的终端可以显示
// 但实际上很难准确检测,所以我们依赖环境变量和终端类型检测
try {
// 检查终端是否支持UTF-8
const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) {
return true;
}
// 检查LC_*环境变量
const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG'];
for (const lcVar of lcVars) {
const value = process.env[lcVar];
if (value && (value.includes('UTF-8') || value.includes('utf8'))) {
return true;
}
}
} catch (e) {
// 如果检查失败默认返回true
return true;
}
// 默认情况下,假设可以显示
return true;
}
export async function parseStatusLineData(input: StatusLineInput): Promise<string> {
try {
// 检查是否应该使用简单主题
const useSimpleTheme = shouldUseSimpleTheme();
// 检查是否可以显示Nerd Fonts图标
const canDisplayNerd = canDisplayNerdFonts();
// 确定使用的主题如果用户强制使用简单主题或无法显示Nerd Fonts则使用简单主题
const effectiveTheme = useSimpleTheme || !canDisplayNerd ? SIMPLE_THEME : DEFAULT_THEME;
// 获取主目录的主题配置,如果没有则使用确定的默认配置
const { theme: projectTheme, style: currentStyle } = await getProjectThemeConfig();
const theme = projectTheme || effectiveTheme;
// 获取当前工作目录和Git分支
const workDir = input.workspace.current_dir;
let gitBranch = "";
try {
// 尝试获取Git分支名
gitBranch = execSync("git branch --show-current", {
cwd: workDir,
stdio: ["pipe", "pipe", "ignore"],
})
.toString()
.trim();
} catch (error) {
// 如果不是Git仓库或获取失败则忽略错误
}
// 从transcript_path文件中读取最后一条assistant消息
const transcriptContent = await fs.readFile(input.transcript_path, "utf-8");
const lines = transcriptContent.trim().split("\n");
// 反向遍历寻找最后一条assistant消息
let model = "";
let inputTokens = 0;
let outputTokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const message: AssistantMessage = JSON.parse(lines[i]);
if (message.type === "assistant" && message.message.model) {
model = message.message.model;
if (message.message.usage) {
inputTokens = message.message.usage.input_tokens;
outputTokens = message.message.usage.output_tokens;
}
break;
}
} catch (parseError) {
// 忽略解析错误,继续查找
continue;
}
}
// 如果没有从transcript中获取到模型名称则尝试从配置文件中获取
if (!model) {
try {
// 获取项目配置文件路径
const projectConfigPath = path.join(workDir, ".claude-code-router", "config.json");
let configPath = projectConfigPath;
// 检查项目配置文件是否存在,如果不存在则使用用户主目录的配置文件
try {
await fs.access(projectConfigPath);
} catch {
configPath = CONFIG_FILE;
}
// 读取配置文件
const configContent = await fs.readFile(configPath, "utf-8");
const config = JSON5.parse(configContent);
// 从Router字段的default内容中获取模型名称
if (config.Router && config.Router.default) {
const [, defaultModel] = config.Router.default.split(",");
if (defaultModel) {
model = defaultModel.trim();
}
}
} catch (configError) {
// 如果配置文件读取失败,则忽略错误
}
}
// 如果仍然没有获取到模型名称则使用传入的JSON数据中的model字段的display_name
if (!model) {
model = input.model.display_name;
}
// 获取工作目录名
const workDirName = workDir.split("/").pop() || "";
// 格式化usage信息
const usage = formatUsage(inputTokens, outputTokens);
const [formattedInputTokens, formattedOutputTokens] = usage.split(" ");
// 定义变量替换映射
const variables = {
workDirName,
gitBranch,
model,
inputTokens: formattedInputTokens,
outputTokens: formattedOutputTokens
};
// 确定使用的风格
const isPowerline = currentStyle === 'powerline';
// 根据风格渲染状态行
if (isPowerline) {
return await renderPowerlineStyle(theme, variables);
} else {
return await renderDefaultStyle(theme, variables);
}
} catch (error) {
// 发生错误时返回空字符串
return "";
}
}
// 读取用户主目录的主题配置(指定风格)
async function getProjectThemeConfigForStyle(style: string): Promise<StatusLineThemeConfig | null> {
try {
// 只使用主目录的固定配置文件
const configPath = CONFIG_FILE;
// 检查配置文件是否存在
try {
await fs.access(configPath);
} catch {
return null;
}
const configContent = await fs.readFile(configPath, "utf-8");
const config = JSON5.parse(configContent);
// 检查是否有StatusLine配置
if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) {
return config.StatusLine[style];
}
} catch (error) {
// 如果读取失败返回null
// console.error("Failed to read theme config:", error);
}
return null;
}
// 渲染默认风格的状态行
async function renderDefaultStyle(
theme: StatusLineThemeConfig,
variables: Record<string, string>
): Promise<string> {
const modules = theme.modules || DEFAULT_THEME.modules;
const parts: string[] = [];
// 遍历模块数组,渲染每个模块
for (let i = 0; i < Math.min(modules.length, 5); i++) {
const module = modules[i];
const color = module.color ? getColorCode(module.color) : "";
const background = module.background ? getColorCode(module.background) : "";
const icon = module.icon || "";
// 如果是script类型执行脚本获取文本
let text = "";
if (module.type === "script" && module.scriptPath) {
text = await executeScript(module.scriptPath, variables);
} else {
text = replaceVariables(module.text, variables);
}
// 构建显示文本
let displayText = "";
if (icon) {
displayText += `${icon} `;
}
displayText += text;
// 如果displayText为空或者只有图标没有实际文本则跳过该模块
if (!displayText || !text) {
continue;
}
// 构建模块字符串
let part = `${background}${color}`;
part += `${displayText}${COLORS.reset}`;
parts.push(part);
}
// 使用空格连接所有部分
return parts.join(" ");
}
// Powerline符号
const SEP_RIGHT = "\uE0B0"; // 
// 颜色编号256色表
const COLOR_MAP: Record<string, number> = {
// 基础颜色映射到256色
black: 0,
red: 1,
green: 2,
yellow: 3,
blue: 4,
magenta: 5,
cyan: 6,
white: 7,
bright_black: 8,
bright_red: 9,
bright_green: 10,
bright_yellow: 11,
bright_blue: 12,
bright_magenta: 13,
bright_cyan: 14,
bright_white: 15,
// 亮背景色映射
bg_black: 0,
bg_red: 1,
bg_green: 2,
bg_yellow: 3,
bg_blue: 4,
bg_magenta: 5,
bg_cyan: 6,
bg_white: 7,
bg_bright_black: 8,
bg_bright_red: 9,
bg_bright_green: 10,
bg_bright_yellow: 11,
bg_bright_blue: 12,
bg_bright_magenta: 13,
bg_bright_cyan: 14,
bg_bright_white: 15,
// 自定义颜色映射
bg_bright_orange: 202,
bg_bright_purple: 129,
};
// 获取TrueColor的RGB值
function getTrueColorRgb(colorName: string): { r: number; g: number; b: number } | null {
// 如果是预定义颜色返回对应RGB
if (COLOR_MAP[colorName] !== undefined) {
const color256 = COLOR_MAP[colorName];
return color256ToRgb(color256);
}
// 处理十六进制颜色
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
return hexToRgb(colorName);
}
// 处理背景色十六进制
if (colorName.startsWith('bg_#')) {
return hexToRgb(colorName.substring(3));
}
return null;
}
// 将256色表索引转换为RGB值
function color256ToRgb(index: number): { r: number; g: number; b: number } | null {
if (index < 0 || index > 255) return null;
// ANSI 256色表转换
if (index < 16) {
// 基本颜色
const basicColors = [
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
];
return { r: basicColors[index][0], g: basicColors[index][1], b: basicColors[index][2] };
} else if (index < 232) {
// 216色6×6×6的颜色立方体
const i = index - 16;
const r = Math.floor(i / 36);
const g = Math.floor((i % 36) / 6);
const b = i % 6;
const rgb = [0, 95, 135, 175, 215, 255];
return { r: rgb[r], g: rgb[g], b: rgb[b] };
} else {
// 灰度色
const gray = 8 + (index - 232) * 10;
return { r: gray, g: gray, b: gray };
}
}
// 生成一个无缝拼接的段:文本在 bgN 上显示,分隔符从 bgN 过渡到 nextBgN
function segment(text: string, textFg: string, bgColor: string, nextBgColor: string | null): string {
const bgRgb = getTrueColorRgb(bgColor);
if (!bgRgb) {
// 如果无法获取RGB使用默认蓝色背景
const defaultBlueRgb = { r: 33, g: 150, b: 243 };
const curBg = `\x1b[48;2;${defaultBlueRgb.r};${defaultBlueRgb.g};${defaultBlueRgb.b}m`;
const fgColor = `\x1b[38;2;255;255;255m`;
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
return body;
}
const curBg = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
// 获取前景色RGB
let fgRgb = { r: 255, g: 255, b: 255 }; // 默认前景色为白色
const textFgRgb = getTrueColorRgb(textFg);
if (textFgRgb) {
fgRgb = textFgRgb;
}
const fgColor = `\x1b[38;2;${fgRgb.r};${fgRgb.g};${fgRgb.b}m`;
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
if (nextBgColor != null) {
const nextBgRgb = getTrueColorRgb(nextBgColor);
if (nextBgRgb) {
// 分隔符:前景色是当前段的背景色,背景色是下一段的背景色
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
const sepNextBg = `\x1b[48;2;${nextBgRgb.r};${nextBgRgb.g};${nextBgRgb.b}m`;
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
return body + sep;
}
// 如果没有下一个背景色,假设终端背景为黑色并渲染黑色箭头
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
const sepNextBg = `\x1b[48;2;0;0;0m`; // 黑色背景
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
return body + sep;
}
return body;
}
// 渲染Powerline风格的状态行
async function renderPowerlineStyle(
theme: StatusLineThemeConfig,
variables: Record<string, string>
): Promise<string> {
const modules = theme.modules || POWERLINE_THEME.modules;
const segments: string[] = [];
// 遍历模块数组,渲染每个模块
for (let i = 0; i < Math.min(modules.length, 5); i++) {
const module = modules[i];
const color = module.color || "white";
const backgroundName = module.background || "";
const icon = module.icon || "";
// 如果是script类型执行脚本获取文本
let text = "";
if (module.type === "script" && module.scriptPath) {
text = await executeScript(module.scriptPath, variables);
} else {
text = replaceVariables(module.text, variables);
}
// 构建显示文本
let displayText = "";
if (icon) {
displayText += `${icon} `;
}
displayText += text;
// 如果displayText为空或者只有图标没有实际文本则跳过该模块
if (!displayText || !text) {
continue;
}
// 获取下一个模块的背景色(用于分隔符)
let nextBackground: string | null = null;
if (i < modules.length - 1) {
const nextModule = modules[i + 1];
nextBackground = nextModule.background || null;
}
// 使用模块定义的背景色或者为Powerline风格提供默认背景色
const actualBackground = backgroundName || "bg_bright_blue";
// 生成段,支持十六进制颜色
const segmentStr = segment(displayText, color, actualBackground, nextBackground);
segments.push(segmentStr);
}
return segments.join("");
}

View File

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

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

@@ -71,7 +71,7 @@ export class ImageAgent implements IAgent {
) )
) { ) {
req.body.model = config.Router.image; req.body.model = config.Router.image;
const images = []; const images: any[] = [];
lastMessage.content lastMessage.content
.filter((item: any) => item.type === "tool_result") .filter((item: any) => item.type === "tool_result")
.forEach((item: any) => { .forEach((item: any) => {
@@ -183,7 +183,7 @@ export class ImageAgent implements IAgent {
context.req.body.messages[context.req.body.messages.length - 1]; context.req.body.messages[context.req.body.messages.length - 1];
if (userMessage.role === "user" && Array.isArray(userMessage.content)) { if (userMessage.role === "user" && Array.isArray(userMessage.content)) {
const msgs = userMessage.content.filter( const msgs = userMessage.content.filter(
(item) => (item: any) =>
item.type === "text" && item.type === "text" &&
!item.text.includes( !item.text.includes(
"This is an image, if you need to view or analyze it, you need to extract the imageId" "This is an image, if you need to view or analyze it, you need to extract the imageId"
@@ -286,7 +286,7 @@ Your response should consistently follow this rule whenever image-related analys
} else if (msg.type === "tool_result") { } else if (msg.type === "tool_result") {
if ( if (
Array.isArray(msg.content) && Array.isArray(msg.content) &&
msg.content.some((ele) => ele.type === "image") msg.content.some((ele: any) => ele.type === "image")
) { ) {
imageCache.storeImage( imageCache.storeImage(
`${req.id}_Image#${imgId}`, `${req.id}_Image#${imgId}`,

View File

@@ -1,19 +1,13 @@
import { existsSync } from "fs"; import { existsSync, writeFileSync, unlinkSync } from "fs";
import { writeFile } from "fs/promises"; import { writeFile } from "fs/promises";
import { homedir } from "os"; import { homedir } from "os";
import path, { join } from "path"; import { join } from "path";
import { initConfig, initDir, cleanupLogFiles } from "./utils"; import { initConfig, initDir } from "./utils";
import { createServer } from "./server"; import { createServer } from "./server";
import { router } from "./utils/router"; import { router } from "./utils/router";
import { apiKeyAuth } from "./middleware/auth"; import { apiKeyAuth } from "./middleware/auth";
import { import { PID_FILE, CONFIG_FILE, HOME_DIR } from "@musistudio/claude-code-router-shared";
cleanupPidFile,
isServiceRunning,
savePid,
} from "./utils/processCheck";
import { CONFIG_FILE } from "./constants";
import { createStream } from 'rotating-file-stream'; import { createStream } from 'rotating-file-stream';
import { HOME_DIR } from "./constants";
import { sessionUsageCache } from "./utils/cache"; import { sessionUsageCache } from "./utils/cache";
import {SSEParserTransform} from "./utils/SSEParser.transform"; import {SSEParserTransform} from "./utils/SSEParser.transform";
import {SSESerializerTransform} from "./utils/SSESerializer.transform"; import {SSESerializerTransform} from "./utils/SSESerializer.transform";
@@ -47,11 +41,12 @@ async function initializeClaudeConfig() {
interface RunOptions { interface RunOptions {
port?: number; port?: number;
logger?: any;
} }
async function run(options: RunOptions = {}) { async function run(options: RunOptions = {}) {
// Check if service is already running // Check if service is already running
const isRunning = await isServiceRunning() const isRunning = existsSync(PID_FILE);
if (isRunning) { if (isRunning) {
console.log("✅ Service is already running in the background."); console.log("✅ Service is already running in the background.");
return; return;
@@ -59,33 +54,56 @@ async function run(options: RunOptions = {}) {
await initializeClaudeConfig(); await initializeClaudeConfig();
await initDir(); await initDir();
// Clean up old log files, keeping only the 10 most recent ones
await cleanupLogFiles();
const config = await initConfig(); 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"; let HOST = config.HOST || "127.0.0.1";
if (config.HOST && !config.APIKEY) { if (hasProviders) {
HOST = "127.0.0.1"; // When providers are configured, require both HOST and APIKEY
console.warn("⚠️ API key is not set. HOST is forced to 127.0.0.1."); 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; const port = config.PORT || 3456;
// Save the PID of the background process // Save the PID of the background process
savePid(process.pid); writeFileSync(PID_FILE, process.pid.toString());
// Handle SIGINT (Ctrl+C) to clean up PID file // Handle SIGINT (Ctrl+C) to clean up PID file
process.on("SIGINT", () => { process.on("SIGINT", () => {
console.log("Received SIGINT, cleaning up..."); console.log("Received SIGINT, cleaning up...");
cleanupPidFile(); if (existsSync(PID_FILE)) {
try {
unlinkSync(PID_FILE);
} catch (e) {
// Ignore cleanup errors
}
}
process.exit(0); process.exit(0);
}); });
// Handle SIGTERM to clean up PID file // Handle SIGTERM to clean up PID file
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
cleanupPidFile(); if (existsSync(PID_FILE)) {
try {
const fs = require('fs');
fs.unlinkSync(PID_FILE);
} catch (e) {
// Ignore cleanup errors
}
}
process.exit(0); process.exit(0);
}); });
@@ -94,33 +112,52 @@ async function run(options: RunOptions = {}) {
? parseInt(process.env.SERVICE_PORT) ? parseInt(process.env.SERVICE_PORT)
: port; : port;
// Configure logger based on config settings // Configure logger based on config settings or external options
const pad = num => (num > 9 ? "" : "0") + num; const pad = (num: number) => (num > 9 ? "" : "0") + num;
const generator = (time, index) => { const generator = (time: number | Date | undefined, index: number | undefined) => {
let date: Date;
if (!time) { if (!time) {
time = new Date() date = new Date();
} else if (typeof time === 'number') {
date = new Date(time);
} else {
date = time;
} }
var month = time.getFullYear() + "" + pad(time.getMonth() + 1); const month = date.getFullYear() + "" + pad(date.getMonth() + 1);
var day = pad(time.getDate()); const day = pad(date.getDate());
var hour = pad(time.getHours()); const hour = pad(date.getHours());
var minute = pad(time.getMinutes()); const minute = pad(date.getMinutes());
return `./logs/ccr-${month}${day}${hour}${minute}${pad(time.getSeconds())}${index ? `_${index}` : ''}.log`; return `./logs/ccr-${month}${day}${hour}${minute}${pad(date.getSeconds())}${index ? `_${index}` : ''}.log`;
}; };
const loggerConfig =
config.LOG !== false let loggerConfig: any;
? {
level: config.LOG_LEVEL || "debug", // 如果外部传入了 logger 配置,使用外部的
stream: createStream(generator, { if (options.logger !== undefined) {
path: HOME_DIR, loggerConfig = options.logger;
maxFiles: 3, } else {
interval: "1d", // 如果没有传入,并且 config.LOG !== false则启用 logger
compress: false, if (config.LOG !== false) {
maxSize: "50M" // 将 config.LOG 设为 true如果它还未设置
}), if (config.LOG === undefined) {
} config.LOG = true;
: false; }
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({ const server = createServer({
jsonPath: CONFIG_FILE, jsonPath: CONFIG_FILE,
@@ -147,8 +184,8 @@ async function run(options: RunOptions = {}) {
server.logger.error("Unhandled rejection at:", promise, "reason:", reason); server.logger.error("Unhandled rejection at:", promise, "reason:", reason);
}); });
// Add async preHandler hook for authentication // Add async preHandler hook for authentication
server.addHook("preHandler", async (req, reply) => { server.addHook("preHandler", async (req: any, reply: any) => {
return new Promise((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const done = (err?: Error) => { const done = (err?: Error) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
@@ -157,7 +194,7 @@ async function run(options: RunOptions = {}) {
apiKeyAuth(config)(req, reply, done).catch(reject); apiKeyAuth(config)(req, reply, done).catch(reject);
}); });
}); });
server.addHook("preHandler", async (req, reply) => { server.addHook("preHandler", async (req: any, reply: any) => {
if (req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) { if (req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
const useAgents = [] const useAgents = []
@@ -194,10 +231,10 @@ async function run(options: RunOptions = {}) {
}); });
} }
}); });
server.addHook("onError", async (request, reply, error) => { server.addHook("onError", async (request: any, reply: any, error: any) => {
event.emit('onError', request, reply, error); event.emit('onError', request, reply, error);
}) })
server.addHook("onSend", (req, reply, payload, done) => { 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 (req.sessionId && req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
if (payload instanceof ReadableStream) { if (payload instanceof ReadableStream) {
if (req.agents) { if (req.agents) {
@@ -281,7 +318,7 @@ async function run(options: RunOptions = {}) {
if (!response.ok) { if (!response.ok) {
return undefined; return undefined;
} }
const stream = response.body!.pipeThrough(new SSEParserTransform()) const stream = response.body!.pipeThrough(new SSEParserTransform() as any)
const reader = stream.getReader() const reader = stream.getReader()
while (true) { while (true) {
try { try {
@@ -289,7 +326,8 @@ async function run(options: RunOptions = {}) {
if (done) { if (done) {
break; break;
} }
if (['message_start', 'message_stop'].includes(value.event)) { const eventData = value as any;
if (['message_start', 'message_stop'].includes(eventData.event)) {
continue continue
} }
@@ -298,7 +336,7 @@ async function run(options: RunOptions = {}) {
break; break;
} }
controller.enqueue(value) controller.enqueue(eventData)
}catch (readError: any) { }catch (readError: any) {
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') { if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
abortController.abort(); // 中止所有相关操作 abortController.abort(); // 中止所有相关操作
@@ -371,7 +409,7 @@ async function run(options: RunOptions = {}) {
} }
done(null, payload) done(null, payload)
}); });
server.addHook("onSend", async (req, reply, payload) => { server.addHook("onSend", async (req: any, reply: any, payload: any) => {
event.emit('onSend', req, reply, payload); event.emit('onSend', req, reply, payload);
return payload; return payload;
}) })
@@ -381,4 +419,11 @@ async function run(options: RunOptions = {}) {
} }
export { run }; export { run };
// run();
// 如果是直接运行此文件,则启动服务
if (require.main === module) {
run().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
}

View File

@@ -8,6 +8,13 @@ export const apiKeyAuth =
return done(); 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; const apiKey = config.APIKEY;
if (!apiKey) { if (!apiKey) {
// If no API key is set, enable CORS for local // If no API key is set, enable CORS for local

View File

@@ -1,29 +1,28 @@
import Server from "@musistudio/llms"; import Server from "@musistudio/llms";
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils"; import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { checkForUpdates, performUpdate } from "./utils";
import { join } from "path"; import { join } from "path";
import fastifyStatic from "@fastify/static"; import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs"; import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import {calculateTokenCount} from "./utils/router"; import {calculateTokenCount} from "./utils/router";
export const createServer = (config: any): Server => { export const createServer = (config: any): any => {
const server = new Server(config); const server = new Server(config);
server.app.post("/v1/messages/count_tokens", async (req, reply) => { server.app.post("/v1/messages/count_tokens", async (req: any, reply: any) => {
const {messages, tools, system} = req.body; const {messages, tools, system} = req.body;
const tokenCount = calculateTokenCount(messages, system, tools); const tokenCount = calculateTokenCount(messages, system, tools);
return { "input_tokens": tokenCount } return { "input_tokens": tokenCount }
}); });
// Add endpoint to read config.json with access control // Add endpoint to read config.json with access control
server.app.get("/api/config", async (req, reply) => { server.app.get("/api/config", async (req: any, reply: any) => {
return await readConfigFile(); return await readConfigFile();
}); });
server.app.get("/api/transformers", async () => { server.app.get("/api/transformers", async (req: any, reply: any) => {
const transformers = const transformers =
server.app._server!.transformerService.getAllTransformers(); (server.app as any)._server!.transformerService.getAllTransformers();
const transformerList = Array.from(transformers.entries()).map( const transformerList = Array.from(transformers.entries()).map(
([name, transformer]: any) => ({ ([name, transformer]: any) => ({
name, name,
@@ -34,7 +33,7 @@ export const createServer = (config: any): Server => {
}); });
// Add endpoint to save config.json with access control // Add endpoint to save config.json with access control
server.app.post("/api/config", async (req, reply) => { server.app.post("/api/config", async (req: any, reply: any) => {
const newConfig = req.body; const newConfig = req.body;
// Backup existing config file if it exists // Backup existing config file if it exists
@@ -48,7 +47,7 @@ export const createServer = (config: any): Server => {
}); });
// Add endpoint to restart the service with access control // Add endpoint to restart the service with access control
server.app.post("/api/restart", async (req, reply) => { server.app.post("/api/restart", async (req: any, reply: any) => {
reply.send({ success: true, message: "Service restart initiated" }); reply.send({ success: true, message: "Service restart initiated" });
// Restart the service after a short delay to allow response to be sent // Restart the service after a short delay to allow response to be sent
@@ -69,50 +68,12 @@ export const createServer = (config: any): Server => {
}); });
// Redirect /ui to /ui/ for proper static file serving // Redirect /ui to /ui/ for proper static file serving
server.app.get("/ui", async (_, reply) => { server.app.get("/ui", async (_: any, reply: any) => {
return reply.redirect("/ui/"); return reply.redirect("/ui/");
}); });
// 版本检查端点
server.app.get("/api/update/check", async (req, reply) => {
try {
// 获取当前版本
const currentVersion = require("../package.json").version;
const { hasUpdate, latestVersion, changelog } = await checkForUpdates(currentVersion);
return {
hasUpdate,
latestVersion: hasUpdate ? latestVersion : undefined,
changelog: hasUpdate ? changelog : undefined
};
} catch (error) {
console.error("Failed to check for updates:", error);
reply.status(500).send({ error: "Failed to check for updates" });
}
});
// 执行更新端点
server.app.post("/api/update/perform", async (req, reply) => {
try {
// 只允许完全访问权限的用户执行更新
const accessLevel = (req as any).accessLevel || "restricted";
if (accessLevel !== "full") {
reply.status(403).send("Full access required to perform updates");
return;
}
// 执行更新逻辑
const result = await performUpdate();
return result;
} catch (error) {
console.error("Failed to perform update:", error);
reply.status(500).send({ error: "Failed to perform update" });
}
});
// 获取日志文件列表端点 // 获取日志文件列表端点
server.app.get("/api/logs/files", async (req, reply) => { server.app.get("/api/logs/files", async (req: any, reply: any) => {
try { try {
const logDir = join(homedir(), ".claude-code-router", "logs"); const logDir = join(homedir(), ".claude-code-router", "logs");
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = []; const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
@@ -146,7 +107,7 @@ export const createServer = (config: any): Server => {
}); });
// 获取日志内容端点 // 获取日志内容端点
server.app.get("/api/logs", async (req, reply) => { server.app.get("/api/logs", async (req: any, reply: any) => {
try { try {
const filePath = (req.query as any).file as string; const filePath = (req.query as any).file as string;
let logFilePath: string; let logFilePath: string;
@@ -174,7 +135,7 @@ export const createServer = (config: any): Server => {
}); });
// 清除日志内容端点 // 清除日志内容端点
server.app.delete("/api/logs", async (req, reply) => { server.app.delete("/api/logs", async (req: any, reply: any) => {
try { try {
const filePath = (req.query as any).file as string; const filePath = (req.query as any).file as string;
let logFilePath: string; let logFilePath: string;

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

@@ -5,9 +5,7 @@ export class SSEParserTransform extends TransformStream<string, any> {
constructor() { constructor() {
super({ super({
transform: (chunk: string, controller) => { transform: (chunk: string, controller) => {
const decoder = new TextDecoder(); this.buffer += chunk;
const text = decoder.decode(chunk);
this.buffer += text;
const lines = this.buffer.split('\n'); const lines = this.buffer.split('\n');
// 保留最后一行(可能不完整) // 保留最后一行(可能不完整)

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

@@ -1,16 +1,35 @@
import {
MessageCreateParamsBase,
MessageParam,
Tool,
} from "@anthropic-ai/sdk/resources/messages";
import { get_encoding } from "tiktoken"; import { get_encoding } from "tiktoken";
import { sessionUsageCache, Usage } from "./cache"; import { sessionUsageCache, Usage } from "./cache";
import { readFile, access } from "fs/promises"; import { readFile, access } from "fs/promises";
import { opendir, stat } from "fs/promises"; import { opendir, stat } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { CLAUDE_PROJECTS_DIR, HOME_DIR } from "../constants"; import { CLAUDE_PROJECTS_DIR, HOME_DIR } from "@musistudio/claude-code-router-shared";
import { LRUCache } from "lru-cache"; 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"); const enc = get_encoding("cl100k_base");
export const calculateTokenCount = ( export const calculateTokenCount = (
@@ -232,7 +251,7 @@ export const router = async (req: any, _res: any, context: any) => {
// 内存缓存存储sessionId到项目名称的映射 // 内存缓存存储sessionId到项目名称的映射
// null值表示之前已查找过但未找到项目 // null值表示之前已查找过但未找到项目
// 使用LRU缓存限制最大1000个条目 // 使用LRU缓存限制最大1000个条目
const sessionProjectCache = new LRUCache<string, string | null>({ const sessionProjectCache = new LRUCache<string, string>({
max: 1000, max: 1000,
}); });
@@ -241,7 +260,11 @@ export const searchProjectBySession = async (
): Promise<string | null> => { ): Promise<string | null> => {
// 首先检查缓存 // 首先检查缓存
if (sessionProjectCache.has(sessionId)) { if (sessionProjectCache.has(sessionId)) {
return sessionProjectCache.get(sessionId)!; const result = sessionProjectCache.get(sessionId);
if (!result || result === '') {
return null;
}
return result;
} }
try { try {
@@ -283,12 +306,12 @@ export const searchProjectBySession = async (
} }
// 缓存未找到的结果null值表示之前已查找过但未找到项目 // 缓存未找到的结果null值表示之前已查找过但未找到项目
sessionProjectCache.set(sessionId, null); sessionProjectCache.set(sessionId, '');
return null; // 没有找到匹配的项目 return null; // 没有找到匹配的项目
} catch (error) { } catch (error) {
console.error("Error searching for project by session:", error); console.error("Error searching for project by session:", error);
// 出错时也缓存null结果避免重复出错 // 出错时也缓存null结果避免重复出错
sessionProjectCache.set(sessionId, null); sessionProjectCache.set(sessionId, '');
return null; 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"]
}

View File

@@ -0,0 +1,23 @@
{
"name": "@musistudio/claude-code-router-shared",
"version": "1.0.73",
"description": "Shared utilities and constants for Claude Code Router",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "node ../../scripts/build-shared.js"
},
"keywords": [
"claude",
"code",
"router",
"shared"
],
"author": "musistudio",
"license": "MIT",
"devDependencies": {
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"typescript": "^5.8.2"
}
}

View File

@@ -0,0 +1 @@
export * from "./constants";

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "temp-project", "name": "@musistudio/claude-code-router-ui",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.73",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

3632
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'packages/*'

90
scripts/build-cli.js Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
console.log('Building CLI package...');
try {
const rootDir = path.join(__dirname, '..');
const sharedDir = path.join(rootDir, 'packages/shared');
const cliDir = path.join(rootDir, 'packages/cli');
const serverDir = path.join(rootDir, 'packages/server');
const uiDir = path.join(rootDir, 'packages/ui');
// Step 0: Ensure shared package is built first
console.log('Ensuring shared package is built...');
const sharedDistDir = path.join(sharedDir, 'dist');
if (!fs.existsSync(sharedDistDir) || !fs.existsSync(path.join(sharedDistDir, 'index.js'))) {
console.log('Shared package not found, building it first...');
execSync('node scripts/build-shared.js', {
stdio: 'inherit',
cwd: rootDir
});
}
// Step 1: Build Server package first
console.log('Building Server package...');
execSync('node scripts/build-server.js', {
stdio: 'inherit',
cwd: rootDir
});
// Step 2: Build UI package
console.log('Building UI package...');
execSync('pnpm build', {
stdio: 'inherit',
cwd: uiDir
});
// Step 3: Create CLI dist directory
const cliDistDir = path.join(cliDir, 'dist');
if (!fs.existsSync(cliDistDir)) {
fs.mkdirSync(cliDistDir, { recursive: true });
}
// Step 4: Build the CLI application
console.log('Building CLI application...');
execSync('esbuild src/cli.ts --bundle --platform=node --outfile=dist/cli.js', {
stdio: 'inherit',
cwd: cliDir
});
// Step 5: Copy tiktoken WASM file from server dist to CLI dist
console.log('Copying tiktoken_bg.wasm from server to CLI dist...');
const tiktokenSource = path.join(serverDir, 'dist/tiktoken_bg.wasm');
const tiktokenDest = path.join(cliDistDir, 'tiktoken_bg.wasm');
if (fs.existsSync(tiktokenSource)) {
fs.copyFileSync(tiktokenSource, tiktokenDest);
console.log('✓ tiktoken_bg.wasm copied successfully!');
} else {
console.warn('⚠ Warning: tiktoken_bg.wasm not found in server dist, skipping...');
}
// Step 6: Copy UI index.html from UI dist to CLI dist
console.log('Copying index.html from UI to CLI dist...');
const uiSource = path.join(uiDir, 'dist/index.html');
const uiDest = path.join(cliDistDir, 'index.html');
if (fs.existsSync(uiSource)) {
fs.copyFileSync(uiSource, uiDest);
console.log('✓ index.html copied successfully!');
} else {
console.warn('⚠ Warning: index.html not found in UI dist, skipping...');
}
console.log('CLI build completed successfully!');
console.log('\nCLI dist contents:');
const files = fs.readdirSync(cliDistDir);
files.forEach(file => {
const filePath = path.join(cliDistDir, file);
const stats = fs.statSync(filePath);
const size = (stats.size / 1024 / 1024).toFixed(2);
console.log(` - ${file} (${size} MB)`);
});
} catch (error) {
console.error('CLI build failed:', error.message);
process.exit(1);
}

40
scripts/build-server.js Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
console.log('Building Server package...');
try {
// Create dist directory
const distDir = path.join(__dirname, '../packages/server/dist');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Build the server application
console.log('Building server application...');
// 使用 minify 和 tree-shaking 优化体积
execSync('esbuild src/index.ts --bundle --platform=node --minify --tree-shaking=true --outfile=dist/index.js', {
stdio: 'inherit',
cwd: path.join(__dirname, '../packages/server')
});
// Copy the tiktoken WASM file
console.log('Copying tiktoken WASM file...');
const tiktokenSource = path.join(__dirname, '../packages/server/node_modules/tiktoken/tiktoken_bg.wasm');
const tiktokenDest = path.join(__dirname, '../packages/server/dist/tiktoken_bg.wasm');
if (fs.existsSync(tiktokenSource)) {
fs.copyFileSync(tiktokenSource, tiktokenDest);
console.log('Tiktoken WASM file copied successfully!');
} else {
console.warn('Warning: tiktoken_bg.wasm not found, skipping...');
}
console.log('Server build completed successfully!');
} catch (error) {
console.error('Server build failed:', error.message);
process.exit(1);
}

36
scripts/build-shared.js Normal file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
console.log('Building Shared package...');
try {
const sharedDir = path.join(__dirname, '../packages/shared');
// Create dist directory
const distDir = path.join(sharedDir, 'dist');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Generate type declaration files
console.log('Generating type declaration files...');
execSync('tsc --emitDeclarationOnly', {
stdio: 'inherit',
cwd: sharedDir
});
// Build the shared package
console.log('Building shared package...');
execSync('esbuild src/index.ts --bundle --platform=node --minify --tree-shaking=true --outfile=dist/index.js', {
stdio: 'inherit',
cwd: sharedDir
});
console.log('Shared package build completed successfully!');
} catch (error) {
console.error('Shared package build failed:', error.message);
process.exit(1);
}

Some files were not shown because too many files have changed in this diff Show More