llm-simple-router 0.1.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/.env.example +13 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/admin/constants.d.ts +10 -0
- package/dist/admin/constants.js +11 -0
- package/dist/admin/groups.d.ts +7 -0
- package/dist/admin/groups.js +118 -0
- package/dist/admin/logs.d.ts +7 -0
- package/dist/admin/logs.js +43 -0
- package/dist/admin/mappings.d.ts +7 -0
- package/dist/admin/mappings.js +120 -0
- package/dist/admin/metrics.d.ts +7 -0
- package/dist/admin/metrics.js +41 -0
- package/dist/admin/providers.d.ts +8 -0
- package/dist/admin/providers.js +101 -0
- package/dist/admin/retry-rules.d.ts +9 -0
- package/dist/admin/retry-rules.js +98 -0
- package/dist/admin/router-keys.d.ts +8 -0
- package/dist/admin/router-keys.js +85 -0
- package/dist/admin/routes.d.ts +12 -0
- package/dist/admin/routes.js +22 -0
- package/dist/admin/services.d.ts +7 -0
- package/dist/admin/services.js +63 -0
- package/dist/admin/stats.d.ts +7 -0
- package/dist/admin/stats.js +15 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +28 -0
- package/dist/db/helpers.d.ts +12 -0
- package/dist/db/helpers.js +28 -0
- package/dist/db/index.d.ts +16 -0
- package/dist/db/index.js +45 -0
- package/dist/db/logs.d.ts +90 -0
- package/dist/db/logs.js +47 -0
- package/dist/db/mappings.d.ts +36 -0
- package/dist/db/mappings.js +55 -0
- package/dist/db/metrics.d.ts +24 -0
- package/dist/db/metrics.js +119 -0
- package/dist/db/migrations/001_init.sql +37 -0
- package/dist/db/migrations/002_add_request_response_body.sql +2 -0
- package/dist/db/migrations/003_add_full_request_chain_log.sql +4 -0
- package/dist/db/migrations/004_rename_to_providers.sql +9 -0
- package/dist/db/migrations/005_add_api_key_preview.sql +1 -0
- package/dist/db/migrations/006_create_request_metrics.sql +20 -0
- package/dist/db/migrations/007_add_retry_fields.sql +2 -0
- package/dist/db/migrations/008_create_router_keys.sql +17 -0
- package/dist/db/migrations/009_add_request_logs_indexes.sql +2 -0
- package/dist/db/migrations/010_add_key_encrypted.sql +1 -0
- package/dist/db/migrations/011_create_mapping_groups.sql +33 -0
- package/dist/db/migrations/012_add_provider_models.sql +2 -0
- package/dist/db/migrations/013_add_retry_strategy.sql +4 -0
- package/dist/db/providers.d.ts +27 -0
- package/dist/db/providers.js +29 -0
- package/dist/db/retry-rules.d.ts +32 -0
- package/dist/db/retry-rules.js +49 -0
- package/dist/db/router-keys.d.ts +29 -0
- package/dist/db/router-keys.js +36 -0
- package/dist/db/stats.d.ts +9 -0
- package/dist/db/stats.js +34 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +131 -0
- package/dist/metrics/metrics-extractor.d.ts +32 -0
- package/dist/metrics/metrics-extractor.js +178 -0
- package/dist/metrics/sse-metrics-transform.d.ts +16 -0
- package/dist/metrics/sse-metrics-transform.js +35 -0
- package/dist/metrics/sse-parser.d.ts +20 -0
- package/dist/metrics/sse-parser.js +81 -0
- package/dist/middleware/admin-auth.d.ts +8 -0
- package/dist/middleware/admin-auth.js +57 -0
- package/dist/middleware/auth.d.ts +14 -0
- package/dist/middleware/auth.js +41 -0
- package/dist/proxy/anthropic.d.ts +12 -0
- package/dist/proxy/anthropic.js +34 -0
- package/dist/proxy/mapping-resolver.d.ts +3 -0
- package/dist/proxy/mapping-resolver.js +27 -0
- package/dist/proxy/openai.d.ts +12 -0
- package/dist/proxy/openai.js +72 -0
- package/dist/proxy/proxy-core.d.ts +75 -0
- package/dist/proxy/proxy-core.js +408 -0
- package/dist/proxy/retry-rules.d.ts +9 -0
- package/dist/proxy/retry-rules.js +27 -0
- package/dist/proxy/retry.d.ts +43 -0
- package/dist/proxy/retry.js +120 -0
- package/dist/proxy/strategy/failover.d.ts +4 -0
- package/dist/proxy/strategy/failover.js +5 -0
- package/dist/proxy/strategy/random.d.ts +4 -0
- package/dist/proxy/strategy/random.js +5 -0
- package/dist/proxy/strategy/round-robin.d.ts +4 -0
- package/dist/proxy/strategy/round-robin.js +5 -0
- package/dist/proxy/strategy/scheduled.d.ts +4 -0
- package/dist/proxy/strategy/scheduled.js +62 -0
- package/dist/proxy/strategy/types.d.ts +13 -0
- package/dist/proxy/strategy/types.js +3 -0
- package/dist/utils/crypto.d.ts +2 -0
- package/dist/utils/crypto.js +32 -0
- package/frontend-dist/assets/CardContent-BE9fukPi.js +1 -0
- package/frontend-dist/assets/CardHeader-D5lVaeAA.js +1 -0
- package/frontend-dist/assets/CardTitle-H-zwhi3Z.js +1 -0
- package/frontend-dist/assets/Checkbox--1gw0dYW.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-D_ptA35Y.js +1 -0
- package/frontend-dist/assets/Dashboard-D4AwkULO.js +3 -0
- package/frontend-dist/assets/Label-GiPfoz7u.js +1 -0
- package/frontend-dist/assets/Login-BUet1sbM.js +1 -0
- package/frontend-dist/assets/Logs-yztb_F9t.js +3 -0
- package/frontend-dist/assets/ModelMappings-MbZhdPNv.js +1 -0
- package/frontend-dist/assets/Providers-BjsqH6A2.js +1 -0
- package/frontend-dist/assets/RetryRules-C2vvJvLr.js +1 -0
- package/frontend-dist/assets/RouterKeys-DavrgpAQ.js +1 -0
- package/frontend-dist/assets/RovingFocusItem-DnIa_lwH.js +1 -0
- package/frontend-dist/assets/SelectValue-BB0Ckbjh.js +1 -0
- package/frontend-dist/assets/TableHeader-D2GkiqRx.js +1 -0
- package/frontend-dist/assets/alert-dialog-CWjBke-O.js +1 -0
- package/frontend-dist/assets/badge-_ZHrMEpC.js +3 -0
- package/frontend-dist/assets/button-C4_mChkc.js +1 -0
- package/frontend-dist/assets/client-BWw0R36V.js +12 -0
- package/frontend-dist/assets/dialog-CUHMcTqp.js +1 -0
- package/frontend-dist/assets/index-DEl48bm9.css +1 -0
- package/frontend-dist/assets/index-UZK1BnPG.js +1 -0
- package/frontend-dist/assets/lib-Qs8xoTas.js +1 -0
- package/frontend-dist/assets/useForwardExpose-B-xauF1X.js +1 -0
- package/frontend-dist/assets/x-JBJB26JV.js +1 -0
- package/frontend-dist/favicon.svg +1 -0
- package/frontend-dist/icons.svg +24 -0
- package/frontend-dist/index.html +18 -0
- package/package.json +72 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { buildUpdateQuery, deleteById } from "./helpers.js";
|
|
3
|
+
const MAPPING_FIELDS = new Set(["client_model", "backend_model", "provider_id", "is_active"]);
|
|
4
|
+
const GROUP_FIELDS = new Set(["client_model", "strategy", "rule"]);
|
|
5
|
+
// --- ModelMapping CRUD ---
|
|
6
|
+
export function getModelMapping(db, clientModel) {
|
|
7
|
+
return db
|
|
8
|
+
.prepare("SELECT * FROM model_mappings WHERE client_model = ? AND is_active = 1")
|
|
9
|
+
.get(clientModel);
|
|
10
|
+
}
|
|
11
|
+
export function getAllModelMappings(db) {
|
|
12
|
+
return db.prepare("SELECT * FROM model_mappings ORDER BY created_at DESC").all();
|
|
13
|
+
}
|
|
14
|
+
export function createModelMapping(db, mapping) {
|
|
15
|
+
const id = randomUUID();
|
|
16
|
+
const now = new Date().toISOString();
|
|
17
|
+
db.prepare(`INSERT INTO model_mappings (id, client_model, backend_model, provider_id, is_active, created_at)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(id, mapping.client_model, mapping.backend_model, mapping.provider_id, mapping.is_active ?? 1, now);
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
export function updateModelMapping(db, id, fields) {
|
|
22
|
+
buildUpdateQuery(db, "model_mappings", id, fields, MAPPING_FIELDS);
|
|
23
|
+
}
|
|
24
|
+
export function deleteModelMapping(db, id) {
|
|
25
|
+
deleteById(db, "model_mappings", id);
|
|
26
|
+
}
|
|
27
|
+
// --- MappingGroups CRUD ---
|
|
28
|
+
export function getMappingGroup(db, clientModel) {
|
|
29
|
+
return db
|
|
30
|
+
.prepare("SELECT * FROM mapping_groups WHERE client_model = ?")
|
|
31
|
+
.get(clientModel);
|
|
32
|
+
}
|
|
33
|
+
export function getMappingGroupById(db, id) {
|
|
34
|
+
return db
|
|
35
|
+
.prepare("SELECT * FROM mapping_groups WHERE id = ?")
|
|
36
|
+
.get(id);
|
|
37
|
+
}
|
|
38
|
+
export function getAllMappingGroups(db) {
|
|
39
|
+
return db
|
|
40
|
+
.prepare("SELECT * FROM mapping_groups ORDER BY created_at DESC")
|
|
41
|
+
.all();
|
|
42
|
+
}
|
|
43
|
+
export function createMappingGroup(db, mapping) {
|
|
44
|
+
const id = randomUUID();
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
db.prepare(`INSERT INTO mapping_groups (id, client_model, strategy, rule, created_at)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?)`).run(id, mapping.client_model, mapping.strategy, mapping.rule, now);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
export function updateMappingGroup(db, id, fields) {
|
|
51
|
+
buildUpdateQuery(db, "mapping_groups", id, fields, GROUP_FIELDS);
|
|
52
|
+
}
|
|
53
|
+
export function deleteMappingGroup(db, id) {
|
|
54
|
+
deleteById(db, "mapping_groups", id);
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export type MetricsPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
|
|
3
|
+
export type MetricsMetric = "ttft" | "tps" | "tokens" | "cache_rate" | "request_count" | "input_tokens" | "output_tokens" | "cache_hit_tokens";
|
|
4
|
+
export interface MetricsSummaryRow {
|
|
5
|
+
provider_id: string;
|
|
6
|
+
provider_name: string;
|
|
7
|
+
backend_model: string;
|
|
8
|
+
request_count: number;
|
|
9
|
+
avg_ttft_ms: number | null;
|
|
10
|
+
p50_ttft_ms: null;
|
|
11
|
+
p95_ttft_ms: null;
|
|
12
|
+
avg_tps: number | null;
|
|
13
|
+
total_input_tokens: number;
|
|
14
|
+
total_output_tokens: number;
|
|
15
|
+
total_cache_hit_tokens: number;
|
|
16
|
+
cache_hit_rate: number | null;
|
|
17
|
+
}
|
|
18
|
+
export declare function getMetricsSummary(db: Database.Database, period: MetricsPeriod, providerId?: string, backendModel?: string, routerKeyId?: string): MetricsSummaryRow[];
|
|
19
|
+
export interface MetricsTimeseriesRow {
|
|
20
|
+
time_bucket: string;
|
|
21
|
+
avg_value: number | null;
|
|
22
|
+
count: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function getMetricsTimeseries(db: Database.Database, period: MetricsPeriod, metric: MetricsMetric, providerId?: string, backendModel?: string, routerKeyId?: string): MetricsTimeseriesRow[];
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const PERIOD_OFFSET = {
|
|
2
|
+
"1h": "-1 hours",
|
|
3
|
+
"6h": "-6 hours",
|
|
4
|
+
"24h": "-1 day",
|
|
5
|
+
"7d": "-7 days",
|
|
6
|
+
"30d": "-30 days",
|
|
7
|
+
};
|
|
8
|
+
const BUCKET_SECONDS = {
|
|
9
|
+
"1h": 60,
|
|
10
|
+
"6h": 300,
|
|
11
|
+
"24h": 900,
|
|
12
|
+
"7d": 3600,
|
|
13
|
+
"30d": 14400,
|
|
14
|
+
};
|
|
15
|
+
// unix epoch 秒转毫秒的乘数
|
|
16
|
+
const MS_PER_SEC = 1000;
|
|
17
|
+
export function getMetricsSummary(db, period, providerId, backendModel, routerKeyId) {
|
|
18
|
+
const offset = PERIOD_OFFSET[period];
|
|
19
|
+
const conditions = ["rm.is_complete = 1", "rm.created_at >= datetime('now', ?)"];
|
|
20
|
+
const params = [offset];
|
|
21
|
+
if (providerId) {
|
|
22
|
+
conditions.push("rm.provider_id = ?");
|
|
23
|
+
params.push(providerId);
|
|
24
|
+
}
|
|
25
|
+
if (backendModel) {
|
|
26
|
+
conditions.push("rm.backend_model = ?");
|
|
27
|
+
params.push(backendModel);
|
|
28
|
+
}
|
|
29
|
+
const joins = "LEFT JOIN providers p ON p.id = rm.provider_id";
|
|
30
|
+
if (routerKeyId) {
|
|
31
|
+
conditions.push("rl.router_key_id = ?");
|
|
32
|
+
params.push(routerKeyId);
|
|
33
|
+
return db.prepare(`
|
|
34
|
+
SELECT
|
|
35
|
+
rm.provider_id, COALESCE(p.name, rm.provider_id) AS provider_name, rm.backend_model,
|
|
36
|
+
COUNT(*) AS request_count, AVG(rm.ttft_ms) AS avg_ttft_ms, NULL AS p50_ttft_ms, NULL AS p95_ttft_ms,
|
|
37
|
+
AVG(rm.tokens_per_second) AS avg_tps,
|
|
38
|
+
COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens, COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens,
|
|
39
|
+
COALESCE(SUM(rm.cache_read_tokens), 0) AS total_cache_hit_tokens,
|
|
40
|
+
CASE WHEN SUM(rm.input_tokens) > 0 THEN SUM(rm.cache_read_tokens) * 1.0 / SUM(rm.input_tokens) ELSE NULL END AS cache_hit_rate
|
|
41
|
+
FROM request_metrics rm
|
|
42
|
+
LEFT JOIN providers p ON p.id = rm.provider_id
|
|
43
|
+
LEFT JOIN request_logs rl ON rl.id = rm.request_log_id
|
|
44
|
+
WHERE ${conditions.join(" AND ")}
|
|
45
|
+
GROUP BY rm.provider_id, rm.backend_model ORDER BY request_count DESC
|
|
46
|
+
`).all(...params);
|
|
47
|
+
}
|
|
48
|
+
const where = conditions.join(" AND ");
|
|
49
|
+
return db.prepare(`
|
|
50
|
+
SELECT
|
|
51
|
+
rm.provider_id,
|
|
52
|
+
COALESCE(p.name, rm.provider_id) AS provider_name,
|
|
53
|
+
rm.backend_model,
|
|
54
|
+
COUNT(*) AS request_count,
|
|
55
|
+
AVG(rm.ttft_ms) AS avg_ttft_ms,
|
|
56
|
+
NULL AS p50_ttft_ms,
|
|
57
|
+
NULL AS p95_ttft_ms,
|
|
58
|
+
AVG(rm.tokens_per_second) AS avg_tps,
|
|
59
|
+
COALESCE(SUM(rm.input_tokens), 0) AS total_input_tokens,
|
|
60
|
+
COALESCE(SUM(rm.output_tokens), 0) AS total_output_tokens,
|
|
61
|
+
COALESCE(SUM(rm.cache_read_tokens), 0) AS total_cache_hit_tokens,
|
|
62
|
+
CASE WHEN SUM(rm.input_tokens) > 0
|
|
63
|
+
THEN SUM(rm.cache_read_tokens) * 1.0 / SUM(rm.input_tokens)
|
|
64
|
+
ELSE NULL
|
|
65
|
+
END AS cache_hit_rate
|
|
66
|
+
FROM request_metrics rm
|
|
67
|
+
${joins}
|
|
68
|
+
WHERE ${where}
|
|
69
|
+
GROUP BY rm.provider_id, rm.backend_model
|
|
70
|
+
ORDER BY request_count DESC
|
|
71
|
+
`).all(...params);
|
|
72
|
+
}
|
|
73
|
+
const METRIC_EXPR = {
|
|
74
|
+
ttft: "AVG(rm.ttft_ms)",
|
|
75
|
+
tps: "AVG(rm.tokens_per_second)",
|
|
76
|
+
tokens: "SUM(rm.output_tokens)",
|
|
77
|
+
cache_rate: "CASE WHEN SUM(rm.input_tokens) > 0 THEN SUM(rm.cache_read_tokens) * 1.0 / SUM(rm.input_tokens) ELSE NULL END",
|
|
78
|
+
request_count: "COUNT(*)",
|
|
79
|
+
input_tokens: "SUM(rm.input_tokens)",
|
|
80
|
+
output_tokens: "SUM(rm.output_tokens)",
|
|
81
|
+
cache_hit_tokens: "SUM(rm.cache_read_tokens)",
|
|
82
|
+
};
|
|
83
|
+
export function getMetricsTimeseries(db, period, metric, providerId, backendModel, routerKeyId) {
|
|
84
|
+
const offset = PERIOD_OFFSET[period];
|
|
85
|
+
const bucketSec = BUCKET_SECONDS[period];
|
|
86
|
+
const conditions = ["rm.is_complete = 1", "rm.created_at >= datetime('now', ?)"];
|
|
87
|
+
const params = [offset];
|
|
88
|
+
if (providerId) {
|
|
89
|
+
conditions.push("rm.provider_id = ?");
|
|
90
|
+
params.push(providerId);
|
|
91
|
+
}
|
|
92
|
+
if (backendModel) {
|
|
93
|
+
conditions.push("rm.backend_model = ?");
|
|
94
|
+
params.push(backendModel);
|
|
95
|
+
}
|
|
96
|
+
if (routerKeyId) {
|
|
97
|
+
conditions.push("rl.router_key_id = ?");
|
|
98
|
+
params.push(routerKeyId);
|
|
99
|
+
}
|
|
100
|
+
const where = conditions.join(" AND ");
|
|
101
|
+
const expr = METRIC_EXPR[metric];
|
|
102
|
+
const joinClause = routerKeyId ? "LEFT JOIN request_logs rl ON rl.id = rm.request_log_id" : "";
|
|
103
|
+
const rows = db.prepare(`
|
|
104
|
+
SELECT
|
|
105
|
+
(unixepoch(rm.created_at) / ?) * ? AS bucket_key,
|
|
106
|
+
${expr} AS avg_value,
|
|
107
|
+
COUNT(*) AS count
|
|
108
|
+
FROM request_metrics rm
|
|
109
|
+
${joinClause}
|
|
110
|
+
WHERE ${where}
|
|
111
|
+
GROUP BY bucket_key
|
|
112
|
+
ORDER BY bucket_key ASC
|
|
113
|
+
`).all(bucketSec, bucketSec, ...params);
|
|
114
|
+
return rows.map((r) => ({
|
|
115
|
+
time_bucket: new Date(r.bucket_key * MS_PER_SEC).toISOString(),
|
|
116
|
+
avg_value: r.avg_value,
|
|
117
|
+
count: r.count,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
2
|
+
name TEXT PRIMARY KEY,
|
|
3
|
+
applied_at TEXT NOT NULL
|
|
4
|
+
);
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS backend_services (
|
|
7
|
+
id TEXT PRIMARY KEY,
|
|
8
|
+
name TEXT NOT NULL,
|
|
9
|
+
api_type TEXT NOT NULL CHECK(api_type IN ('openai', 'anthropic')),
|
|
10
|
+
base_url TEXT NOT NULL,
|
|
11
|
+
api_key TEXT NOT NULL,
|
|
12
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
13
|
+
created_at TEXT NOT NULL,
|
|
14
|
+
updated_at TEXT NOT NULL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS model_mappings (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
client_model TEXT NOT NULL UNIQUE,
|
|
20
|
+
backend_model TEXT NOT NULL,
|
|
21
|
+
backend_service_id TEXT NOT NULL,
|
|
22
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
23
|
+
created_at TEXT NOT NULL,
|
|
24
|
+
FOREIGN KEY (backend_service_id) REFERENCES backend_services(id)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS request_logs (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
api_type TEXT NOT NULL,
|
|
30
|
+
model TEXT,
|
|
31
|
+
backend_service_id TEXT,
|
|
32
|
+
status_code INTEGER,
|
|
33
|
+
latency_ms INTEGER,
|
|
34
|
+
is_stream INTEGER,
|
|
35
|
+
error_message TEXT,
|
|
36
|
+
created_at TEXT NOT NULL
|
|
37
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- backend_services → providers
|
|
2
|
+
-- model_mappings.backend_service_id → provider_id
|
|
3
|
+
-- request_logs.backend_service_id → provider_id
|
|
4
|
+
|
|
5
|
+
ALTER TABLE backend_services RENAME TO providers;
|
|
6
|
+
|
|
7
|
+
ALTER TABLE model_mappings RENAME COLUMN backend_service_id TO provider_id;
|
|
8
|
+
|
|
9
|
+
ALTER TABLE request_logs RENAME COLUMN backend_service_id TO provider_id;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE providers ADD COLUMN api_key_preview TEXT;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
CREATE TABLE request_metrics (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
request_log_id TEXT NOT NULL UNIQUE REFERENCES request_logs(id) ON DELETE CASCADE,
|
|
4
|
+
provider_id TEXT NOT NULL,
|
|
5
|
+
backend_model TEXT NOT NULL,
|
|
6
|
+
api_type TEXT NOT NULL,
|
|
7
|
+
input_tokens INTEGER,
|
|
8
|
+
output_tokens INTEGER,
|
|
9
|
+
cache_creation_tokens INTEGER,
|
|
10
|
+
cache_read_tokens INTEGER,
|
|
11
|
+
ttft_ms INTEGER,
|
|
12
|
+
total_duration_ms INTEGER,
|
|
13
|
+
tokens_per_second REAL,
|
|
14
|
+
stop_reason TEXT,
|
|
15
|
+
is_complete INTEGER NOT NULL DEFAULT 1,
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX idx_metrics_time_provider_model ON request_metrics(created_at, provider_id, backend_model);
|
|
20
|
+
CREATE INDEX idx_metrics_api_type_created_at ON request_metrics(api_type, created_at);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS router_keys (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL,
|
|
4
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
5
|
+
key_prefix TEXT NOT NULL,
|
|
6
|
+
allowed_models TEXT,
|
|
7
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
8
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
9
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_router_keys_hash ON router_keys(key_hash);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_router_keys_active ON router_keys(is_active);
|
|
14
|
+
|
|
15
|
+
ALTER TABLE request_logs ADD COLUMN router_key_id TEXT;
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_request_logs_router_key ON request_logs(router_key_id);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE router_keys ADD COLUMN key_encrypted TEXT;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS mapping_groups (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
client_model TEXT NOT NULL UNIQUE,
|
|
4
|
+
strategy TEXT NOT NULL DEFAULT 'scheduled',
|
|
5
|
+
rule TEXT NOT NULL,
|
|
6
|
+
created_at TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS retry_rules (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
status_code INTEGER NOT NULL,
|
|
13
|
+
body_pattern TEXT NOT NULL,
|
|
14
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
15
|
+
created_at TEXT NOT NULL
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
-- 从现有 model_mappings 迁移数据:
|
|
19
|
+
-- 每条旧映射转为 scheduled 策略,default 指向原后端,windows 为空数组
|
|
20
|
+
INSERT INTO mapping_groups (id, client_model, strategy, rule, created_at)
|
|
21
|
+
SELECT
|
|
22
|
+
lower(hex(randomblob(16))) AS id,
|
|
23
|
+
client_model,
|
|
24
|
+
'scheduled' AS strategy,
|
|
25
|
+
json_object(
|
|
26
|
+
'default', json_object('provider_id', provider_id, 'backend_model', backend_model),
|
|
27
|
+
'windows', json_array()
|
|
28
|
+
) AS rule,
|
|
29
|
+
created_at
|
|
30
|
+
FROM model_mappings
|
|
31
|
+
WHERE is_active = 1;
|
|
32
|
+
|
|
33
|
+
-- 默认重试规则在首次启动时通过 seedDefaultRules() 自动插入,不在此处硬编码
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE retry_rules ADD COLUMN retry_strategy TEXT NOT NULL DEFAULT 'exponential';
|
|
2
|
+
ALTER TABLE retry_rules ADD COLUMN retry_delay_ms INTEGER NOT NULL DEFAULT 5000;
|
|
3
|
+
ALTER TABLE retry_rules ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 10;
|
|
4
|
+
ALTER TABLE retry_rules ADD COLUMN max_delay_ms INTEGER NOT NULL DEFAULT 60000;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export interface Provider {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
api_type: "openai" | "anthropic";
|
|
6
|
+
base_url: string;
|
|
7
|
+
api_key: string;
|
|
8
|
+
api_key_preview?: string;
|
|
9
|
+
models: string;
|
|
10
|
+
is_active: number;
|
|
11
|
+
created_at: string;
|
|
12
|
+
updated_at: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function getActiveProviders(db: Database.Database, apiType: "openai" | "anthropic"): Provider[];
|
|
15
|
+
export declare function getAllProviders(db: Database.Database): Provider[];
|
|
16
|
+
export declare function getProviderById(db: Database.Database, id: string): Provider | undefined;
|
|
17
|
+
export declare function createProvider(db: Database.Database, provider: {
|
|
18
|
+
name: string;
|
|
19
|
+
api_type: "openai" | "anthropic";
|
|
20
|
+
base_url: string;
|
|
21
|
+
api_key: string;
|
|
22
|
+
api_key_preview?: string;
|
|
23
|
+
models?: string;
|
|
24
|
+
is_active?: number;
|
|
25
|
+
}): string;
|
|
26
|
+
export declare function updateProvider(db: Database.Database, id: string, fields: Partial<Pick<Provider, "name" | "api_type" | "base_url" | "api_key" | "api_key_preview" | "is_active">>): void;
|
|
27
|
+
export declare function deleteProvider(db: Database.Database, id: string): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { buildUpdateQuery, deleteById } from "./helpers.js";
|
|
3
|
+
const PROVIDER_FIELDS = new Set([
|
|
4
|
+
"name", "api_type", "base_url", "api_key", "api_key_preview", "models", "is_active",
|
|
5
|
+
]);
|
|
6
|
+
export function getActiveProviders(db, apiType) {
|
|
7
|
+
return db
|
|
8
|
+
.prepare("SELECT * FROM providers WHERE api_type = ? AND is_active = 1")
|
|
9
|
+
.all(apiType);
|
|
10
|
+
}
|
|
11
|
+
export function getAllProviders(db) {
|
|
12
|
+
return db.prepare("SELECT * FROM providers ORDER BY created_at DESC").all();
|
|
13
|
+
}
|
|
14
|
+
export function getProviderById(db, id) {
|
|
15
|
+
return db.prepare("SELECT * FROM providers WHERE id = ?").get(id);
|
|
16
|
+
}
|
|
17
|
+
export function createProvider(db, provider) {
|
|
18
|
+
const id = randomUUID();
|
|
19
|
+
const now = new Date().toISOString();
|
|
20
|
+
db.prepare(`INSERT INTO providers (id, name, api_type, base_url, api_key, api_key_preview, models, is_active, created_at, updated_at)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, provider.name, provider.api_type, provider.base_url, provider.api_key, provider.api_key_preview ?? null, provider.models ?? "[]", provider.is_active ?? 1, now, now);
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
export function updateProvider(db, id, fields) {
|
|
25
|
+
buildUpdateQuery(db, "providers", id, fields, PROVIDER_FIELDS, { updatedAt: true });
|
|
26
|
+
}
|
|
27
|
+
export function deleteProvider(db, id) {
|
|
28
|
+
deleteById(db, "providers", id);
|
|
29
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export interface RetryRule {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
status_code: number;
|
|
6
|
+
body_pattern: string;
|
|
7
|
+
is_active: number;
|
|
8
|
+
created_at: string;
|
|
9
|
+
retry_strategy: "fixed" | "exponential";
|
|
10
|
+
retry_delay_ms: number;
|
|
11
|
+
max_retries: number;
|
|
12
|
+
max_delay_ms: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function getActiveRetryRules(db: Database.Database): RetryRule[];
|
|
15
|
+
export declare function getAllRetryRules(db: Database.Database): RetryRule[];
|
|
16
|
+
export declare function createRetryRule(db: Database.Database, rule: {
|
|
17
|
+
name: string;
|
|
18
|
+
status_code: number;
|
|
19
|
+
body_pattern: string;
|
|
20
|
+
is_active?: number;
|
|
21
|
+
retry_strategy?: string;
|
|
22
|
+
retry_delay_ms?: number;
|
|
23
|
+
max_retries?: number;
|
|
24
|
+
max_delay_ms?: number;
|
|
25
|
+
}): string;
|
|
26
|
+
export declare function updateRetryRule(db: Database.Database, id: string, fields: Partial<Pick<RetryRule, "name" | "status_code" | "body_pattern" | "is_active" | "retry_strategy" | "retry_delay_ms" | "max_retries" | "max_delay_ms">>): void;
|
|
27
|
+
export declare function deleteRetryRule(db: Database.Database, id: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* 首次启动时(表为空)插入默认重试规则。
|
|
30
|
+
* 429/503 为通用 HTTP 重试;其余为 ZAI middleware 特定 400 响应。
|
|
31
|
+
*/
|
|
32
|
+
export declare function seedDefaultRules(db: Database.Database): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { buildUpdateQuery, deleteById } from "./helpers.js";
|
|
3
|
+
const RETRY_FIELDS = new Set(["name", "status_code", "body_pattern", "is_active", "retry_strategy", "retry_delay_ms", "max_retries", "max_delay_ms"]);
|
|
4
|
+
export function getActiveRetryRules(db) {
|
|
5
|
+
return db
|
|
6
|
+
.prepare("SELECT * FROM retry_rules WHERE is_active = 1 ORDER BY created_at DESC")
|
|
7
|
+
.all();
|
|
8
|
+
}
|
|
9
|
+
export function getAllRetryRules(db) {
|
|
10
|
+
return db
|
|
11
|
+
.prepare("SELECT * FROM retry_rules ORDER BY created_at DESC")
|
|
12
|
+
.all();
|
|
13
|
+
}
|
|
14
|
+
export function createRetryRule(db, rule) {
|
|
15
|
+
const id = randomUUID();
|
|
16
|
+
const now = new Date().toISOString();
|
|
17
|
+
db.prepare(`INSERT INTO retry_rules (id, name, status_code, body_pattern, is_active, created_at, retry_strategy, retry_delay_ms, max_retries, max_delay_ms)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, rule.name, rule.status_code, rule.body_pattern, rule.is_active ?? 1, now, rule.retry_strategy ?? "exponential", rule.retry_delay_ms ?? 5000, rule.max_retries ?? 10, rule.max_delay_ms ?? 60000);
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
export function updateRetryRule(db, id, fields) {
|
|
22
|
+
buildUpdateQuery(db, "retry_rules", id, fields, RETRY_FIELDS);
|
|
23
|
+
}
|
|
24
|
+
export function deleteRetryRule(db, id) {
|
|
25
|
+
deleteById(db, "retry_rules", id);
|
|
26
|
+
}
|
|
27
|
+
// ---------- Default seed rules ----------
|
|
28
|
+
const DEFAULT_RULES = [
|
|
29
|
+
{ name: "429 Too Many Requests", status_code: 429, body_pattern: ".*", is_active: 1, retry_strategy: "exponential", retry_delay_ms: 5000, max_retries: 10, max_delay_ms: 60000 },
|
|
30
|
+
{ name: "503 Service Unavailable", status_code: 503, body_pattern: ".*", is_active: 1, retry_strategy: "exponential", retry_delay_ms: 5000, max_retries: 10, max_delay_ms: 60000 },
|
|
31
|
+
{ name: 'ZAI 网络错误 (code 1234)', status_code: 400, body_pattern: '"type"\\s*:\\s*"error".*"code"\\s*:\\s*"1234"', is_active: 1, retry_strategy: "exponential", retry_delay_ms: 5000, max_retries: 10, max_delay_ms: 60000 },
|
|
32
|
+
{ name: 'ZAI 临时不可用', status_code: 400, body_pattern: '"type"\\s*:\\s*"error".*请稍后重试', is_active: 1, retry_strategy: "exponential", retry_delay_ms: 5000, max_retries: 10, max_delay_ms: 60000 },
|
|
33
|
+
{ name: 'ZAI 操作失败 (code 500)', status_code: 400, body_pattern: '"type"\\s*:\\s*"error".*"code"\\s*:\\s*"500"', is_active: 1, retry_strategy: "exponential", retry_delay_ms: 5000, max_retries: 10, max_delay_ms: 60000 },
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* 首次启动时(表为空)插入默认重试规则。
|
|
37
|
+
* 429/503 为通用 HTTP 重试;其余为 ZAI middleware 特定 400 响应。
|
|
38
|
+
*/
|
|
39
|
+
export function seedDefaultRules(db) {
|
|
40
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM retry_rules").get().c;
|
|
41
|
+
if (count > 0)
|
|
42
|
+
return;
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
const insert = db.prepare(`INSERT INTO retry_rules (id, name, status_code, body_pattern, is_active, created_at, retry_strategy, retry_delay_ms, max_retries, max_delay_ms)
|
|
45
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
46
|
+
for (const rule of DEFAULT_RULES) {
|
|
47
|
+
insert.run(randomUUID(), rule.name, rule.status_code, rule.body_pattern, rule.is_active, now, rule.retry_strategy, rule.retry_delay_ms, rule.max_retries, rule.max_delay_ms);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export interface RouterKey {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
key_hash: string;
|
|
6
|
+
key_prefix: string;
|
|
7
|
+
key_encrypted: string | null;
|
|
8
|
+
allowed_models: string | null;
|
|
9
|
+
is_active: number;
|
|
10
|
+
created_at: string;
|
|
11
|
+
updated_at: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function getRouterKeyByHash(db: Database.Database, hash: string): {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
allowed_models: string | null;
|
|
17
|
+
} | undefined;
|
|
18
|
+
export declare function getAllRouterKeys(db: Database.Database): RouterKey[];
|
|
19
|
+
export declare function getRouterKeyById(db: Database.Database, id: string): RouterKey | undefined;
|
|
20
|
+
export declare function createRouterKey(db: Database.Database, key: {
|
|
21
|
+
name: string;
|
|
22
|
+
key_hash: string;
|
|
23
|
+
key_prefix: string;
|
|
24
|
+
key_encrypted: string;
|
|
25
|
+
allowed_models?: string | null;
|
|
26
|
+
}): string;
|
|
27
|
+
export declare function updateRouterKey(db: Database.Database, id: string, fields: Partial<Pick<RouterKey, "name" | "allowed_models" | "is_active">>): void;
|
|
28
|
+
export declare function deleteRouterKey(db: Database.Database, id: string): void;
|
|
29
|
+
export declare function getAvailableModels(db: Database.Database): string[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { buildUpdateQuery, deleteById } from "./helpers.js";
|
|
3
|
+
export function getRouterKeyByHash(db, hash) {
|
|
4
|
+
return db.prepare("SELECT id, name, allowed_models FROM router_keys WHERE key_hash = ? AND is_active = 1").get(hash);
|
|
5
|
+
}
|
|
6
|
+
export function getAllRouterKeys(db) {
|
|
7
|
+
return db.prepare("SELECT * FROM router_keys ORDER BY created_at DESC").all();
|
|
8
|
+
}
|
|
9
|
+
export function getRouterKeyById(db, id) {
|
|
10
|
+
return db.prepare("SELECT * FROM router_keys WHERE id = ?").get(id);
|
|
11
|
+
}
|
|
12
|
+
export function createRouterKey(db, key) {
|
|
13
|
+
const id = randomUUID();
|
|
14
|
+
const now = new Date().toISOString();
|
|
15
|
+
db.prepare(`INSERT INTO router_keys (id, name, key_hash, key_prefix, key_encrypted, allowed_models, is_active, created_at, updated_at)
|
|
16
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`).run(id, key.name, key.key_hash, key.key_prefix, key.key_encrypted, key.allowed_models ?? null, now, now);
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
const ROUTER_KEY_FIELDS = new Set(["name", "allowed_models", "is_active"]);
|
|
20
|
+
export function updateRouterKey(db, id, fields) {
|
|
21
|
+
buildUpdateQuery(db, "router_keys", id, fields, ROUTER_KEY_FIELDS, { updatedAt: true });
|
|
22
|
+
}
|
|
23
|
+
export function deleteRouterKey(db, id) {
|
|
24
|
+
deleteById(db, "router_keys", id);
|
|
25
|
+
}
|
|
26
|
+
export function getAvailableModels(db) {
|
|
27
|
+
const rows = db.prepare("SELECT models FROM providers WHERE is_active = 1").all();
|
|
28
|
+
const set = new Set();
|
|
29
|
+
for (const r of rows) {
|
|
30
|
+
try {
|
|
31
|
+
JSON.parse(r.models || "[]").forEach((m) => set.add(m));
|
|
32
|
+
}
|
|
33
|
+
catch { /* skip invalid JSON */ }
|
|
34
|
+
}
|
|
35
|
+
return [...set].sort();
|
|
36
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export type StatsPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
|
|
3
|
+
export interface Stats {
|
|
4
|
+
totalRequests: number;
|
|
5
|
+
successRate: number;
|
|
6
|
+
avgTps: number;
|
|
7
|
+
totalTokens: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function getStats(db: Database.Database, period: StatsPeriod, routerKeyId?: string): Stats;
|
package/dist/db/stats.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const PERIOD_OFFSET = {
|
|
2
|
+
"1h": "-1 hours",
|
|
3
|
+
"6h": "-6 hours",
|
|
4
|
+
"24h": "-1 day",
|
|
5
|
+
"7d": "-7 days",
|
|
6
|
+
"30d": "-30 days",
|
|
7
|
+
};
|
|
8
|
+
export function getStats(db, period, routerKeyId) {
|
|
9
|
+
const offset = PERIOD_OFFSET[period];
|
|
10
|
+
const conditions = ["rm.is_complete = 1", "rm.created_at >= datetime('now', ?)"];
|
|
11
|
+
const params = [offset];
|
|
12
|
+
if (routerKeyId) {
|
|
13
|
+
conditions.push("rl.router_key_id = ?");
|
|
14
|
+
params.push(routerKeyId);
|
|
15
|
+
}
|
|
16
|
+
const where = conditions.join(" AND ");
|
|
17
|
+
const row = db.prepare(`
|
|
18
|
+
SELECT
|
|
19
|
+
COUNT(*) AS total_requests,
|
|
20
|
+
SUM(CASE WHEN rl.status_code >= 200 AND rl.status_code < 300 THEN 1 ELSE 0 END) AS success_count,
|
|
21
|
+
AVG(rm.tokens_per_second) AS avg_tps,
|
|
22
|
+
COALESCE(SUM(rm.input_tokens), 0) + COALESCE(SUM(rm.output_tokens), 0) AS total_tokens
|
|
23
|
+
FROM request_metrics rm
|
|
24
|
+
JOIN request_logs rl ON rl.id = rm.request_log_id
|
|
25
|
+
WHERE ${where}
|
|
26
|
+
`).get(...params);
|
|
27
|
+
const total = row?.total_requests ?? 0;
|
|
28
|
+
return {
|
|
29
|
+
totalRequests: total,
|
|
30
|
+
successRate: total > 0 ? (row?.success_count ?? 0) / total : 0,
|
|
31
|
+
avgTps: row?.avg_tps ?? 0,
|
|
32
|
+
totalTokens: row?.total_tokens ?? 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { FastifyInstance } from "fastify";
|
|
3
|
+
import { Config } from "./config.js";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
export interface AppOptions {
|
|
6
|
+
config?: Config;
|
|
7
|
+
db?: Database.Database;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildApp(options?: AppOptions): Promise<{
|
|
10
|
+
app: FastifyInstance;
|
|
11
|
+
db: Database.Database;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
}>;
|