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.
- package/README.md +12 -14
- package/dist/admin/groups.js +25 -0
- package/dist/admin/providers.d.ts +0 -1
- package/dist/admin/providers.js +16 -13
- package/dist/admin/proxy-enhancement.d.ts +7 -0
- package/dist/admin/proxy-enhancement.js +39 -0
- package/dist/admin/router-keys.d.ts +0 -1
- package/dist/admin/router-keys.js +17 -8
- package/dist/admin/routes.d.ts +0 -3
- package/dist/admin/routes.js +9 -4
- package/dist/admin/setup.d.ts +7 -0
- package/dist/admin/setup.js +44 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4 -0
- package/dist/config.d.ts +1 -4
- package/dist/config.js +13 -13
- package/dist/db/index.d.ts +5 -2
- package/dist/db/index.js +3 -1
- package/dist/db/logs.d.ts +5 -2
- package/dist/db/logs.js +4 -4
- package/dist/db/mappings.d.ts +16 -0
- package/dist/db/mappings.js +72 -0
- package/dist/db/migrations/014_create_settings.sql +4 -0
- package/dist/db/migrations/015_add_original_model.sql +1 -0
- package/dist/db/migrations/016_create_session_model_tables.sql +24 -0
- package/dist/db/session-states.d.ts +40 -0
- package/dist/db/session-states.js +37 -0
- package/dist/db/settings.d.ts +4 -0
- package/dist/db/settings.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +53 -13
- package/dist/middleware/admin-auth.d.ts +2 -2
- package/dist/middleware/admin-auth.js +21 -8
- package/dist/middleware/auth.js +46 -1
- package/dist/proxy/anthropic.d.ts +0 -1
- package/dist/proxy/anthropic.js +2 -2
- package/dist/proxy/directive-parser.d.ts +7 -0
- package/dist/proxy/directive-parser.js +70 -0
- package/dist/proxy/enhancement-handler.d.ts +23 -0
- package/dist/proxy/enhancement-handler.js +167 -0
- package/dist/proxy/log-helpers.d.ts +41 -0
- package/dist/proxy/log-helpers.js +35 -0
- package/dist/proxy/mapping-resolver.js +39 -2
- package/dist/proxy/model-state.d.ts +28 -0
- package/dist/proxy/model-state.js +111 -0
- package/dist/proxy/openai.d.ts +0 -1
- package/dist/proxy/openai.js +4 -3
- package/dist/proxy/proxy-core.d.ts +9 -47
- package/dist/proxy/proxy-core.js +215 -344
- package/dist/proxy/response-cleaner.d.ts +5 -0
- package/dist/proxy/response-cleaner.js +60 -0
- package/dist/proxy/strategy/failover.d.ts +1 -1
- package/dist/proxy/strategy/failover.js +10 -2
- package/dist/proxy/strategy/random.d.ts +1 -1
- package/dist/proxy/strategy/random.js +8 -2
- package/dist/proxy/strategy/round-robin.d.ts +2 -1
- package/dist/proxy/strategy/round-robin.js +13 -2
- package/dist/proxy/strategy/targets-rule.d.ts +7 -0
- package/dist/proxy/strategy/targets-rule.js +14 -0
- package/dist/proxy/strategy/types.d.ts +5 -1
- package/dist/proxy/strategy/types.js +3 -0
- package/dist/proxy/upstream-call.d.ts +43 -0
- package/dist/proxy/upstream-call.js +208 -0
- package/dist/utils/password.d.ts +2 -0
- package/dist/utils/password.js +14 -0
- package/package.json +6 -5
- package/.env.example +0 -13
package/dist/db/mappings.js
CHANGED
|
@@ -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 @@
|
|
|
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
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 ??
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
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
|
|
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,
|
package/dist/middleware/auth.js
CHANGED
|
@@ -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;
|
package/dist/proxy/anthropic.js
CHANGED
|
@@ -24,9 +24,9 @@ const anthropicErrors = {
|
|
|
24
24
|
}),
|
|
25
25
|
};
|
|
26
26
|
const anthropicProxyRaw = (app, opts, done) => {
|
|
27
|
-
const { db,
|
|
27
|
+
const { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = opts;
|
|
28
28
|
app.post(MESSAGES_PATH, async (request, reply) => {
|
|
29
|
-
const deps = { db,
|
|
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,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;
|