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,98 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, } from "../db/index.js";
3
+ import { HTTP_BAD_REQUEST, HTTP_CREATED } from "./constants.js";
4
+ const CreateRetryRuleSchema = Type.Object({
5
+ name: Type.String({ minLength: 1 }),
6
+ status_code: Type.Number({ minimum: 100, maximum: 599 }),
7
+ body_pattern: Type.String({ minLength: 1 }),
8
+ is_active: Type.Optional(Type.Number()),
9
+ retry_strategy: Type.Optional(Type.Union([Type.Literal("fixed"), Type.Literal("exponential")])),
10
+ retry_delay_ms: Type.Optional(Type.Number({ minimum: 100 })),
11
+ max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })),
12
+ max_delay_ms: Type.Optional(Type.Number({ minimum: 100 })),
13
+ });
14
+ const UpdateRetryRuleSchema = Type.Object({
15
+ name: Type.Optional(Type.String({ minLength: 1 })),
16
+ status_code: Type.Optional(Type.Number({ minimum: 100, maximum: 599 })),
17
+ body_pattern: Type.Optional(Type.String({ minLength: 1 })),
18
+ is_active: Type.Optional(Type.Number()),
19
+ retry_strategy: Type.Optional(Type.Union([Type.Literal("fixed"), Type.Literal("exponential")])),
20
+ retry_delay_ms: Type.Optional(Type.Number({ minimum: 100 })),
21
+ max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })),
22
+ max_delay_ms: Type.Optional(Type.Number({ minimum: 100 })),
23
+ });
24
+ function validateBodyPattern(pattern) {
25
+ try {
26
+ new RegExp(pattern);
27
+ return undefined;
28
+ }
29
+ catch {
30
+ return "Invalid body_pattern regex";
31
+ }
32
+ }
33
+ function refreshMatcher(matcher, db) {
34
+ if (matcher)
35
+ matcher.load(db);
36
+ }
37
+ export const adminRetryRuleRoutes = (app, options, done) => {
38
+ const { db, matcher } = options;
39
+ app.get("/admin/api/retry-rules", async (_request, reply) => {
40
+ const rules = getAllRetryRules(db);
41
+ return reply.send(rules);
42
+ });
43
+ app.post("/admin/api/retry-rules", { schema: { body: CreateRetryRuleSchema } }, async (request, reply) => {
44
+ const body = request.body;
45
+ const regexError = validateBodyPattern(body.body_pattern);
46
+ if (regexError) {
47
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: regexError } });
48
+ }
49
+ const id = createRetryRule(db, {
50
+ name: body.name,
51
+ status_code: body.status_code,
52
+ body_pattern: body.body_pattern,
53
+ is_active: body.is_active ?? 1,
54
+ retry_strategy: body.retry_strategy ?? "exponential",
55
+ retry_delay_ms: body.retry_delay_ms ?? 5000,
56
+ max_retries: body.max_retries ?? 10,
57
+ max_delay_ms: body.max_delay_ms ?? 60000,
58
+ });
59
+ refreshMatcher(matcher, db);
60
+ return reply.code(HTTP_CREATED).send({ id });
61
+ });
62
+ app.put("/admin/api/retry-rules/:id", { schema: { body: UpdateRetryRuleSchema } }, async (request, reply) => {
63
+ const { id } = request.params;
64
+ const body = request.body;
65
+ const fields = {};
66
+ if (body.name !== undefined)
67
+ fields.name = body.name;
68
+ if (body.status_code !== undefined)
69
+ fields.status_code = body.status_code;
70
+ if (body.body_pattern !== undefined) {
71
+ const regexError = validateBodyPattern(body.body_pattern);
72
+ if (regexError) {
73
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: regexError } });
74
+ }
75
+ fields.body_pattern = body.body_pattern;
76
+ }
77
+ if (body.is_active !== undefined)
78
+ fields.is_active = body.is_active;
79
+ if (body.retry_strategy !== undefined)
80
+ fields.retry_strategy = body.retry_strategy;
81
+ if (body.retry_delay_ms !== undefined)
82
+ fields.retry_delay_ms = body.retry_delay_ms;
83
+ if (body.max_retries !== undefined)
84
+ fields.max_retries = body.max_retries;
85
+ if (body.max_delay_ms !== undefined)
86
+ fields.max_delay_ms = body.max_delay_ms;
87
+ updateRetryRule(db, id, fields);
88
+ refreshMatcher(matcher, db);
89
+ return reply.send({ success: true });
90
+ });
91
+ app.delete("/admin/api/retry-rules/:id", async (request, reply) => {
92
+ const { id } = request.params;
93
+ deleteRetryRule(db, id);
94
+ refreshMatcher(matcher, db);
95
+ return reply.send({ success: true });
96
+ });
97
+ done();
98
+ };
@@ -0,0 +1,8 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface RouterKeyRoutesOptions {
4
+ db: Database.Database;
5
+ encryptionKey: string;
6
+ }
7
+ export declare const adminRouterKeyRoutes: FastifyPluginCallback<RouterKeyRoutesOptions>;
8
+ export {};
@@ -0,0 +1,85 @@
1
+ import { randomBytes, createHash } from "crypto";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { encrypt, decrypt } from "../utils/crypto.js";
4
+ import { getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "../db/index.js";
5
+ const HTTP_CREATED = 201;
6
+ const HTTP_NOT_FOUND = 404;
7
+ const KEY_RANDOM_BYTES = 32;
8
+ const KEY_PREFIX_LENGTH = 8;
9
+ const CreateRouterKeySchema = Type.Object({
10
+ name: Type.String({ minLength: 1 }),
11
+ allowed_models: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
12
+ });
13
+ const UpdateRouterKeySchema = Type.Object({
14
+ name: Type.Optional(Type.String({ minLength: 1 })),
15
+ allowed_models: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
16
+ is_active: Type.Optional(Type.Number()),
17
+ });
18
+ function generateRouterKey(encryptionKey) {
19
+ const key = `sk-router-${randomBytes(KEY_RANDOM_BYTES).toString("hex")}`;
20
+ const hash = createHash("sha256").update(key).digest("hex");
21
+ const prefix = key.slice(0, KEY_PREFIX_LENGTH);
22
+ const encrypted = encrypt(key, encryptionKey);
23
+ return { key, hash, prefix, encrypted };
24
+ }
25
+ function toPublicRouterKey(rk, encryptionKey) {
26
+ return {
27
+ id: rk.id,
28
+ name: rk.name,
29
+ key_prefix: rk.key_prefix,
30
+ key: rk.key_encrypted ? decrypt(rk.key_encrypted, encryptionKey) : null,
31
+ allowed_models: rk.allowed_models ? JSON.parse(rk.allowed_models) : null,
32
+ is_active: rk.is_active,
33
+ created_at: rk.created_at,
34
+ updated_at: rk.updated_at,
35
+ };
36
+ }
37
+ export const adminRouterKeyRoutes = (app, options, done) => {
38
+ const { db, encryptionKey } = options;
39
+ app.get("/admin/api/router-keys", async (_request, reply) => {
40
+ const keys = getAllRouterKeys(db);
41
+ return reply.send(keys.map((rk) => toPublicRouterKey(rk, encryptionKey)));
42
+ });
43
+ app.post("/admin/api/router-keys", { schema: { body: CreateRouterKeySchema } }, async (request, reply) => {
44
+ const body = request.body;
45
+ const { key, hash, prefix, encrypted } = generateRouterKey(encryptionKey);
46
+ const allowedModels = body.allowed_models ? JSON.stringify(body.allowed_models) : null;
47
+ const id = createRouterKey(db, { name: body.name, key_hash: hash, key_prefix: prefix, key_encrypted: encrypted, allowed_models: allowedModels });
48
+ return reply.code(HTTP_CREATED).send({
49
+ id,
50
+ name: body.name,
51
+ key,
52
+ key_prefix: prefix,
53
+ allowed_models: body.allowed_models ?? null,
54
+ is_active: true,
55
+ created_at: new Date().toISOString(),
56
+ });
57
+ });
58
+ app.put("/admin/api/router-keys/:id", { schema: { body: UpdateRouterKeySchema } }, async (request, reply) => {
59
+ const { id } = request.params;
60
+ const existing = getRouterKeyById(db, id);
61
+ if (!existing) {
62
+ return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Router key not found" } });
63
+ }
64
+ const body = request.body;
65
+ const fields = {};
66
+ if (body.name !== undefined)
67
+ fields.name = body.name;
68
+ if (body.allowed_models !== undefined)
69
+ fields.allowed_models = JSON.stringify(body.allowed_models);
70
+ if (body.is_active !== undefined)
71
+ fields.is_active = body.is_active;
72
+ updateRouterKey(db, id, fields);
73
+ return reply.send({ success: true });
74
+ });
75
+ app.delete("/admin/api/router-keys/:id", async (request, reply) => {
76
+ const { id } = request.params;
77
+ deleteRouterKey(db, id);
78
+ return reply.send({ success: true });
79
+ });
80
+ app.get("/admin/api/models/available", async (_request, reply) => {
81
+ const models = getAvailableModels(db);
82
+ return reply.send(models);
83
+ });
84
+ done();
85
+ };
@@ -0,0 +1,12 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ import { RetryRuleMatcher } from "../proxy/retry-rules.js";
4
+ interface AdminRoutesOptions {
5
+ db: Database.Database;
6
+ adminPassword: string;
7
+ jwtSecret: string;
8
+ encryptionKey: string;
9
+ matcher: RetryRuleMatcher | null;
10
+ }
11
+ export declare const adminRoutes: FastifyPluginCallback<AdminRoutesOptions>;
12
+ export {};
@@ -0,0 +1,22 @@
1
+ import { adminAuthPlugin, adminLoginRoutes } from "../middleware/admin-auth.js";
2
+ import { adminProviderRoutes } from "./providers.js";
3
+ import { adminMappingRoutes } from "./mappings.js";
4
+ import { adminGroupRoutes } from "./groups.js";
5
+ import { adminRetryRuleRoutes } from "./retry-rules.js";
6
+ import { adminLogRoutes } from "./logs.js";
7
+ import { adminStatsRoutes } from "./stats.js";
8
+ import { adminMetricsRoutes } from "./metrics.js";
9
+ import { adminRouterKeyRoutes } from "./router-keys.js";
10
+ export const adminRoutes = (app, options, done) => {
11
+ app.register(adminAuthPlugin, { adminPassword: options.adminPassword, jwtSecret: options.jwtSecret });
12
+ app.register(adminLoginRoutes, { adminPassword: options.adminPassword, jwtSecret: options.jwtSecret });
13
+ app.register(adminProviderRoutes, { db: options.db, encryptionKey: options.encryptionKey });
14
+ app.register(adminMappingRoutes, { db: options.db });
15
+ app.register(adminGroupRoutes, { db: options.db });
16
+ app.register(adminRetryRuleRoutes, { db: options.db, matcher: options.matcher });
17
+ app.register(adminLogRoutes, { db: options.db });
18
+ app.register(adminRouterKeyRoutes, { db: options.db, encryptionKey: options.encryptionKey });
19
+ app.register(adminStatsRoutes, { db: options.db });
20
+ app.register(adminMetricsRoutes, { db: options.db });
21
+ done();
22
+ };
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ interface ServiceRoutesOptions {
3
+ db: any;
4
+ encryptionKey: string;
5
+ }
6
+ export declare const adminServiceRoutes: FastifyPluginCallback<ServiceRoutesOptions>;
7
+ export {};
@@ -0,0 +1,63 @@
1
+ import { getAllBackendServices, getBackendServiceById, createBackendService, updateBackendService, deleteBackendService } from "../db/index.js";
2
+ import { decrypt, encrypt } from "../utils/crypto.js";
3
+ function maskApiKey(encrypted, key) {
4
+ const decrypted = decrypt(encrypted, key);
5
+ if (decrypted.length <= 8)
6
+ return "****";
7
+ return `${decrypted.slice(0, 4)}...${decrypted.slice(-4)}`;
8
+ }
9
+ export const adminServiceRoutes = (app, options, done) => {
10
+ const { db, encryptionKey } = options;
11
+ app.get("/admin/api/services", async (_request, reply) => {
12
+ const services = getAllBackendServices(db);
13
+ return reply.send(services.map((s) => ({
14
+ ...s,
15
+ api_key: maskApiKey(s.api_key, encryptionKey),
16
+ })));
17
+ });
18
+ app.post("/admin/api/services", async (request, reply) => {
19
+ const body = request.body;
20
+ if (!body.name || !body.api_type || !body.base_url || !body.api_key) {
21
+ return reply.code(400).send({ error: { message: "Missing required fields: name, api_type, base_url, api_key" } });
22
+ }
23
+ if (!["openai", "anthropic"].includes(body.api_type)) {
24
+ return reply.code(400).send({ error: { message: "api_type must be 'openai' or 'anthropic'" } });
25
+ }
26
+ const encryptedKey = encrypt(body.api_key, encryptionKey);
27
+ const id = createBackendService(db, {
28
+ name: body.name,
29
+ api_type: body.api_type,
30
+ base_url: body.base_url,
31
+ api_key: encryptedKey,
32
+ is_active: body.is_active ?? 1,
33
+ });
34
+ return reply.code(201).send({ id });
35
+ });
36
+ app.put("/admin/api/services/:id", async (request, reply) => {
37
+ const { id } = request.params;
38
+ const existing = getBackendServiceById(db, id);
39
+ if (!existing) {
40
+ return reply.code(404).send({ error: { message: "Service not found" } });
41
+ }
42
+ const body = request.body;
43
+ const fields = {};
44
+ if (body.name !== undefined)
45
+ fields.name = body.name;
46
+ if (body.api_type !== undefined)
47
+ fields.api_type = body.api_type;
48
+ if (body.base_url !== undefined)
49
+ fields.base_url = body.base_url;
50
+ if (body.is_active !== undefined)
51
+ fields.is_active = body.is_active;
52
+ if (body.api_key)
53
+ fields.api_key = encrypt(body.api_key, encryptionKey);
54
+ updateBackendService(db, id, fields);
55
+ return reply.send({ success: true });
56
+ });
57
+ app.delete("/admin/api/services/:id", async (request, reply) => {
58
+ const { id } = request.params;
59
+ deleteBackendService(db, id);
60
+ return reply.send({ success: true });
61
+ });
62
+ done();
63
+ };
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface StatsRoutesOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminStatsRoutes: FastifyPluginCallback<StatsRoutesOptions>;
7
+ export {};
@@ -0,0 +1,15 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getStats } from "../db/index.js";
3
+ const StatsQuerySchema = Type.Object({
4
+ period: Type.Optional(Type.String()),
5
+ router_key_id: Type.Optional(Type.String()),
6
+ });
7
+ export const adminStatsRoutes = (app, options, done) => {
8
+ app.get("/admin/api/stats", { schema: { querystring: StatsQuerySchema } }, async (request, reply) => {
9
+ const query = request.query;
10
+ const period = (query.period || "24h");
11
+ const stats = getStats(options.db, period, query.router_key_id);
12
+ return reply.send(stats);
13
+ });
14
+ done();
15
+ };
@@ -0,0 +1,15 @@
1
+ import "dotenv/config";
2
+ export interface Config {
3
+ ADMIN_PASSWORD: string;
4
+ ENCRYPTION_KEY: string;
5
+ JWT_SECRET: string;
6
+ PORT: number;
7
+ DB_PATH: string;
8
+ LOG_LEVEL: string;
9
+ TZ: string;
10
+ STREAM_TIMEOUT_MS: number;
11
+ RETRY_MAX_ATTEMPTS: number;
12
+ RETRY_BASE_DELAY_MS: number;
13
+ }
14
+ export declare function resetConfig(): void;
15
+ export declare function getConfig(): Config;
package/dist/config.js ADDED
@@ -0,0 +1,28 @@
1
+ import "dotenv/config";
2
+ let cachedConfig = null;
3
+ export function resetConfig() {
4
+ cachedConfig = null;
5
+ }
6
+ export function getConfig() {
7
+ if (cachedConfig)
8
+ 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
+ cachedConfig = {
16
+ ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
17
+ ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
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",
21
+ LOG_LEVEL: process.env.LOG_LEVEL || "info",
22
+ TZ: process.env.TZ || "Asia/Shanghai",
23
+ STREAM_TIMEOUT_MS: parseInt(process.env.STREAM_TIMEOUT_MS || "3000000", 10),
24
+ RETRY_MAX_ATTEMPTS: parseInt(process.env.RETRY_MAX_ATTEMPTS || "3", 10),
25
+ RETRY_BASE_DELAY_MS: parseInt(process.env.RETRY_BASE_DELAY_MS || "1000", 10),
26
+ };
27
+ return cachedConfig;
28
+ }
@@ -0,0 +1,12 @@
1
+ import Database from "better-sqlite3";
2
+ /**
3
+ * 通用 UPDATE 构建器。
4
+ * 用白名单过滤安全字段,拼接 SET 子句。
5
+ *
6
+ * table 参数来自代码中的字符串常量(非用户输入),不存在 SQL 注入风险。
7
+ */
8
+ export declare function buildUpdateQuery(db: Database.Database, table: string, id: string, fields: Record<string, unknown>, allowedKeys: Set<string>, options?: {
9
+ updatedAt?: boolean;
10
+ }): void;
11
+ /** 通用 DELETE by ID */
12
+ export declare function deleteById(db: Database.Database, table: string, id: string): void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 通用 UPDATE 构建器。
3
+ * 用白名单过滤安全字段,拼接 SET 子句。
4
+ *
5
+ * table 参数来自代码中的字符串常量(非用户输入),不存在 SQL 注入风险。
6
+ */
7
+ export function buildUpdateQuery(db, table, id, fields, allowedKeys, options) {
8
+ const sets = [];
9
+ const values = [];
10
+ for (const [key, value] of Object.entries(fields)) {
11
+ if (allowedKeys.has(key)) {
12
+ sets.push(`${key} = ?`);
13
+ values.push(value);
14
+ }
15
+ }
16
+ if (sets.length === 0)
17
+ return;
18
+ if (options?.updatedAt) {
19
+ sets.push("updated_at = ?");
20
+ values.push(new Date().toISOString());
21
+ }
22
+ values.push(id);
23
+ db.prepare(`UPDATE ${table} SET ${sets.join(", ")} WHERE id = ?`).run(...values);
24
+ }
25
+ /** 通用 DELETE by ID */
26
+ export function deleteById(db, table, id) {
27
+ db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
28
+ }
@@ -0,0 +1,16 @@
1
+ import Database from "better-sqlite3";
2
+ export declare function initDatabase(dbPath: string): Database.Database;
3
+ export { getActiveProviders, getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, } from "./providers.js";
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";
7
+ export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, seedDefaultRules, } from "./retry-rules.js";
8
+ export type { RetryRule } from "./retry-rules.js";
9
+ export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, insertMetrics, } from "./logs.js";
10
+ export type { RequestLog, MetricsRow, MetricsInsert } from "./logs.js";
11
+ export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
12
+ export type { RouterKey } from "./router-keys.js";
13
+ export { getMetricsSummary, getMetricsTimeseries } from "./metrics.js";
14
+ export type { MetricsSummaryRow, MetricsTimeseriesRow, MetricsPeriod, MetricsMetric } from "./metrics.js";
15
+ export { getStats } from "./stats.js";
16
+ export type { Stats, StatsPeriod } from "./stats.js";
@@ -0,0 +1,45 @@
1
+ import Database from "better-sqlite3";
2
+ import { readFileSync, readdirSync, mkdirSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const MIGRATIONS_DIR = join(__dirname, "migrations");
8
+ export function initDatabase(dbPath) {
9
+ if (dbPath !== ":memory:") {
10
+ mkdirSync(dirname(dbPath), { recursive: true });
11
+ }
12
+ const db = new Database(dbPath);
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS migrations (
15
+ name TEXT PRIMARY KEY,
16
+ applied_at TEXT NOT NULL
17
+ );
18
+ `);
19
+ const applied = new Set(db.prepare("SELECT name FROM migrations").all().map((r) => r.name));
20
+ const files = readdirSync(MIGRATIONS_DIR)
21
+ .filter((f) => f.endsWith(".sql"))
22
+ .sort();
23
+ for (const file of files) {
24
+ if (applied.has(file))
25
+ continue;
26
+ try {
27
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf-8");
28
+ db.exec(sql);
29
+ }
30
+ catch (err) {
31
+ console.error(`Failed to apply migration ${file}:`, err);
32
+ throw err;
33
+ }
34
+ db.prepare("INSERT INTO migrations (name, applied_at) VALUES (?, ?)").run(file, new Date().toISOString());
35
+ }
36
+ return db;
37
+ }
38
+ // --- Re-export from per-table modules ---
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";
41
+ export { getActiveRetryRules, getAllRetryRules, createRetryRule, updateRetryRule, deleteRetryRule, seedDefaultRules, } from "./retry-rules.js";
42
+ export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, insertMetrics, } from "./logs.js";
43
+ export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
44
+ export { getMetricsSummary, getMetricsTimeseries } from "./metrics.js";
45
+ export { getStats } from "./stats.js";
@@ -0,0 +1,90 @@
1
+ import Database from "better-sqlite3";
2
+ export interface RequestLog {
3
+ id: string;
4
+ api_type: string;
5
+ model: string | null;
6
+ provider_id: string | null;
7
+ status_code: number | null;
8
+ latency_ms: number | null;
9
+ is_stream: number;
10
+ error_message: string | null;
11
+ created_at: string;
12
+ request_body: string | null;
13
+ response_body: string | null;
14
+ client_request: string | null;
15
+ upstream_request: string | null;
16
+ upstream_response: string | null;
17
+ client_response: string | null;
18
+ is_retry: number;
19
+ original_request_id: string | null;
20
+ }
21
+ /** 列表查询扩展字段:JOIN request_metrics + providers 获得 */
22
+ export interface RequestLogListRow extends RequestLog {
23
+ backend_model: string | null;
24
+ provider_name: string | null;
25
+ }
26
+ export interface MetricsRow {
27
+ id: string;
28
+ request_log_id: string;
29
+ provider_id: string;
30
+ backend_model: string;
31
+ api_type: string;
32
+ input_tokens: number | null;
33
+ output_tokens: number | null;
34
+ cache_creation_tokens: number | null;
35
+ cache_read_tokens: number | null;
36
+ ttft_ms: number | null;
37
+ total_duration_ms: number | null;
38
+ tokens_per_second: number | null;
39
+ stop_reason: string | null;
40
+ is_complete: number;
41
+ created_at: string;
42
+ }
43
+ export type MetricsInsert = {
44
+ request_log_id: string;
45
+ provider_id: string;
46
+ backend_model: string;
47
+ api_type: string;
48
+ input_tokens?: number | null;
49
+ output_tokens?: number | null;
50
+ cache_creation_tokens?: number | null;
51
+ cache_read_tokens?: number | null;
52
+ ttft_ms?: number | null;
53
+ total_duration_ms?: number | null;
54
+ tokens_per_second?: number | null;
55
+ stop_reason?: string | null;
56
+ is_complete?: number;
57
+ };
58
+ export declare function insertRequestLog(db: Database.Database, log: {
59
+ id: string;
60
+ api_type: string;
61
+ model: string | null;
62
+ provider_id: string | null;
63
+ status_code: number | null;
64
+ latency_ms: number | null;
65
+ is_stream: number;
66
+ error_message: string | null;
67
+ created_at: string;
68
+ request_body?: string | null;
69
+ response_body?: string | null;
70
+ client_request?: string | null;
71
+ upstream_request?: string | null;
72
+ upstream_response?: string | null;
73
+ client_response?: string | null;
74
+ is_retry?: number;
75
+ original_request_id?: string | null;
76
+ router_key_id?: string | null;
77
+ }): void;
78
+ export declare function getRequestLogs(db: Database.Database, options: {
79
+ page: number;
80
+ limit: number;
81
+ api_type?: string;
82
+ model?: string;
83
+ router_key_id?: string;
84
+ }): {
85
+ data: RequestLogListRow[];
86
+ total: number;
87
+ };
88
+ export declare function getRequestLogById(db: Database.Database, id: string): RequestLog | undefined;
89
+ export declare function deleteLogsBefore(db: Database.Database, beforeDate: string): number;
90
+ export declare function insertMetrics(db: Database.Database, m: MetricsInsert): string;
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from "crypto";
2
+ // --- request_logs ---
3
+ 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);
6
+ }
7
+ export function getRequestLogs(db, options) {
8
+ let where = "1=1";
9
+ const params = [];
10
+ if (options.api_type) {
11
+ where += " AND rl.api_type = ?";
12
+ params.push(options.api_type);
13
+ }
14
+ if (options.model) {
15
+ where += " AND rl.model LIKE ?";
16
+ params.push(`%${options.model}%`);
17
+ }
18
+ if (options.router_key_id) {
19
+ where += " AND rl.router_key_id = ?";
20
+ params.push(options.router_key_id);
21
+ }
22
+ const total = db.prepare(`SELECT COUNT(*) as count FROM request_logs rl WHERE ${where}`).get(...params).count;
23
+ const offset = (options.page - 1) * options.limit;
24
+ const data = db
25
+ .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,
27
+ rm.backend_model, COALESCE(p.name, rl.provider_id) AS provider_name
28
+ FROM request_logs rl
29
+ LEFT JOIN request_metrics rm ON rm.request_log_id = rl.id
30
+ LEFT JOIN providers p ON p.id = rl.provider_id
31
+ WHERE ${where} ORDER BY rl.created_at DESC LIMIT ? OFFSET ?`)
32
+ .all(...params, options.limit, offset);
33
+ return { data, total };
34
+ }
35
+ export function getRequestLogById(db, id) {
36
+ return db.prepare("SELECT * FROM request_logs WHERE id = ?").get(id);
37
+ }
38
+ export function deleteLogsBefore(db, beforeDate) {
39
+ return db.prepare("DELETE FROM request_logs WHERE created_at < ?").run(beforeDate).changes;
40
+ }
41
+ // --- request_metrics ---
42
+ export function insertMetrics(db, m) {
43
+ const id = randomUUID();
44
+ db.prepare(`INSERT INTO request_metrics (id, request_log_id, provider_id, backend_model, api_type, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, ttft_ms, total_duration_ms, tokens_per_second, stop_reason, is_complete)
45
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, m.request_log_id, m.provider_id, m.backend_model, m.api_type, m.input_tokens ?? null, m.output_tokens ?? null, m.cache_creation_tokens ?? null, m.cache_read_tokens ?? null, m.ttft_ms ?? null, m.total_duration_ms ?? null, m.tokens_per_second ?? null, m.stop_reason ?? null, m.is_complete ?? 1);
46
+ return id;
47
+ }
@@ -0,0 +1,36 @@
1
+ import Database from "better-sqlite3";
2
+ export interface ModelMapping {
3
+ id: string;
4
+ client_model: string;
5
+ backend_model: string;
6
+ provider_id: string;
7
+ is_active: number;
8
+ created_at: string;
9
+ }
10
+ export interface MappingGroup {
11
+ id: string;
12
+ client_model: string;
13
+ strategy: string;
14
+ rule: string;
15
+ created_at: string;
16
+ }
17
+ export declare function getModelMapping(db: Database.Database, clientModel: string): ModelMapping | undefined;
18
+ export declare function getAllModelMappings(db: Database.Database): ModelMapping[];
19
+ export declare function createModelMapping(db: Database.Database, mapping: {
20
+ client_model: string;
21
+ backend_model: string;
22
+ provider_id: string;
23
+ is_active?: number;
24
+ }): string;
25
+ export declare function updateModelMapping(db: Database.Database, id: string, fields: Partial<Pick<ModelMapping, "client_model" | "backend_model" | "provider_id" | "is_active">>): void;
26
+ export declare function deleteModelMapping(db: Database.Database, id: string): void;
27
+ export declare function getMappingGroup(db: Database.Database, clientModel: string): MappingGroup | undefined;
28
+ export declare function getMappingGroupById(db: Database.Database, id: string): MappingGroup | undefined;
29
+ export declare function getAllMappingGroups(db: Database.Database): MappingGroup[];
30
+ export declare function createMappingGroup(db: Database.Database, mapping: {
31
+ client_model: string;
32
+ strategy: string;
33
+ rule: string;
34
+ }): string;
35
+ export declare function updateMappingGroup(db: Database.Database, id: string, fields: Partial<Pick<MappingGroup, "client_model" | "strategy" | "rule">>): void;
36
+ export declare function deleteMappingGroup(db: Database.Database, id: string): void;