mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-02-18 06:30:50 +08:00
fix preset error
This commit is contained in:
@@ -39,11 +39,14 @@
|
||||
"google-auth-library": "^10.1.0",
|
||||
"json5": "^2.2.3",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"lru-cache": "^11.2.2",
|
||||
"openai": "^5.6.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@CCR/shared": "workspace:*",
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"tsx": "^4.20.3",
|
||||
|
||||
@@ -10,7 +10,7 @@ const baseConfig: esbuild.BuildOptions = {
|
||||
platform: "node",
|
||||
target: "node18",
|
||||
plugins: [],
|
||||
external: ["fastify", "dotenv", "@fastify/cors", "undici"],
|
||||
external: ["fastify", "dotenv", "@fastify/cors", "undici", "tiktoken", "@CCR/shared", "lru-cache"],
|
||||
};
|
||||
|
||||
const cjsConfig: esbuild.BuildOptions = {
|
||||
|
||||
@@ -30,6 +30,8 @@ import { errorHandler } from "./api/middleware";
|
||||
import { registerApiRoutes } from "./api/routes";
|
||||
import { ProviderService } from "./services/provider";
|
||||
import { TransformerService } from "./services/transformer";
|
||||
import { router, calculateTokenCount, searchProjectBySession } from "./utils/router";
|
||||
import { sessionUsageCache } from "./utils/cache";
|
||||
|
||||
// Extend FastifyRequest to include custom properties
|
||||
declare module "fastify" {
|
||||
@@ -125,6 +127,15 @@ class Server {
|
||||
fastify.decorate('configService', this.configService);
|
||||
fastify.decorate('transformerService', this.transformerService);
|
||||
fastify.decorate('providerService', this.providerService);
|
||||
// Add router hook for main namespace
|
||||
fastify.addHook('preHandler', async (req: any, reply: any) => {
|
||||
const url = new URL(`http://127.0.0.1${req.url}`);
|
||||
if (url.pathname.endsWith("/v1/messages")) {
|
||||
await router(req, reply, {
|
||||
configService: this.configService,
|
||||
});
|
||||
}
|
||||
});
|
||||
await registerApiRoutes(fastify);
|
||||
});
|
||||
return
|
||||
@@ -133,6 +144,7 @@ class Server {
|
||||
const configService = new ConfigService({
|
||||
initialConfig: {
|
||||
providers: options.Providers,
|
||||
Router: options.Router,
|
||||
}
|
||||
});
|
||||
const transformerService = new TransformerService(
|
||||
@@ -145,15 +157,19 @@ class Server {
|
||||
transformerService,
|
||||
this.app.log
|
||||
);
|
||||
// await this.app.register((fastify) => {
|
||||
// fastify.decorate('configService', configService);
|
||||
// fastify.decorate('transformerService', transformerService);
|
||||
// fastify.decorate('providerService', providerService);
|
||||
// }, { prefix: name });
|
||||
await this.app.register(async (fastify) => {
|
||||
fastify.decorate('configService', configService);
|
||||
fastify.decorate('transformerService', transformerService);
|
||||
fastify.decorate('providerService', providerService);
|
||||
// Add router hook for namespace
|
||||
fastify.addHook('preHandler', async (req: any, reply: any) => {
|
||||
const url = new URL(`http://127.0.0.1${req.url}`);
|
||||
if (url.pathname.endsWith("/v1/messages")) {
|
||||
await router(req, reply, {
|
||||
configService,
|
||||
});
|
||||
}
|
||||
});
|
||||
await registerApiRoutes(fastify);
|
||||
}, { prefix: name });
|
||||
}
|
||||
@@ -174,6 +190,8 @@ class Server {
|
||||
done();
|
||||
});
|
||||
|
||||
await this.registerNamespace('/')
|
||||
|
||||
this.app.addHook(
|
||||
"preHandler",
|
||||
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
@@ -198,7 +216,6 @@ class Server {
|
||||
}
|
||||
);
|
||||
|
||||
await this.registerNamespace('/')
|
||||
|
||||
const address = await this.app.listen({
|
||||
port: parseInt(this.configService.get("PORT") || "3000", 10),
|
||||
@@ -224,3 +241,10 @@ class Server {
|
||||
|
||||
// Export for external use
|
||||
export default Server;
|
||||
export { sessionUsageCache };
|
||||
export { router };
|
||||
export { calculateTokenCount };
|
||||
export { searchProjectBySession };
|
||||
export { ConfigService } from "./services/config";
|
||||
export { ProviderService } from "./services/provider";
|
||||
export { TransformerService } from "./services/transformer";
|
||||
|
||||
47
packages/core/src/utils/cache.ts
Normal file
47
packages/core/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);
|
||||
325
packages/core/src/utils/router.ts
Normal file
325
packages/core/src/utils/router.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { get_encoding } from "tiktoken";
|
||||
import { sessionUsageCache, Usage } from "./cache";
|
||||
import { readFile } from "fs/promises";
|
||||
import { opendir, stat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { CLAUDE_PROJECTS_DIR, HOME_DIR } from "@CCR/shared";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { ConfigService } from "../services/config";
|
||||
|
||||
// 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 getProjectSpecificRouter = async (
|
||||
req: any,
|
||||
configService: ConfigService
|
||||
) => {
|
||||
// 检查是否有项目特定的配置
|
||||
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文件
|
||||
try {
|
||||
const sessionConfig = JSON.parse(await readFile(sessionConfigPath, "utf8"));
|
||||
if (sessionConfig && sessionConfig.Router) {
|
||||
return sessionConfig.Router;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const projectConfig = JSON.parse(await readFile(projectConfigPath, "utf8"));
|
||||
if (projectConfig && projectConfig.Router) {
|
||||
return projectConfig.Router;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return undefined; // 返回undefined表示使用原始配置
|
||||
};
|
||||
|
||||
const getUseModel = async (
|
||||
req: any,
|
||||
tokenCount: number,
|
||||
configService: ConfigService,
|
||||
lastUsage?: Usage | undefined
|
||||
) => {
|
||||
const projectSpecificRouter = await getProjectSpecificRouter(req, configService);
|
||||
const providers = configService.get<any[]>("providers") || [];
|
||||
const Router = projectSpecificRouter || configService.get("Router");
|
||||
|
||||
if (req.body.model.includes(",")) {
|
||||
const [provider, model] = req.body.model.split(",");
|
||||
const finalProvider = 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
|
||||
const globalRouter = configService.get("Router");
|
||||
if (
|
||||
req.body.model?.includes("claude") &&
|
||||
req.body.model?.includes("haiku") &&
|
||||
globalRouter?.background
|
||||
) {
|
||||
req.log.info(`Using background model for ${req.body.model}`);
|
||||
return globalRouter.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 interface RouterContext {
|
||||
configService: ConfigService;
|
||||
event?: any;
|
||||
}
|
||||
|
||||
export const router = async (req: any, _res: any, context: RouterContext) => {
|
||||
const { configService, 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;
|
||||
const rewritePrompt = configService.get("REWRITE_SYSTEM_PROMPT");
|
||||
if (
|
||||
rewritePrompt &&
|
||||
system.length > 1 &&
|
||||
system[1]?.text?.includes("<env>")
|
||||
) {
|
||||
const prompt = await readFile(rewritePrompt, "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;
|
||||
const customRouterPath = configService.get("CUSTOM_ROUTER_PATH");
|
||||
if (customRouterPath) {
|
||||
try {
|
||||
const customRouter = require(customRouterPath);
|
||||
req.tokenCount = tokenCount; // Pass token count to custom router
|
||||
model = await customRouter(req, configService.getAll(), {
|
||||
event,
|
||||
});
|
||||
} catch (e: any) {
|
||||
req.log.error(`failed to load custom router: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
model = await getUseModel(req, tokenCount, configService, lastMessageUsage);
|
||||
}
|
||||
req.body.model = model;
|
||||
} catch (error: any) {
|
||||
req.log.error(`Error in router middleware: ${error.message}`);
|
||||
const Router = configService.get("Router");
|
||||
req.body.model = 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user