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.
Files changed (124) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +121 -0
  4. package/dist/admin/constants.d.ts +10 -0
  5. package/dist/admin/constants.js +11 -0
  6. package/dist/admin/groups.d.ts +7 -0
  7. package/dist/admin/groups.js +118 -0
  8. package/dist/admin/logs.d.ts +7 -0
  9. package/dist/admin/logs.js +43 -0
  10. package/dist/admin/mappings.d.ts +7 -0
  11. package/dist/admin/mappings.js +120 -0
  12. package/dist/admin/metrics.d.ts +7 -0
  13. package/dist/admin/metrics.js +41 -0
  14. package/dist/admin/providers.d.ts +8 -0
  15. package/dist/admin/providers.js +101 -0
  16. package/dist/admin/retry-rules.d.ts +9 -0
  17. package/dist/admin/retry-rules.js +98 -0
  18. package/dist/admin/router-keys.d.ts +8 -0
  19. package/dist/admin/router-keys.js +85 -0
  20. package/dist/admin/routes.d.ts +12 -0
  21. package/dist/admin/routes.js +22 -0
  22. package/dist/admin/services.d.ts +7 -0
  23. package/dist/admin/services.js +63 -0
  24. package/dist/admin/stats.d.ts +7 -0
  25. package/dist/admin/stats.js +15 -0
  26. package/dist/config.d.ts +15 -0
  27. package/dist/config.js +28 -0
  28. package/dist/db/helpers.d.ts +12 -0
  29. package/dist/db/helpers.js +28 -0
  30. package/dist/db/index.d.ts +16 -0
  31. package/dist/db/index.js +45 -0
  32. package/dist/db/logs.d.ts +90 -0
  33. package/dist/db/logs.js +47 -0
  34. package/dist/db/mappings.d.ts +36 -0
  35. package/dist/db/mappings.js +55 -0
  36. package/dist/db/metrics.d.ts +24 -0
  37. package/dist/db/metrics.js +119 -0
  38. package/dist/db/migrations/001_init.sql +37 -0
  39. package/dist/db/migrations/002_add_request_response_body.sql +2 -0
  40. package/dist/db/migrations/003_add_full_request_chain_log.sql +4 -0
  41. package/dist/db/migrations/004_rename_to_providers.sql +9 -0
  42. package/dist/db/migrations/005_add_api_key_preview.sql +1 -0
  43. package/dist/db/migrations/006_create_request_metrics.sql +20 -0
  44. package/dist/db/migrations/007_add_retry_fields.sql +2 -0
  45. package/dist/db/migrations/008_create_router_keys.sql +17 -0
  46. package/dist/db/migrations/009_add_request_logs_indexes.sql +2 -0
  47. package/dist/db/migrations/010_add_key_encrypted.sql +1 -0
  48. package/dist/db/migrations/011_create_mapping_groups.sql +33 -0
  49. package/dist/db/migrations/012_add_provider_models.sql +2 -0
  50. package/dist/db/migrations/013_add_retry_strategy.sql +4 -0
  51. package/dist/db/providers.d.ts +27 -0
  52. package/dist/db/providers.js +29 -0
  53. package/dist/db/retry-rules.d.ts +32 -0
  54. package/dist/db/retry-rules.js +49 -0
  55. package/dist/db/router-keys.d.ts +29 -0
  56. package/dist/db/router-keys.js +36 -0
  57. package/dist/db/stats.d.ts +9 -0
  58. package/dist/db/stats.js +34 -0
  59. package/dist/index.d.ts +13 -0
  60. package/dist/index.js +131 -0
  61. package/dist/metrics/metrics-extractor.d.ts +32 -0
  62. package/dist/metrics/metrics-extractor.js +178 -0
  63. package/dist/metrics/sse-metrics-transform.d.ts +16 -0
  64. package/dist/metrics/sse-metrics-transform.js +35 -0
  65. package/dist/metrics/sse-parser.d.ts +20 -0
  66. package/dist/metrics/sse-parser.js +81 -0
  67. package/dist/middleware/admin-auth.d.ts +8 -0
  68. package/dist/middleware/admin-auth.js +57 -0
  69. package/dist/middleware/auth.d.ts +14 -0
  70. package/dist/middleware/auth.js +41 -0
  71. package/dist/proxy/anthropic.d.ts +12 -0
  72. package/dist/proxy/anthropic.js +34 -0
  73. package/dist/proxy/mapping-resolver.d.ts +3 -0
  74. package/dist/proxy/mapping-resolver.js +27 -0
  75. package/dist/proxy/openai.d.ts +12 -0
  76. package/dist/proxy/openai.js +72 -0
  77. package/dist/proxy/proxy-core.d.ts +75 -0
  78. package/dist/proxy/proxy-core.js +408 -0
  79. package/dist/proxy/retry-rules.d.ts +9 -0
  80. package/dist/proxy/retry-rules.js +27 -0
  81. package/dist/proxy/retry.d.ts +43 -0
  82. package/dist/proxy/retry.js +120 -0
  83. package/dist/proxy/strategy/failover.d.ts +4 -0
  84. package/dist/proxy/strategy/failover.js +5 -0
  85. package/dist/proxy/strategy/random.d.ts +4 -0
  86. package/dist/proxy/strategy/random.js +5 -0
  87. package/dist/proxy/strategy/round-robin.d.ts +4 -0
  88. package/dist/proxy/strategy/round-robin.js +5 -0
  89. package/dist/proxy/strategy/scheduled.d.ts +4 -0
  90. package/dist/proxy/strategy/scheduled.js +62 -0
  91. package/dist/proxy/strategy/types.d.ts +13 -0
  92. package/dist/proxy/strategy/types.js +3 -0
  93. package/dist/utils/crypto.d.ts +2 -0
  94. package/dist/utils/crypto.js +32 -0
  95. package/frontend-dist/assets/CardContent-BE9fukPi.js +1 -0
  96. package/frontend-dist/assets/CardHeader-D5lVaeAA.js +1 -0
  97. package/frontend-dist/assets/CardTitle-H-zwhi3Z.js +1 -0
  98. package/frontend-dist/assets/Checkbox--1gw0dYW.js +1 -0
  99. package/frontend-dist/assets/CollapsibleTrigger-D_ptA35Y.js +1 -0
  100. package/frontend-dist/assets/Dashboard-D4AwkULO.js +3 -0
  101. package/frontend-dist/assets/Label-GiPfoz7u.js +1 -0
  102. package/frontend-dist/assets/Login-BUet1sbM.js +1 -0
  103. package/frontend-dist/assets/Logs-yztb_F9t.js +3 -0
  104. package/frontend-dist/assets/ModelMappings-MbZhdPNv.js +1 -0
  105. package/frontend-dist/assets/Providers-BjsqH6A2.js +1 -0
  106. package/frontend-dist/assets/RetryRules-C2vvJvLr.js +1 -0
  107. package/frontend-dist/assets/RouterKeys-DavrgpAQ.js +1 -0
  108. package/frontend-dist/assets/RovingFocusItem-DnIa_lwH.js +1 -0
  109. package/frontend-dist/assets/SelectValue-BB0Ckbjh.js +1 -0
  110. package/frontend-dist/assets/TableHeader-D2GkiqRx.js +1 -0
  111. package/frontend-dist/assets/alert-dialog-CWjBke-O.js +1 -0
  112. package/frontend-dist/assets/badge-_ZHrMEpC.js +3 -0
  113. package/frontend-dist/assets/button-C4_mChkc.js +1 -0
  114. package/frontend-dist/assets/client-BWw0R36V.js +12 -0
  115. package/frontend-dist/assets/dialog-CUHMcTqp.js +1 -0
  116. package/frontend-dist/assets/index-DEl48bm9.css +1 -0
  117. package/frontend-dist/assets/index-UZK1BnPG.js +1 -0
  118. package/frontend-dist/assets/lib-Qs8xoTas.js +1 -0
  119. package/frontend-dist/assets/useForwardExpose-B-xauF1X.js +1 -0
  120. package/frontend-dist/assets/x-JBJB26JV.js +1 -0
  121. package/frontend-dist/favicon.svg +1 -0
  122. package/frontend-dist/icons.svg +24 -0
  123. package/frontend-dist/index.html +18 -0
  124. 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,2 @@
1
+ ALTER TABLE request_logs ADD COLUMN request_body TEXT;
2
+ ALTER TABLE request_logs ADD COLUMN response_body TEXT;
@@ -0,0 +1,4 @@
1
+ ALTER TABLE request_logs ADD COLUMN client_request TEXT;
2
+ ALTER TABLE request_logs ADD COLUMN upstream_request TEXT;
3
+ ALTER TABLE request_logs ADD COLUMN upstream_response TEXT;
4
+ ALTER TABLE request_logs ADD COLUMN client_response TEXT;
@@ -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,2 @@
1
+ ALTER TABLE request_logs ADD COLUMN is_retry INTEGER NOT NULL DEFAULT 0;
2
+ ALTER TABLE request_logs ADD COLUMN original_request_id TEXT;
@@ -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,2 @@
1
+ CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at DESC);
2
+ CREATE INDEX IF NOT EXISTS idx_request_logs_api_type ON request_logs(api_type);
@@ -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,2 @@
1
+ -- 为供应商增加可用模型列表,存储 JSON 数组
2
+ ALTER TABLE providers ADD COLUMN models TEXT NOT NULL DEFAULT '[]';
@@ -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;
@@ -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
+ }
@@ -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
+ }>;