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/README.md
CHANGED
|
@@ -59,7 +59,7 @@ Router 根据模型映射找到后端供应商 -> 转发请求 -> 自动重试
|
|
|
59
59
|
**方式一:shell alias(推荐)**
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="http://127.0.0.1:
|
|
62
|
+
alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="http://127.0.0.1:9981" claude'
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
**方式二:~/.claude/settings.json**
|
|
@@ -68,7 +68,7 @@ alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="htt
|
|
|
68
68
|
{
|
|
69
69
|
"env": {
|
|
70
70
|
"ANTHROPIC_AUTH_TOKEN": "sk-router-change-me",
|
|
71
|
-
"ANTHROPIC_BASE_URL": "http://127.0.0.1:
|
|
71
|
+
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9981",
|
|
72
72
|
"ANTHROPIC_MODEL": "some-model"
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -90,14 +90,13 @@ alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="htt
|
|
|
90
90
|
## 快速开始
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
# 访问 http://localhost:3000/admin
|
|
93
|
+
# 一行命令启动
|
|
94
|
+
npx llm-simple-router
|
|
95
|
+
# 访问 http://localhost:9981/admin
|
|
96
|
+
# 首次访问会进入 Setup 页面设置管理员密码
|
|
98
97
|
```
|
|
99
98
|
|
|
100
|
-
|
|
99
|
+
无需任何环境变量。数据默认存储在 `~/.llm-simple-router/`。
|
|
101
100
|
|
|
102
101
|
## Docker 部署
|
|
103
102
|
|
|
@@ -107,15 +106,14 @@ docker compose up -d
|
|
|
107
106
|
|
|
108
107
|
## 环境变量
|
|
109
108
|
|
|
109
|
+
所有密钥(管理员密码、加密密钥、JWT 密钥)通过首次启动的 Setup 页面设置,无需环境变量。
|
|
110
|
+
|
|
110
111
|
| 变量 | 必需 | 默认值 | 说明 |
|
|
111
112
|
|------|------|--------|------|
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `JWT_SECRET` | Yes | -- | JWT 签名密钥(64字符 hex) |
|
|
115
|
-
| `PORT` | No | `3000` | 服务端口 |
|
|
116
|
-
| `DB_PATH` | No | `./data/router.db` | SQLite 数据库路径 |
|
|
113
|
+
| `PORT` | No | `9981` | 服务端口 |
|
|
114
|
+
| `DB_PATH` | No | `~/.llm-simple-router/router.db` | SQLite 数据库路径 |
|
|
117
115
|
| `LOG_LEVEL` | No | `info` | 日志级别 |
|
|
118
|
-
| `TZ` | No |
|
|
116
|
+
| `TZ` | No | `Asia/Shanghai` | 时区设置 |
|
|
119
117
|
| `STREAM_TIMEOUT_MS` | No | `3000000` | 流式代理空闲超时(ms) |
|
|
120
118
|
| `RETRY_MAX_ATTEMPTS` | No | `3` | 最大重试次数 |
|
|
121
119
|
| `RETRY_BASE_DELAY_MS` | No | `1000` | 重试基础延迟(ms) |
|
package/dist/admin/groups.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
|
|
|
2
2
|
import { getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getProviderById, getMappingGroupById, } from "../db/index.js";
|
|
3
3
|
import { STRATEGY_NAMES } from "../proxy/strategy/types.js";
|
|
4
4
|
import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_CONFLICT } from "./constants.js";
|
|
5
|
+
const MIN_FAILOVER_TARGETS = 2;
|
|
5
6
|
const CreateGroupSchema = Type.Object({
|
|
6
7
|
client_model: Type.String({ minLength: 1 }),
|
|
7
8
|
strategy: Type.String({ minLength: 1 }),
|
|
@@ -13,6 +14,10 @@ const UpdateGroupSchema = Type.Object({
|
|
|
13
14
|
rule: Type.Optional(Type.String()),
|
|
14
15
|
});
|
|
15
16
|
async function validateRule(db, strategy, ruleJson) {
|
|
17
|
+
const VALID_STRATEGIES = new Set(Object.values(STRATEGY_NAMES));
|
|
18
|
+
if (!VALID_STRATEGIES.has(strategy)) {
|
|
19
|
+
return `Unknown strategy '${strategy}'. Valid: ${[...VALID_STRATEGIES].join(", ")}`;
|
|
20
|
+
}
|
|
16
21
|
let rule;
|
|
17
22
|
try {
|
|
18
23
|
rule = JSON.parse(ruleJson);
|
|
@@ -45,6 +50,26 @@ async function validateRule(db, strategy, ruleJson) {
|
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
}
|
|
53
|
+
if (strategy === STRATEGY_NAMES.ROUND_ROBIN || strategy === STRATEGY_NAMES.RANDOM || strategy === STRATEGY_NAMES.FAILOVER) {
|
|
54
|
+
const r = rule;
|
|
55
|
+
if (!Array.isArray(r.targets) || r.targets.length === 0) {
|
|
56
|
+
return "rule.targets must be a non-empty array";
|
|
57
|
+
}
|
|
58
|
+
const minTargets = strategy === STRATEGY_NAMES.FAILOVER ? MIN_FAILOVER_TARGETS : 1;
|
|
59
|
+
if (r.targets.length < minTargets) {
|
|
60
|
+
return `strategy '${strategy}' requires at least ${minTargets} target(s)`;
|
|
61
|
+
}
|
|
62
|
+
for (let i = 0; i < r.targets.length; i++) {
|
|
63
|
+
const t = r.targets[i];
|
|
64
|
+
if (!t.backend_model || !t.provider_id) {
|
|
65
|
+
return `targets[${i}] missing backend_model or provider_id`;
|
|
66
|
+
}
|
|
67
|
+
const p = getProviderById(db, t.provider_id);
|
|
68
|
+
if (!p) {
|
|
69
|
+
return `targets[${i}] provider_id '${t.provider_id}' not found`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
48
73
|
return undefined;
|
|
49
74
|
}
|
|
50
75
|
export const adminGroupRoutes = (app, options, done) => {
|
|
@@ -2,7 +2,6 @@ import { FastifyPluginCallback } from "fastify";
|
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
interface ProviderRoutesOptions {
|
|
4
4
|
db: Database.Database;
|
|
5
|
-
encryptionKey: string;
|
|
6
5
|
}
|
|
7
6
|
export declare const adminProviderRoutes: FastifyPluginCallback<ProviderRoutesOptions>;
|
|
8
7
|
export {};
|
package/dist/admin/providers.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups } from "../db/index.js";
|
|
3
|
-
import { encrypt } from "../utils/crypto.js";
|
|
3
|
+
import { encrypt, decrypt } from "../utils/crypto.js";
|
|
4
|
+
import { getSetting } from "../db/settings.js";
|
|
4
5
|
import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT } from "./constants.js";
|
|
5
6
|
const API_KEY_PREVIEW_MIN_LEN = 8;
|
|
6
7
|
const API_KEY_PREVIEW_PREFIX_LEN = 4;
|
|
8
|
+
const PROVIDER_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
7
9
|
const CreateProviderSchema = Type.Object({
|
|
8
10
|
name: Type.String({ minLength: 1 }),
|
|
9
11
|
api_type: Type.Union([Type.Literal("openai"), Type.Literal("anthropic")]),
|
|
@@ -20,21 +22,17 @@ const UpdateProviderSchema = Type.Object({
|
|
|
20
22
|
models: Type.Optional(Type.Array(Type.String())),
|
|
21
23
|
is_active: Type.Optional(Type.Number()),
|
|
22
24
|
});
|
|
23
|
-
function computeApiKeyPreview(apiKey) {
|
|
24
|
-
if (apiKey.length <= API_KEY_PREVIEW_MIN_LEN)
|
|
25
|
-
return "****";
|
|
26
|
-
return `${apiKey.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${apiKey.slice(-API_KEY_PREVIEW_PREFIX_LEN)}`;
|
|
27
|
-
}
|
|
28
25
|
export const adminProviderRoutes = (app, options, done) => {
|
|
29
|
-
const { db
|
|
26
|
+
const { db } = options;
|
|
30
27
|
app.get("/admin/api/providers", async (_request, reply) => {
|
|
28
|
+
const encryptionKey = getSetting(db, "encryption_key");
|
|
31
29
|
const providers = getAllProviders(db);
|
|
32
30
|
return reply.send(providers.map((s) => ({
|
|
33
31
|
id: s.id,
|
|
34
32
|
name: s.name,
|
|
35
33
|
api_type: s.api_type,
|
|
36
34
|
base_url: s.base_url,
|
|
37
|
-
|
|
35
|
+
api_key: s.api_key ? decrypt(s.api_key, encryptionKey) : "",
|
|
38
36
|
models: JSON.parse(s.models || "[]"),
|
|
39
37
|
is_active: s.is_active,
|
|
40
38
|
created_at: s.created_at,
|
|
@@ -43,14 +41,16 @@ export const adminProviderRoutes = (app, options, done) => {
|
|
|
43
41
|
});
|
|
44
42
|
app.post("/admin/api/providers", { schema: { body: CreateProviderSchema } }, async (request, reply) => {
|
|
45
43
|
const body = request.body;
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
if (!PROVIDER_NAME_RE.test(body.name)) {
|
|
45
|
+
return reply.status(400).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
|
|
46
|
+
}
|
|
47
|
+
const encryptedKey = encrypt(body.api_key, getSetting(db, "encryption_key"));
|
|
48
48
|
const id = createProvider(db, {
|
|
49
49
|
name: body.name,
|
|
50
50
|
api_type: body.api_type,
|
|
51
51
|
base_url: body.base_url,
|
|
52
52
|
api_key: encryptedKey,
|
|
53
|
-
api_key_preview:
|
|
53
|
+
api_key_preview: body.api_key.length > 8 ? `${body.api_key.slice(0, 4)}...${body.api_key.slice(-4)}` : "****",
|
|
54
54
|
models: JSON.stringify(body.models ?? []),
|
|
55
55
|
is_active: body.is_active ?? 1,
|
|
56
56
|
});
|
|
@@ -63,6 +63,9 @@ export const adminProviderRoutes = (app, options, done) => {
|
|
|
63
63
|
return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Provider not found" } });
|
|
64
64
|
}
|
|
65
65
|
const body = request.body;
|
|
66
|
+
if (body.name !== undefined && !PROVIDER_NAME_RE.test(body.name)) {
|
|
67
|
+
return reply.status(400).send({ error: { message: "Provider 名称仅允许英文大小写字母、数字、横线和下划线" } });
|
|
68
|
+
}
|
|
66
69
|
const fields = {};
|
|
67
70
|
if (body.name !== undefined)
|
|
68
71
|
fields.name = body.name;
|
|
@@ -75,8 +78,8 @@ export const adminProviderRoutes = (app, options, done) => {
|
|
|
75
78
|
if (body.models !== undefined)
|
|
76
79
|
fields.models = JSON.stringify(body.models);
|
|
77
80
|
if (body.api_key) {
|
|
78
|
-
fields.api_key = encrypt(body.api_key,
|
|
79
|
-
fields.api_key_preview =
|
|
81
|
+
fields.api_key = encrypt(body.api_key, getSetting(db, "encryption_key"));
|
|
82
|
+
fields.api_key_preview = body.api_key.length > 8 ? `${body.api_key.slice(0, 4)}...${body.api_key.slice(-4)}` : "****";
|
|
80
83
|
}
|
|
81
84
|
updateProvider(db, id, fields);
|
|
82
85
|
return reply.send({ success: true });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FastifyPluginCallback } from "fastify";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
interface ProxyEnhancementOptions {
|
|
4
|
+
db: Database.Database;
|
|
5
|
+
}
|
|
6
|
+
export declare const adminProxyEnhancementRoutes: FastifyPluginCallback<ProxyEnhancementOptions>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getSetting, setSetting } from "../db/settings.js";
|
|
2
|
+
import { getSessionStates, getSessionHistory, } from "../db/session-states.js";
|
|
3
|
+
import { modelState } from "../proxy/model-state.js";
|
|
4
|
+
export const adminProxyEnhancementRoutes = (app, options, done) => {
|
|
5
|
+
const { db } = options;
|
|
6
|
+
app.get("/admin/api/proxy-enhancement", async (_req, reply) => {
|
|
7
|
+
const raw = getSetting(db, "proxy_enhancement");
|
|
8
|
+
const config = raw
|
|
9
|
+
? JSON.parse(raw)
|
|
10
|
+
: { claude_code_enabled: false };
|
|
11
|
+
return reply.send(config);
|
|
12
|
+
});
|
|
13
|
+
app.put("/admin/api/proxy-enhancement", async (req, reply) => {
|
|
14
|
+
const body = req.body;
|
|
15
|
+
if (typeof body.claude_code_enabled !== "boolean") {
|
|
16
|
+
return reply.status(400).send({ error: "claude_code_enabled must be a boolean" }); // eslint-disable-line no-magic-numbers
|
|
17
|
+
}
|
|
18
|
+
const config = {
|
|
19
|
+
claude_code_enabled: body.claude_code_enabled,
|
|
20
|
+
};
|
|
21
|
+
setSetting(db, "proxy_enhancement", JSON.stringify(config));
|
|
22
|
+
return reply.send({ success: true });
|
|
23
|
+
});
|
|
24
|
+
app.get("/admin/api/session-states", async (_req, reply) => {
|
|
25
|
+
const states = getSessionStates(db);
|
|
26
|
+
return reply.send(states);
|
|
27
|
+
});
|
|
28
|
+
app.get("/admin/api/session-states/:keyId/:sessionId/history", async (req, reply) => {
|
|
29
|
+
const { keyId, sessionId } = req.params;
|
|
30
|
+
const history = getSessionHistory(db, keyId, sessionId);
|
|
31
|
+
return reply.send(history);
|
|
32
|
+
});
|
|
33
|
+
app.delete("/admin/api/session-states/:keyId/:sessionId", async (req, reply) => {
|
|
34
|
+
const { keyId, sessionId } = req.params;
|
|
35
|
+
modelState.delete(keyId, sessionId);
|
|
36
|
+
return reply.send({ success: true });
|
|
37
|
+
});
|
|
38
|
+
done();
|
|
39
|
+
};
|
|
@@ -2,7 +2,6 @@ import { FastifyPluginCallback } from "fastify";
|
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
interface RouterKeyRoutesOptions {
|
|
4
4
|
db: Database.Database;
|
|
5
|
-
encryptionKey: string;
|
|
6
5
|
}
|
|
7
6
|
export declare const adminRouterKeyRoutes: FastifyPluginCallback<RouterKeyRoutesOptions>;
|
|
8
7
|
export {};
|
|
@@ -2,10 +2,18 @@ import { randomBytes, createHash } from "crypto";
|
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
import { encrypt, decrypt } from "../utils/crypto.js";
|
|
4
4
|
import { getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "../db/index.js";
|
|
5
|
+
import { getSetting } from "../db/settings.js";
|
|
5
6
|
const HTTP_CREATED = 201;
|
|
6
7
|
const HTTP_NOT_FOUND = 404;
|
|
7
8
|
const KEY_RANDOM_BYTES = 32;
|
|
8
9
|
const KEY_PREFIX_LENGTH = 8;
|
|
10
|
+
/** 归一化 allowed_models:null/空数组/仅含空字符串 → null(允许所有模型) */
|
|
11
|
+
function normalizeAllowedModels(val) {
|
|
12
|
+
if (!val)
|
|
13
|
+
return null;
|
|
14
|
+
const filtered = val.filter((m) => m.trim() !== "");
|
|
15
|
+
return filtered.length > 0 ? JSON.stringify(filtered) : null;
|
|
16
|
+
}
|
|
9
17
|
const CreateRouterKeySchema = Type.Object({
|
|
10
18
|
name: Type.String({ minLength: 1 }),
|
|
11
19
|
allowed_models: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
|
@@ -15,14 +23,15 @@ const UpdateRouterKeySchema = Type.Object({
|
|
|
15
23
|
allowed_models: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
|
16
24
|
is_active: Type.Optional(Type.Number()),
|
|
17
25
|
});
|
|
18
|
-
function generateRouterKey(
|
|
26
|
+
function generateRouterKey(db) {
|
|
19
27
|
const key = `sk-router-${randomBytes(KEY_RANDOM_BYTES).toString("hex")}`;
|
|
20
28
|
const hash = createHash("sha256").update(key).digest("hex");
|
|
21
29
|
const prefix = key.slice(0, KEY_PREFIX_LENGTH);
|
|
22
|
-
const encrypted = encrypt(key,
|
|
30
|
+
const encrypted = encrypt(key, getSetting(db, "encryption_key"));
|
|
23
31
|
return { key, hash, prefix, encrypted };
|
|
24
32
|
}
|
|
25
|
-
function toPublicRouterKey(rk,
|
|
33
|
+
function toPublicRouterKey(rk, db) {
|
|
34
|
+
const encryptionKey = getSetting(db, "encryption_key");
|
|
26
35
|
return {
|
|
27
36
|
id: rk.id,
|
|
28
37
|
name: rk.name,
|
|
@@ -35,15 +44,15 @@ function toPublicRouterKey(rk, encryptionKey) {
|
|
|
35
44
|
};
|
|
36
45
|
}
|
|
37
46
|
export const adminRouterKeyRoutes = (app, options, done) => {
|
|
38
|
-
const { db
|
|
47
|
+
const { db } = options;
|
|
39
48
|
app.get("/admin/api/router-keys", async (_request, reply) => {
|
|
40
49
|
const keys = getAllRouterKeys(db);
|
|
41
|
-
return reply.send(keys.map((rk) => toPublicRouterKey(rk,
|
|
50
|
+
return reply.send(keys.map((rk) => toPublicRouterKey(rk, db)));
|
|
42
51
|
});
|
|
43
52
|
app.post("/admin/api/router-keys", { schema: { body: CreateRouterKeySchema } }, async (request, reply) => {
|
|
44
53
|
const body = request.body;
|
|
45
|
-
const { key, hash, prefix, encrypted } = generateRouterKey(
|
|
46
|
-
const allowedModels =
|
|
54
|
+
const { key, hash, prefix, encrypted } = generateRouterKey(db);
|
|
55
|
+
const allowedModels = normalizeAllowedModels(body.allowed_models);
|
|
47
56
|
const id = createRouterKey(db, { name: body.name, key_hash: hash, key_prefix: prefix, key_encrypted: encrypted, allowed_models: allowedModels });
|
|
48
57
|
return reply.code(HTTP_CREATED).send({
|
|
49
58
|
id,
|
|
@@ -66,7 +75,7 @@ export const adminRouterKeyRoutes = (app, options, done) => {
|
|
|
66
75
|
if (body.name !== undefined)
|
|
67
76
|
fields.name = body.name;
|
|
68
77
|
if (body.allowed_models !== undefined)
|
|
69
|
-
fields.allowed_models =
|
|
78
|
+
fields.allowed_models = normalizeAllowedModels(body.allowed_models);
|
|
70
79
|
if (body.is_active !== undefined)
|
|
71
80
|
fields.is_active = body.is_active;
|
|
72
81
|
updateRouterKey(db, id, fields);
|
package/dist/admin/routes.d.ts
CHANGED
|
@@ -3,9 +3,6 @@ import Database from "better-sqlite3";
|
|
|
3
3
|
import { RetryRuleMatcher } from "../proxy/retry-rules.js";
|
|
4
4
|
interface AdminRoutesOptions {
|
|
5
5
|
db: Database.Database;
|
|
6
|
-
adminPassword: string;
|
|
7
|
-
jwtSecret: string;
|
|
8
|
-
encryptionKey: string;
|
|
9
6
|
matcher: RetryRuleMatcher | null;
|
|
10
7
|
}
|
|
11
8
|
export declare const adminRoutes: FastifyPluginCallback<AdminRoutesOptions>;
|
package/dist/admin/routes.js
CHANGED
|
@@ -6,17 +6,22 @@ import { adminRetryRuleRoutes } from "./retry-rules.js";
|
|
|
6
6
|
import { adminLogRoutes } from "./logs.js";
|
|
7
7
|
import { adminStatsRoutes } from "./stats.js";
|
|
8
8
|
import { adminMetricsRoutes } from "./metrics.js";
|
|
9
|
+
import { adminProxyEnhancementRoutes } from "./proxy-enhancement.js";
|
|
9
10
|
import { adminRouterKeyRoutes } from "./router-keys.js";
|
|
11
|
+
import { adminSetupRoutes } from "./setup.js";
|
|
10
12
|
export const adminRoutes = (app, options, done) => {
|
|
11
|
-
|
|
12
|
-
app.register(
|
|
13
|
-
app.register(
|
|
13
|
+
// Setup 路由不需要 auth
|
|
14
|
+
app.register(adminSetupRoutes, { db: options.db });
|
|
15
|
+
app.register(adminAuthPlugin, { db: options.db });
|
|
16
|
+
app.register(adminLoginRoutes, { db: options.db });
|
|
17
|
+
app.register(adminProviderRoutes, { db: options.db });
|
|
14
18
|
app.register(adminMappingRoutes, { db: options.db });
|
|
15
19
|
app.register(adminGroupRoutes, { db: options.db });
|
|
16
20
|
app.register(adminRetryRuleRoutes, { db: options.db, matcher: options.matcher });
|
|
17
21
|
app.register(adminLogRoutes, { db: options.db });
|
|
18
|
-
app.register(adminRouterKeyRoutes, { db: options.db
|
|
22
|
+
app.register(adminRouterKeyRoutes, { db: options.db });
|
|
19
23
|
app.register(adminStatsRoutes, { db: options.db });
|
|
20
24
|
app.register(adminMetricsRoutes, { db: options.db });
|
|
25
|
+
app.register(adminProxyEnhancementRoutes, { db: options.db });
|
|
21
26
|
done();
|
|
22
27
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import { getSetting, setSetting, isInitialized } from "../db/settings.js";
|
|
4
|
+
import { hashPassword } from "../utils/password.js";
|
|
5
|
+
export const adminSetupRoutes = (app, options, done) => {
|
|
6
|
+
const { db } = options;
|
|
7
|
+
app.get("/admin/api/setup/status", async () => {
|
|
8
|
+
return { initialized: isInitialized(db) };
|
|
9
|
+
});
|
|
10
|
+
app.post("/admin/api/setup/initialize", async (request, reply) => {
|
|
11
|
+
const { password } = request.body;
|
|
12
|
+
if (!password || password.length < 6) { // eslint-disable-line no-magic-numbers
|
|
13
|
+
return reply.code(400).send({ error: { message: "Password must be at least 6 characters" } });
|
|
14
|
+
}
|
|
15
|
+
// 事务中原子检查防竞态
|
|
16
|
+
const alreadyInitialized = db.transaction(() => {
|
|
17
|
+
if (isInitialized(db))
|
|
18
|
+
return true;
|
|
19
|
+
const encryptionKey = randomBytes(32).toString("hex");
|
|
20
|
+
const jwtSecret = randomBytes(32).toString("hex");
|
|
21
|
+
setSetting(db, "admin_password_hash", hashPassword(password));
|
|
22
|
+
setSetting(db, "encryption_key", encryptionKey);
|
|
23
|
+
setSetting(db, "jwt_secret", jwtSecret);
|
|
24
|
+
setSetting(db, "initialized", "true");
|
|
25
|
+
return false;
|
|
26
|
+
})();
|
|
27
|
+
if (alreadyInitialized) {
|
|
28
|
+
return reply.code(409).send({ error: { message: "Already initialized" } });
|
|
29
|
+
}
|
|
30
|
+
// 自动登录:签发 JWT
|
|
31
|
+
const TOKEN_EXPIRY_SECONDS = 172800; // 48 hours,与 admin-auth 保持一致
|
|
32
|
+
const secret = getSetting(db, "jwt_secret");
|
|
33
|
+
const token = jwt.sign({ role: "admin" }, secret, { expiresIn: TOKEN_EXPIRY_SECONDS });
|
|
34
|
+
reply.setCookie("admin_token", token, {
|
|
35
|
+
path: "/admin",
|
|
36
|
+
httpOnly: true,
|
|
37
|
+
secure: process.env.NODE_ENV === "production",
|
|
38
|
+
sameSite: "lax",
|
|
39
|
+
maxAge: TOKEN_EXPIRY_SECONDS,
|
|
40
|
+
});
|
|
41
|
+
return { success: true };
|
|
42
|
+
});
|
|
43
|
+
done();
|
|
44
|
+
};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
package/dist/config.d.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import "dotenv/config";
|
|
2
1
|
export interface Config {
|
|
3
|
-
ADMIN_PASSWORD: string;
|
|
4
|
-
ENCRYPTION_KEY: string;
|
|
5
|
-
JWT_SECRET: string;
|
|
6
2
|
PORT: number;
|
|
7
3
|
DB_PATH: string;
|
|
8
4
|
LOG_LEVEL: string;
|
|
@@ -12,4 +8,5 @@ export interface Config {
|
|
|
12
8
|
RETRY_BASE_DELAY_MS: number;
|
|
13
9
|
}
|
|
14
10
|
export declare function resetConfig(): void;
|
|
11
|
+
export declare function getBaseConfig(): Config;
|
|
15
12
|
export declare function getConfig(): Config;
|
package/dist/config.js
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
|
-
import "
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
let cachedConfig = null;
|
|
4
|
+
function getDefaultDbPath() {
|
|
5
|
+
if (process.env.DB_PATH)
|
|
6
|
+
return process.env.DB_PATH;
|
|
7
|
+
return join(homedir(), ".llm-simple-router", "router.db");
|
|
8
|
+
}
|
|
3
9
|
export function resetConfig() {
|
|
4
10
|
cachedConfig = null;
|
|
5
11
|
}
|
|
6
|
-
export function
|
|
12
|
+
export function getBaseConfig() {
|
|
7
13
|
if (cachedConfig)
|
|
8
14
|
return cachedConfig;
|
|
9
|
-
const requiredVars = ["ADMIN_PASSWORD", "ENCRYPTION_KEY", "JWT_SECRET"];
|
|
10
|
-
for (const name of requiredVars) {
|
|
11
|
-
if (!process.env[name]) {
|
|
12
|
-
throw new Error(`Missing required environment variable: ${name}`);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
15
|
cachedConfig = {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
JWT_SECRET: process.env.JWT_SECRET,
|
|
19
|
-
PORT: parseInt(process.env.PORT || "3000", 10),
|
|
20
|
-
DB_PATH: process.env.DB_PATH || "./data/router.db",
|
|
16
|
+
PORT: parseInt(process.env.PORT || "9981", 10),
|
|
17
|
+
DB_PATH: getDefaultDbPath(),
|
|
21
18
|
LOG_LEVEL: process.env.LOG_LEVEL || "info",
|
|
22
19
|
TZ: process.env.TZ || "Asia/Shanghai",
|
|
23
20
|
STREAM_TIMEOUT_MS: parseInt(process.env.STREAM_TIMEOUT_MS || "3000000", 10),
|
|
@@ -26,3 +23,6 @@ export function getConfig() {
|
|
|
26
23
|
};
|
|
27
24
|
return cachedConfig;
|
|
28
25
|
}
|
|
26
|
+
export function getConfig() {
|
|
27
|
+
return getBaseConfig();
|
|
28
|
+
}
|
package/dist/db/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
export declare function initDatabase(dbPath: string): Database.Database;
|
|
3
3
|
export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, } from "./providers.js";
|
|
4
4
|
export type { Provider } from "./providers.js";
|
|
5
|
-
export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, } from "./mappings.js";
|
|
6
|
-
export type { ModelMapping, MappingGroup } from "./mappings.js";
|
|
5
|
+
export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
|
|
6
|
+
export type { ModelMapping, MappingGroup, ProviderModelEntry } from "./mappings.js";
|
|
7
7
|
export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, seedDefaultRules, } from "./retry-rules.js";
|
|
8
8
|
export type { RetryRule } from "./retry-rules.js";
|
|
9
9
|
export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, insertMetrics, } from "./logs.js";
|
|
@@ -14,3 +14,6 @@ export { getMetricsSummary, getMetricsTimeseries } from "./metrics.js";
|
|
|
14
14
|
export type { MetricsSummaryRow, MetricsTimeseriesRow, MetricsPeriod, MetricsMetric } from "./metrics.js";
|
|
15
15
|
export { getStats } from "./stats.js";
|
|
16
16
|
export type { Stats, StatsPeriod } from "./stats.js";
|
|
17
|
+
export { getSetting, setSetting, isInitialized } from "./settings.js";
|
|
18
|
+
export { getSessionStates, getSessionState, getSessionHistory, upsertSessionState, insertSessionHistory, deleteSessionState, } from "./session-states.js";
|
|
19
|
+
export type { SessionModelState, SessionModelHistory, UpsertSessionStateInput, InsertSessionHistoryInput } from "./session-states.js";
|
package/dist/db/index.js
CHANGED
|
@@ -37,9 +37,11 @@ export function initDatabase(dbPath) {
|
|
|
37
37
|
}
|
|
38
38
|
// --- Re-export from per-table modules ---
|
|
39
39
|
export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, } from "./providers.js";
|
|
40
|
-
export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, } from "./mappings.js";
|
|
40
|
+
export { getModelMapping, getAllModelMappings, createModelMapping, updateModelMapping, deleteModelMapping, getMappingGroup, getMappingGroupById, getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getActiveProviderModels, resolveByProviderModel, } from "./mappings.js";
|
|
41
41
|
export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, seedDefaultRules, } from "./retry-rules.js";
|
|
42
42
|
export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, insertMetrics, } from "./logs.js";
|
|
43
43
|
export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
|
|
44
44
|
export { getMetricsSummary, getMetricsTimeseries } from "./metrics.js";
|
|
45
45
|
export { getStats } from "./stats.js";
|
|
46
|
+
export { getSetting, setSetting, isInitialized } from "./settings.js";
|
|
47
|
+
export { getSessionStates, getSessionState, getSessionHistory, upsertSessionState, insertSessionHistory, deleteSessionState, } from "./session-states.js";
|
package/dist/db/logs.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface RequestLog {
|
|
|
17
17
|
client_response: string | null;
|
|
18
18
|
is_retry: number;
|
|
19
19
|
original_request_id: string | null;
|
|
20
|
+
original_model: string | null;
|
|
20
21
|
}
|
|
21
22
|
/** 列表查询扩展字段:JOIN request_metrics + providers 获得 */
|
|
22
23
|
export interface RequestLogListRow extends RequestLog {
|
|
@@ -55,7 +56,7 @@ export type MetricsInsert = {
|
|
|
55
56
|
stop_reason?: string | null;
|
|
56
57
|
is_complete?: number;
|
|
57
58
|
};
|
|
58
|
-
export
|
|
59
|
+
export interface RequestLogInsert {
|
|
59
60
|
id: string;
|
|
60
61
|
api_type: string;
|
|
61
62
|
model: string | null;
|
|
@@ -74,7 +75,9 @@ export declare function insertRequestLog(db: Database.Database, log: {
|
|
|
74
75
|
is_retry?: number;
|
|
75
76
|
original_request_id?: string | null;
|
|
76
77
|
router_key_id?: string | null;
|
|
77
|
-
|
|
78
|
+
original_model?: string | null;
|
|
79
|
+
}
|
|
80
|
+
export declare function insertRequestLog(db: Database.Database, log: RequestLogInsert): void;
|
|
78
81
|
export declare function getRequestLogs(db: Database.Database, options: {
|
|
79
82
|
page: number;
|
|
80
83
|
limit: number;
|
package/dist/db/logs.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
// --- request_logs ---
|
|
3
2
|
export function insertRequestLog(db, log) {
|
|
4
|
-
db.prepare(`INSERT INTO request_logs (id, api_type, model, provider_id, status_code, latency_ms, is_stream, error_message, created_at, request_body, response_body, client_request, upstream_request, upstream_response, client_response, is_retry, original_request_id, router_key_id)
|
|
5
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(log.id, log.api_type, log.model, log.provider_id, log.status_code, log.latency_ms, log.is_stream, log.error_message, log.created_at, log.request_body ?? null, log.response_body ?? null, log.client_request ?? null, log.upstream_request ?? null, log.upstream_response ?? null, log.client_response ?? null, log.is_retry ?? 0, log.original_request_id ?? null, log.router_key_id ?? null);
|
|
3
|
+
db.prepare(`INSERT INTO request_logs (id, api_type, model, provider_id, status_code, latency_ms, is_stream, error_message, created_at, request_body, response_body, client_request, upstream_request, upstream_response, client_response, is_retry, original_request_id, router_key_id, original_model)
|
|
4
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(log.id, log.api_type, log.model, log.provider_id, log.status_code, log.latency_ms, log.is_stream, log.error_message, log.created_at, log.request_body ?? null, log.response_body ?? null, log.client_request ?? null, log.upstream_request ?? null, log.upstream_response ?? null, log.client_response ?? null, log.is_retry ?? 0, log.original_request_id ?? null, log.router_key_id ?? null, log.original_model ?? null);
|
|
6
5
|
}
|
|
7
6
|
export function getRequestLogs(db, options) {
|
|
8
7
|
let where = "1=1";
|
|
@@ -23,7 +22,8 @@ export function getRequestLogs(db, options) {
|
|
|
23
22
|
const offset = (options.page - 1) * options.limit;
|
|
24
23
|
const data = db
|
|
25
24
|
.prepare(`SELECT rl.id, rl.api_type, rl.model, rl.provider_id, rl.status_code, rl.latency_ms,
|
|
26
|
-
rl.is_stream, rl.error_message, rl.created_at, rl.is_retry, rl.original_request_id,
|
|
25
|
+
rl.is_stream, rl.error_message, rl.created_at, rl.is_retry, rl.original_request_id, rl.original_model,
|
|
26
|
+
CASE WHEN rl.provider_id = 'router' THEN rl.upstream_request ELSE NULL END AS upstream_request,
|
|
27
27
|
rm.backend_model, COALESCE(p.name, rl.provider_id) AS provider_name
|
|
28
28
|
FROM request_logs rl
|
|
29
29
|
LEFT JOIN request_metrics rm ON rm.request_log_id = rl.id
|
package/dist/db/mappings.d.ts
CHANGED
|
@@ -34,3 +34,19 @@ export declare function createMappingGroup(db: Database.Database, mapping: {
|
|
|
34
34
|
}): string;
|
|
35
35
|
export declare function updateMappingGroup(db: Database.Database, id: string, fields: Partial<Pick<MappingGroup, "client_model" | "strategy" | "rule">>): void;
|
|
36
36
|
export declare function deleteMappingGroup(db: Database.Database, id: string): void;
|
|
37
|
+
export interface ProviderModelEntry {
|
|
38
|
+
provider_name: string;
|
|
39
|
+
backend_model: string;
|
|
40
|
+
}
|
|
41
|
+
/** 从 providers.models 获取所有可用模型 */
|
|
42
|
+
export declare function getActiveProviderModels(db: Database.Database): ProviderModelEntry[];
|
|
43
|
+
/**
|
|
44
|
+
* 根据 "provider_name/backend_model" 验证模型是否存在于 provider 配置中。
|
|
45
|
+
* 同时尝试从 mapping_groups 中找到对应的 client_model 用于路由。
|
|
46
|
+
* 如果找不到 mapping,返回 backend_model 本身(由 proxy-core 兜底处理)。
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveByProviderModel(db: Database.Database, providerName: string, backendModel: string): {
|
|
49
|
+
client_model: string;
|
|
50
|
+
provider_id: string;
|
|
51
|
+
backend_model: string;
|
|
52
|
+
} | null;
|