llm-simple-router 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +12 -14
  2. package/dist/admin/groups.js +25 -0
  3. package/dist/admin/providers.d.ts +0 -1
  4. package/dist/admin/providers.js +16 -13
  5. package/dist/admin/proxy-enhancement.d.ts +7 -0
  6. package/dist/admin/proxy-enhancement.js +39 -0
  7. package/dist/admin/router-keys.d.ts +0 -1
  8. package/dist/admin/router-keys.js +17 -8
  9. package/dist/admin/routes.d.ts +0 -3
  10. package/dist/admin/routes.js +9 -4
  11. package/dist/admin/setup.d.ts +7 -0
  12. package/dist/admin/setup.js +44 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +4 -0
  15. package/dist/config.d.ts +1 -4
  16. package/dist/config.js +13 -13
  17. package/dist/db/index.d.ts +5 -2
  18. package/dist/db/index.js +3 -1
  19. package/dist/db/logs.d.ts +5 -2
  20. package/dist/db/logs.js +4 -4
  21. package/dist/db/mappings.d.ts +16 -0
  22. package/dist/db/mappings.js +72 -0
  23. package/dist/db/migrations/014_create_settings.sql +4 -0
  24. package/dist/db/migrations/015_add_original_model.sql +1 -0
  25. package/dist/db/migrations/016_create_session_model_tables.sql +24 -0
  26. package/dist/db/session-states.d.ts +40 -0
  27. package/dist/db/session-states.js +37 -0
  28. package/dist/db/settings.d.ts +4 -0
  29. package/dist/db/settings.js +10 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +53 -13
  32. package/dist/middleware/admin-auth.d.ts +2 -2
  33. package/dist/middleware/admin-auth.js +21 -8
  34. package/dist/middleware/auth.js +46 -1
  35. package/dist/proxy/anthropic.d.ts +0 -1
  36. package/dist/proxy/anthropic.js +2 -2
  37. package/dist/proxy/directive-parser.d.ts +7 -0
  38. package/dist/proxy/directive-parser.js +70 -0
  39. package/dist/proxy/enhancement-handler.d.ts +23 -0
  40. package/dist/proxy/enhancement-handler.js +167 -0
  41. package/dist/proxy/log-helpers.d.ts +41 -0
  42. package/dist/proxy/log-helpers.js +35 -0
  43. package/dist/proxy/mapping-resolver.js +39 -2
  44. package/dist/proxy/model-state.d.ts +28 -0
  45. package/dist/proxy/model-state.js +111 -0
  46. package/dist/proxy/openai.d.ts +0 -1
  47. package/dist/proxy/openai.js +4 -3
  48. package/dist/proxy/proxy-core.d.ts +9 -47
  49. package/dist/proxy/proxy-core.js +215 -344
  50. package/dist/proxy/response-cleaner.d.ts +5 -0
  51. package/dist/proxy/response-cleaner.js +60 -0
  52. package/dist/proxy/strategy/failover.d.ts +1 -1
  53. package/dist/proxy/strategy/failover.js +10 -2
  54. package/dist/proxy/strategy/random.d.ts +1 -1
  55. package/dist/proxy/strategy/random.js +8 -2
  56. package/dist/proxy/strategy/round-robin.d.ts +2 -1
  57. package/dist/proxy/strategy/round-robin.js +13 -2
  58. package/dist/proxy/strategy/targets-rule.d.ts +7 -0
  59. package/dist/proxy/strategy/targets-rule.js +14 -0
  60. package/dist/proxy/strategy/types.d.ts +5 -1
  61. package/dist/proxy/strategy/types.js +3 -0
  62. package/dist/proxy/upstream-call.d.ts +43 -0
  63. package/dist/proxy/upstream-call.js +208 -0
  64. package/dist/utils/password.d.ts +2 -0
  65. package/dist/utils/password.js +14 -0
  66. package/package.json +6 -5
  67. package/.env.example +0 -13
@@ -53,3 +53,75 @@ export function updateMappingGroup(db, id, fields) {
53
53
  export function deleteMappingGroup(db, id) {
54
54
  deleteById(db, "mapping_groups", id);
55
55
  }
56
+ /** 从 providers.models 获取所有可用模型 */
57
+ export function getActiveProviderModels(db) {
58
+ const providers = db.prepare("SELECT name, models, is_active FROM providers WHERE is_active = 1").all();
59
+ const results = [];
60
+ for (const p of providers) {
61
+ try {
62
+ const models = JSON.parse(p.models);
63
+ for (const m of models) {
64
+ results.push({ provider_name: p.name, backend_model: m });
65
+ }
66
+ }
67
+ catch { /* 忽略解析失败 */ }
68
+ }
69
+ return results;
70
+ }
71
+ function isTargetLike(obj) {
72
+ return typeof obj === "object" && obj !== null &&
73
+ typeof obj.backend_model === "string" &&
74
+ typeof obj.provider_id === "string";
75
+ }
76
+ function extractTargets(rule) {
77
+ const results = [];
78
+ if (isTargetLike(rule.default))
79
+ results.push(rule.default);
80
+ if (Array.isArray(rule.targets)) {
81
+ for (const t of rule.targets) {
82
+ if (isTargetLike(t))
83
+ results.push(t);
84
+ }
85
+ }
86
+ if (Array.isArray(rule.windows)) {
87
+ for (const w of rule.windows) {
88
+ if (w && typeof w === "object" && isTargetLike(w.target)) {
89
+ results.push(w.target);
90
+ }
91
+ }
92
+ }
93
+ return results;
94
+ }
95
+ /**
96
+ * 根据 "provider_name/backend_model" 验证模型是否存在于 provider 配置中。
97
+ * 同时尝试从 mapping_groups 中找到对应的 client_model 用于路由。
98
+ * 如果找不到 mapping,返回 backend_model 本身(由 proxy-core 兜底处理)。
99
+ */
100
+ export function resolveByProviderModel(db, providerName, backendModel) {
101
+ const providerRow = db.prepare("SELECT id, models FROM providers WHERE name = ? AND is_active = 1").get(providerName);
102
+ if (!providerRow)
103
+ return null;
104
+ try {
105
+ const models = JSON.parse(providerRow.models);
106
+ if (!models.includes(backendModel))
107
+ return null;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ // 尝试从 mapping_groups 找到包含此 provider+backend_model 的 client_model
113
+ const groups = db.prepare("SELECT client_model, rule FROM mapping_groups").all();
114
+ for (const g of groups) {
115
+ try {
116
+ const rule = JSON.parse(g.rule);
117
+ const targets = extractTargets(rule);
118
+ const match = targets.find(t => t.provider_id === providerRow.id && t.backend_model === backendModel);
119
+ if (match) {
120
+ return { client_model: g.client_model, provider_id: providerRow.id, backend_model: backendModel };
121
+ }
122
+ }
123
+ catch { /* continue */ }
124
+ }
125
+ // provider 有这个模型但没有 mapping group,直接返回 provider 维度信息
126
+ return { client_model: backendModel, provider_id: providerRow.id, backend_model: backendModel };
127
+ }
@@ -0,0 +1,4 @@
1
+ CREATE TABLE IF NOT EXISTS settings (
2
+ key TEXT PRIMARY KEY,
3
+ value TEXT NOT NULL
4
+ );
@@ -0,0 +1 @@
1
+ ALTER TABLE request_logs ADD COLUMN original_model TEXT;
@@ -0,0 +1,24 @@
1
+ CREATE TABLE IF NOT EXISTS session_model_states (
2
+ id TEXT PRIMARY KEY,
3
+ router_key_id TEXT NOT NULL,
4
+ session_id TEXT NOT NULL,
5
+ current_model TEXT NOT NULL,
6
+ original_model TEXT,
7
+ last_active_at TEXT NOT NULL,
8
+ created_at TEXT NOT NULL,
9
+ UNIQUE(router_key_id, session_id),
10
+ FOREIGN KEY (router_key_id) REFERENCES router_keys(id)
11
+ );
12
+ CREATE INDEX idx_sms_router_key ON session_model_states(router_key_id);
13
+
14
+ CREATE TABLE IF NOT EXISTS session_model_history (
15
+ id TEXT PRIMARY KEY,
16
+ router_key_id TEXT NOT NULL,
17
+ session_id TEXT NOT NULL,
18
+ old_model TEXT,
19
+ new_model TEXT NOT NULL,
20
+ trigger_type TEXT NOT NULL,
21
+ created_at TEXT NOT NULL,
22
+ FOREIGN KEY (router_key_id) REFERENCES router_keys(id)
23
+ );
24
+ CREATE INDEX idx_smh_session ON session_model_history(router_key_id, session_id);
@@ -0,0 +1,40 @@
1
+ import Database from "better-sqlite3";
2
+ export interface SessionModelState {
3
+ id: string;
4
+ router_key_id: string;
5
+ router_key_name?: string;
6
+ session_id: string;
7
+ current_model: string;
8
+ original_model: string | null;
9
+ last_active_at: string;
10
+ created_at: string;
11
+ }
12
+ export interface SessionModelHistory {
13
+ id: string;
14
+ router_key_id: string;
15
+ session_id: string;
16
+ old_model: string | null;
17
+ new_model: string;
18
+ trigger_type: string;
19
+ created_at: string;
20
+ }
21
+ export interface UpsertSessionStateInput {
22
+ id?: string;
23
+ router_key_id: string;
24
+ session_id: string;
25
+ current_model: string;
26
+ original_model?: string | null;
27
+ }
28
+ export interface InsertSessionHistoryInput {
29
+ router_key_id: string;
30
+ session_id: string;
31
+ old_model?: string | null;
32
+ new_model: string;
33
+ trigger_type: string;
34
+ }
35
+ export declare function getSessionStates(db: Database.Database): SessionModelState[];
36
+ export declare function getSessionHistory(db: Database.Database, routerKeyId: string, sessionId: string): SessionModelHistory[];
37
+ export declare function upsertSessionState(db: Database.Database, input: UpsertSessionStateInput): string;
38
+ export declare function insertSessionHistory(db: Database.Database, input: InsertSessionHistoryInput): string;
39
+ export declare function deleteSessionState(db: Database.Database, routerKeyId: string, sessionId: string): void;
40
+ export declare function getSessionState(db: Database.Database, routerKeyId: string, sessionId: string): SessionModelState | undefined;
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from "crypto";
2
+ // --- CRUD ---
3
+ export function getSessionStates(db) {
4
+ return db.prepare(`SELECT sms.*, rk.name AS router_key_name
5
+ FROM session_model_states sms
6
+ JOIN router_keys rk ON rk.id = sms.router_key_id
7
+ ORDER BY sms.last_active_at DESC`).all();
8
+ }
9
+ export function getSessionHistory(db, routerKeyId, sessionId) {
10
+ return db.prepare(`SELECT * FROM session_model_history
11
+ WHERE router_key_id = ? AND session_id = ?
12
+ ORDER BY created_at DESC`).all(routerKeyId, sessionId);
13
+ }
14
+ export function upsertSessionState(db, input) {
15
+ const now = new Date().toISOString();
16
+ const id = input.id ?? randomUUID();
17
+ db.prepare(`INSERT INTO session_model_states (id, router_key_id, session_id, current_model, original_model, last_active_at, created_at)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?)
19
+ ON CONFLICT(router_key_id, session_id) DO UPDATE SET
20
+ current_model = excluded.current_model,
21
+ original_model = excluded.original_model,
22
+ last_active_at = excluded.last_active_at`).run(id, input.router_key_id, input.session_id, input.current_model, input.original_model ?? null, now, now);
23
+ return id;
24
+ }
25
+ export function insertSessionHistory(db, input) {
26
+ const id = randomUUID();
27
+ const now = new Date().toISOString();
28
+ db.prepare(`INSERT INTO session_model_history (id, router_key_id, session_id, old_model, new_model, trigger_type, created_at)
29
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, input.router_key_id, input.session_id, input.old_model ?? null, input.new_model, input.trigger_type, now);
30
+ return id;
31
+ }
32
+ export function deleteSessionState(db, routerKeyId, sessionId) {
33
+ db.prepare(`DELETE FROM session_model_states WHERE router_key_id = ? AND session_id = ?`).run(routerKeyId, sessionId);
34
+ }
35
+ export function getSessionState(db, routerKeyId, sessionId) {
36
+ return db.prepare(`SELECT * FROM session_model_states WHERE router_key_id = ? AND session_id = ?`).get(routerKeyId, sessionId);
37
+ }
@@ -0,0 +1,4 @@
1
+ import Database from "better-sqlite3";
2
+ export declare function getSetting(db: Database.Database, key: string): string | null;
3
+ export declare function setSetting(db: Database.Database, key: string, value: string): void;
4
+ export declare function isInitialized(db: Database.Database): boolean;
@@ -0,0 +1,10 @@
1
+ export function getSetting(db, key) {
2
+ const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
3
+ return row?.value ?? null;
4
+ }
5
+ export function setSetting(db, key, value) {
6
+ db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);
7
+ }
8
+ export function isInitialized(db) {
9
+ return getSetting(db, "initialized") === "true";
10
+ }
package/dist/index.d.ts CHANGED
@@ -11,3 +11,4 @@ export declare function buildApp(options?: AppOptions): Promise<{
11
11
  db: Database.Database;
12
12
  close: () => Promise<void>;
13
13
  }>;
14
+ export declare function main(): Promise<void>;
package/dist/index.js CHANGED
@@ -2,8 +2,20 @@
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { existsSync } from "node:fs";
5
+ import { randomUUID } from "crypto";
5
6
  import Fastify from "fastify";
7
+ import { insertRequestLog } from "./db/logs.js";
6
8
  const HTTP_NOT_FOUND = 404;
9
+ // 代理路由路径 → api_type,用于在全局 hook/errorHandler 中识别代理请求
10
+ const PROXY_API_TYPES = {
11
+ "/v1/chat/completions": "openai",
12
+ "/v1/messages": "anthropic",
13
+ "/v1/models": "openai",
14
+ };
15
+ function getProxyApiType(url) {
16
+ const path = url.split("?")[0];
17
+ return PROXY_API_TYPES[path] ?? null;
18
+ }
7
19
  const __filename = fileURLToPath(import.meta.url);
8
20
  const __dirname = path.dirname(__filename);
9
21
  import { getConfig } from "./config.js";
@@ -13,9 +25,10 @@ import { openaiProxy } from "./proxy/openai.js";
13
25
  import { anthropicProxy } from "./proxy/anthropic.js";
14
26
  import { adminRoutes } from "./admin/routes.js";
15
27
  import { RetryRuleMatcher } from "./proxy/retry-rules.js";
28
+ import { modelState } from "./proxy/model-state.js";
16
29
  import fastifyStatic from "@fastify/static";
17
30
  export async function buildApp(options) {
18
- const config = options?.config ?? getConfig();
31
+ const config = options?.config ?? getBaseConfig();
19
32
  // 允许外部传入已初始化的 DB(测试用),否则自行创建
20
33
  let db;
21
34
  if (options?.db) {
@@ -24,9 +37,21 @@ export async function buildApp(options) {
24
37
  else {
25
38
  db = initDatabase(config.DB_PATH);
26
39
  }
40
+ const isDev = process.env.NODE_ENV !== "production";
27
41
  const app = Fastify({
28
42
  logger: {
29
43
  level: config.LOG_LEVEL,
44
+ ...(isDev
45
+ ? {
46
+ transport: {
47
+ target: "pino-pretty",
48
+ options: {
49
+ translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
50
+ ignore: "pid,hostname",
51
+ },
52
+ },
53
+ }
54
+ : {}),
30
55
  },
31
56
  // 统一 schema validation 错误格式为 { error: { message } }
32
57
  ajv: {
@@ -44,10 +69,28 @@ export async function buildApp(options) {
44
69
  .join("; ");
45
70
  return new Error(message);
46
71
  });
47
- // 统一 schema validation 错误响应格式
48
- app.setErrorHandler((error, _request, reply) => {
72
+ // 统一 schema validation 错误响应格式,代理路由的错误也记录到 request_logs
73
+ app.setErrorHandler((error, request, reply) => {
49
74
  const fastifyError = error;
50
75
  const status = fastifyError.statusCode ?? 500;
76
+ const proxyApiType = getProxyApiType(request.url);
77
+ if (proxyApiType) {
78
+ request.log.error({ statusCode: status, err: error }, `Proxy request error: ${fastifyError.message}`);
79
+ const body = request.body;
80
+ insertRequestLog(db, {
81
+ id: randomUUID(),
82
+ api_type: proxyApiType,
83
+ model: body?.model || null,
84
+ provider_id: null,
85
+ status_code: status,
86
+ latency_ms: 0,
87
+ is_stream: 0,
88
+ error_message: fastifyError.message,
89
+ created_at: new Date().toISOString(),
90
+ client_request: JSON.stringify({ headers: request.headers }),
91
+ router_key_id: request.routerKey?.id ?? null,
92
+ });
93
+ }
51
94
  if (status === 400 && fastifyError.validation) {
52
95
  return reply.code(400).send({ error: { message: fastifyError.message } });
53
96
  }
@@ -55,12 +98,13 @@ export async function buildApp(options) {
55
98
  });
56
99
  // 首次启动时插入默认重试规则(表为空时)
57
100
  seedDefaultRules(db);
101
+ // 注入 DB 到 modelState 单例,启用会话级持久化
102
+ modelState.init(db);
58
103
  const matcher = new RetryRuleMatcher();
59
104
  matcher.load(db);
60
105
  app.register(authMiddleware, { db });
61
106
  app.register(openaiProxy, {
62
107
  db,
63
- encryptionKey: config.ENCRYPTION_KEY,
64
108
  streamTimeoutMs: config.STREAM_TIMEOUT_MS,
65
109
  retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
66
110
  retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
@@ -68,19 +112,12 @@ export async function buildApp(options) {
68
112
  });
69
113
  app.register(anthropicProxy, {
70
114
  db,
71
- encryptionKey: config.ENCRYPTION_KEY,
72
115
  streamTimeoutMs: config.STREAM_TIMEOUT_MS,
73
116
  retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
74
117
  retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
75
118
  matcher,
76
119
  });
77
- app.register(adminRoutes, {
78
- db,
79
- adminPassword: config.ADMIN_PASSWORD,
80
- jwtSecret: config.JWT_SECRET,
81
- encryptionKey: config.ENCRYPTION_KEY,
82
- matcher,
83
- });
120
+ app.register(adminRoutes, { db, matcher });
84
121
  // 前端静态文件服务(生产环境)
85
122
  const frontendDist = path.resolve(process.env.FRONTEND_DIST || path.join(__dirname, "../frontend-dist"));
86
123
  if (existsSync(frontendDist)) {
@@ -113,7 +150,9 @@ export async function buildApp(options) {
113
150
  },
114
151
  };
115
152
  }
116
- async function main() {
153
+ // index.ts 自身也需要 getBaseConfig,避免循环依赖
154
+ import { getBaseConfig } from "./config.js";
155
+ export async function main() {
117
156
  const { app } = await buildApp();
118
157
  const config = getConfig();
119
158
  try {
@@ -125,6 +164,7 @@ async function main() {
125
164
  process.exit(1);
126
165
  }
127
166
  }
167
+ // 开发时直接运行 tsx src/index.ts 仍可启动
128
168
  const isMainModule = process.argv[1]?.endsWith("index.js") || process.argv[1]?.endsWith("index.ts");
129
169
  if (isMainModule) {
130
170
  main();
@@ -1,7 +1,7 @@
1
1
  import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
2
3
  interface AdminAuthOptions {
3
- adminPassword: string;
4
- jwtSecret: string;
4
+ db: Database.Database;
5
5
  }
6
6
  export declare const adminAuthPlugin: FastifyPluginCallback<AdminAuthOptions>;
7
7
  export declare const adminLoginRoutes: FastifyPluginCallback<AdminAuthOptions>;
@@ -1,22 +1,34 @@
1
1
  import fp from "fastify-plugin";
2
2
  import cookie from "@fastify/cookie";
3
3
  import jwt from "jsonwebtoken";
4
- import { timingSafeEqual } from "crypto";
4
+ import { isInitialized, getSetting } from "../db/settings.js";
5
+ import { verifyPassword } from "../utils/password.js";
5
6
  const HTTP_UNAUTHORIZED = 401;
6
7
  const adminAuthRaw = (app, options, done) => {
7
8
  app.register(cookie);
8
9
  app.addHook("onRequest", async (request, reply) => {
9
10
  const path = request.url.split("?")[0];
10
- if (!path.startsWith("/admin/api/") || path === "/admin/api/login" || path === "/admin/api/logout") {
11
+ // Setup API 不需要 auth
12
+ if (path.startsWith("/admin/api/setup/"))
11
13
  return;
14
+ // Login/logout 不需要 auth
15
+ if (path === "/admin/api/login" || path === "/admin/api/logout")
16
+ return;
17
+ // 非 admin API 路径跳过
18
+ if (!path.startsWith("/admin/api/"))
19
+ return;
20
+ // 未初始化时返回 needsSetup
21
+ if (!isInitialized(options.db)) {
22
+ return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Not initialized", needsSetup: true } });
12
23
  }
13
24
  const token = request.cookies["admin_token"];
14
25
  if (!token) {
15
26
  reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Not authenticated" } });
16
27
  return reply;
17
28
  }
29
+ const secret = getSetting(options.db, "jwt_secret");
18
30
  try {
19
- jwt.verify(token, options.jwtSecret);
31
+ jwt.verify(token, secret ?? "");
20
32
  }
21
33
  catch (err) {
22
34
  request.log.debug({ err }, "invalid JWT token");
@@ -28,18 +40,19 @@ const adminAuthRaw = (app, options, done) => {
28
40
  };
29
41
  export const adminAuthPlugin = fp(adminAuthRaw, { name: "admin-auth" });
30
42
  export const adminLoginRoutes = (app, options, done) => {
31
- const TOKEN_EXPIRY_SECONDS = 86400;
43
+ const TOKEN_EXPIRY_SECONDS = 172800; // 48 hours
32
44
  app.post("/admin/api/login", async (request, reply) => {
33
45
  const { password } = request.body;
34
46
  if (!password) {
35
47
  return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid password" } });
36
48
  }
37
- const passwordBuf = Buffer.from(password);
38
- const keyBuf = Buffer.from(options.adminPassword);
39
- if (passwordBuf.length !== keyBuf.length || !timingSafeEqual(passwordBuf, keyBuf)) {
49
+ // DB 模式:scrypt hash 验证
50
+ const hash = getSetting(options.db, "admin_password_hash");
51
+ if (!hash || !verifyPassword(password, hash)) {
40
52
  return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid password" } });
41
53
  }
42
- const token = jwt.sign({ role: "admin" }, options.jwtSecret, { expiresIn: TOKEN_EXPIRY_SECONDS });
54
+ const secret = getSetting(options.db, "jwt_secret");
55
+ const token = jwt.sign({ role: "admin" }, secret, { expiresIn: TOKEN_EXPIRY_SECONDS });
43
56
  reply.setCookie("admin_token", token, {
44
57
  path: "/admin",
45
58
  httpOnly: true,
@@ -1,5 +1,7 @@
1
- import { createHash } from "crypto";
1
+ import { createHash, randomUUID } from "crypto";
2
2
  import fp from "fastify-plugin";
3
+ import { isInitialized } from "../db/settings.js";
4
+ import { insertRequestLog } from "../db/logs.js";
3
5
  const SKIP_PATHS = ["/health", "/admin"];
4
6
  const HTTP_UNAUTHORIZED = 401;
5
7
  const BEARER_PREFIX_LENGTH = "Bearer ".length;
@@ -16,14 +18,54 @@ function unauthorizedReply(reply) {
16
18
  },
17
19
  });
18
20
  }
21
+ // 代理路由路径 → api_type 映射,用于记录被认证拒绝的请求
22
+ const PROXY_API_TYPES = {
23
+ "/v1/chat/completions": "openai",
24
+ "/v1/messages": "anthropic",
25
+ "/v1/models": "openai",
26
+ };
27
+ function getProxyApiType(url) {
28
+ const path = url.split("?")[0];
29
+ return PROXY_API_TYPES[path] ?? null;
30
+ }
31
+ function logRejectedAuth(db, apiType, statusCode, errorMessage, request) {
32
+ insertRequestLog(db, {
33
+ id: randomUUID(),
34
+ api_type: apiType,
35
+ model: null,
36
+ provider_id: null,
37
+ status_code: statusCode,
38
+ latency_ms: 0,
39
+ is_stream: 0,
40
+ error_message: errorMessage,
41
+ created_at: new Date().toISOString(),
42
+ client_request: JSON.stringify({ headers: request.headers }),
43
+ });
44
+ }
19
45
  const authMiddlewareRaw = (app, options, done) => {
20
46
  const stmt = options.db.prepare("SELECT id, name, allowed_models FROM router_keys WHERE key_hash = ? AND is_active = 1");
21
47
  app.addHook("onRequest", async (request, reply) => {
22
48
  if (shouldSkipAuth(request.url)) {
23
49
  return;
24
50
  }
51
+ const proxyApiType = getProxyApiType(request.url);
52
+ // 代理请求一到达就记录技术日志
53
+ if (proxyApiType) {
54
+ request.log.info({ method: request.method, url: request.url, ip: request.ip }, `Proxy request received [${proxyApiType}]`);
55
+ }
56
+ // 未初始化时代理层不可用
57
+ if (!isInitialized(options.db)) {
58
+ if (proxyApiType) {
59
+ logRejectedAuth(options.db, proxyApiType, 503, "Service not initialized", request);
60
+ }
61
+ reply.code(503).send({ error: { message: "Service not initialized" } });
62
+ return reply;
63
+ }
25
64
  const authHeader = request.headers.authorization;
26
65
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
66
+ if (proxyApiType) {
67
+ logRejectedAuth(options.db, proxyApiType, 401, "Invalid API key", request);
68
+ }
27
69
  unauthorizedReply(reply);
28
70
  return reply;
29
71
  }
@@ -31,6 +73,9 @@ const authMiddlewareRaw = (app, options, done) => {
31
73
  const hash = createHash("sha256").update(token).digest("hex");
32
74
  const row = stmt.get(hash);
33
75
  if (!row) {
76
+ if (proxyApiType) {
77
+ logRejectedAuth(options.db, proxyApiType, 401, "Invalid API key", request);
78
+ }
34
79
  unauthorizedReply(reply);
35
80
  return reply;
36
81
  }
@@ -3,7 +3,6 @@ import type { FastifyPluginCallback } from "fastify";
3
3
  import { RetryRuleMatcher } from "./retry-rules.js";
4
4
  export interface AnthropicProxyOptions {
5
5
  db: Database.Database;
6
- encryptionKey: string;
7
6
  streamTimeoutMs: number;
8
7
  retryMaxAttempts: number;
9
8
  retryBaseDelayMs: number;
@@ -24,9 +24,9 @@ const anthropicErrors = {
24
24
  }),
25
25
  };
26
26
  const anthropicProxyRaw = (app, opts, done) => {
27
- const { db, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = opts;
27
+ const { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = opts;
28
28
  app.post(MESSAGES_PATH, async (request, reply) => {
29
- const deps = { db, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher };
29
+ const deps = { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher };
30
30
  return handleProxyPost(request, reply, "anthropic", MESSAGES_PATH, anthropicErrors, deps);
31
31
  });
32
32
  done();
@@ -0,0 +1,7 @@
1
+ export interface DirectiveParseResult {
2
+ modelName: string | null;
3
+ command: string | null;
4
+ cleanedBody: Record<string, unknown>;
5
+ isCommandMessage: boolean;
6
+ }
7
+ export declare function parseDirective(body: Record<string, unknown>): DirectiveParseResult;
@@ -0,0 +1,70 @@
1
+ const MODEL_MAX_LEN = 128;
2
+ const MODEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/;
3
+ function isValidModelName(name) {
4
+ return name.length <= MODEL_MAX_LEN && MODEL_RE.test(name) && !/^\d+$/.test(name);
5
+ }
6
+ export function parseDirective(body) {
7
+ const messages = body.messages;
8
+ if (!messages?.length) {
9
+ return {
10
+ modelName: null,
11
+ command: null,
12
+ cleanedBody: body,
13
+ isCommandMessage: false,
14
+ };
15
+ }
16
+ let lastUserIdx = -1;
17
+ for (let i = messages.length - 1; i >= 0; i--) {
18
+ if (messages[i].role === "user") {
19
+ lastUserIdx = i;
20
+ break;
21
+ }
22
+ }
23
+ if (lastUserIdx < 0) {
24
+ return {
25
+ modelName: null,
26
+ command: null,
27
+ cleanedBody: body,
28
+ isCommandMessage: false,
29
+ };
30
+ }
31
+ // Deep clone to avoid mutating the original request body
32
+ const cleanedBody = JSON.parse(JSON.stringify(body));
33
+ const cleanedMessages = cleanedBody.messages;
34
+ const lastUser = cleanedMessages[lastUserIdx];
35
+ const content = Array.isArray(lastUser.content)
36
+ ? lastUser.content
37
+ : [lastUser.content];
38
+ let modelName = null;
39
+ let command = null;
40
+ let isCommandMessage = false;
41
+ const reInline = /\$SELECT-MODEL=([a-zA-Z0-9._:-]+)/g;
42
+ const reModelTag = /\[router-model:\s*([a-zA-Z0-9._\/:-]+)\s*\]/g;
43
+ const reCommand = /\[router-command:\s*(\S+(?:\s+\S+)?)\s*\]/g;
44
+ for (const block of content) {
45
+ if (!block || typeof block !== "object")
46
+ continue;
47
+ const b = block;
48
+ if (b.type !== "text" || !b.text)
49
+ continue;
50
+ let text = b.text;
51
+ const cmdMatch = reCommand.exec(text);
52
+ if (cmdMatch) {
53
+ command = cmdMatch[1];
54
+ isCommandMessage = true;
55
+ text = text.replace(reCommand, "").trim();
56
+ }
57
+ const inlineMatch = reInline.exec(text);
58
+ if (inlineMatch && isValidModelName(inlineMatch[1])) {
59
+ modelName = inlineMatch[1];
60
+ text = text.replace(reInline, "").trim();
61
+ }
62
+ const modelTagMatch = reModelTag.exec(text);
63
+ if (modelTagMatch && isValidModelName(modelTagMatch[1])) {
64
+ modelName = modelTagMatch[1];
65
+ text = text.replace(reModelTag, "").trim();
66
+ }
67
+ b.text = text;
68
+ }
69
+ return { modelName, command, cleanedBody, isCommandMessage };
70
+ }
@@ -0,0 +1,23 @@
1
+ import type { FastifyRequest } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ export interface InterceptResponse {
4
+ statusCode: number;
5
+ body: unknown;
6
+ /** 拦截元数据,用于日志记录 */
7
+ meta?: {
8
+ action: string;
9
+ detail?: string;
10
+ };
11
+ }
12
+ export interface EnhancementResult {
13
+ effectiveModel: string;
14
+ originalModel: string | null;
15
+ interceptResponse: InterceptResponse | null;
16
+ }
17
+ /**
18
+ * 在代理转发前应用代理增强逻辑(指令解析 + 会话记忆 + 模型替换 + 命令拦截)。
19
+ * 仅当 proxy_enhancement.claude_code_enabled 开启时生效。
20
+ */
21
+ export declare function applyEnhancement(db: Database.Database, request: FastifyRequest, clientModel: string, sessionId?: string): EnhancementResult;
22
+ /** 生成注入到非流式响应中的模型信息标签 */
23
+ export declare function buildModelInfoTag(effectiveModel: string): string;