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
package/.env.example ADDED
@@ -0,0 +1,13 @@
1
+ # 必需配置
2
+ ADMIN_PASSWORD=admin123
3
+ ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
4
+ JWT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
5
+
6
+ # 可选配置
7
+ PORT=3000
8
+ DB_PATH=./data/router.db
9
+ LOG_LEVEL=info
10
+ TZ=Asia/Shanghai
11
+ STREAM_TIMEOUT_MS=3000000
12
+ RETRY_MAX_ATTEMPTS=3
13
+ RETRY_BASE_DELAY_MS=1000
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZzzzSsssWwww
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # LLM Simple Router
2
+
3
+ > **Status: Active Development**
4
+ >
5
+ > 核心代理、模型映射、自动重试、多密钥管理、请求日志、性能指标已完成。
6
+ > 代码规范 githook 检查已集成。欢迎试用和反馈。
7
+
8
+ ## 解决的核心问题
9
+
10
+ 个人使用 Claude Code 配合国产模型时的实际痛点:
11
+
12
+ - **自动重试** — 国产模型限流、网络错误频繁,对可恢复错误(429/500/网络超时)自动指数退避重试
13
+ - **多供应商模型映射** — 高峰期主模型不可用时,将 claude-opus 映射到 GLM,claude-sonnet 映射到 Kimi 等,低谷期再切回来
14
+ - **多密钥隔离** — 为不同使用方分配独立密钥,按密钥筛选日志和性能指标
15
+
16
+ ## 功能
17
+
18
+ | 功能 | 说明 |
19
+ |------|------|
20
+ | 模型映射 | 客户端模型名 -> 后端模型名 + 供应商,支持分组和优先级 |
21
+ | 自动重试 | 429/500/网络错误自动重试,指数退避,可配置次数和间隔 |
22
+ | 多供应商 | 配置多个后端供应商,按模型映射路由 |
23
+ | 多密钥 (Router Keys) | 为不同使用方创建独立密钥,支持模型白名单 |
24
+ | 流式代理 | 完整支持 SSE 流式和非流式请求 |
25
+ | 管理后台 | Vue 3 + shadcn-vue Web UI,管理供应商、映射、密钥 |
26
+ | 请求日志 | 结构化展示完整四阶段链路(客户端请求/上游请求/上游响应/客户端响应),适配 Claude Code 请求格式 |
27
+ | 性能指标 | TTFT、吞吐量、Token 用量、缓存命中率,支持按模型/密钥筛选 |
28
+
29
+ > **API 兼容性:** 支持 Anthropic 兼容 API(已适配 Claude Code)。OpenAI 兼容 API(`/v1/chat/completions`)尚未充分测试。
30
+
31
+ ## 管理后台预览
32
+
33
+ | Dashboard | Provider 管理 |
34
+ |-----------|-------------|
35
+ | ![Dashboard](docs/screenshot/dashboard.png) | ![Provider](docs/screenshot/provider.png) |
36
+
37
+ | 模型映射 | 重试规则 |
38
+ |---------|--------|
39
+ | ![Model Mapping](docs/screenshot/model_mapping.png) | ![Retry](docs/screenshot/retry.png) |
40
+
41
+ | 请求日志 |
42
+ |---------|
43
+ | ![Logs](docs/screenshot/log.png) |
44
+
45
+ ## 工作原理
46
+
47
+ ```
48
+ Claude Code -> Router (模型映射 + 自动重试) -> 智谱 GLM / Kimi / 其他供应商
49
+ ```
50
+
51
+ Router 根据模型映射找到后端供应商 -> 转发请求 -> 自动重试失败请求 -> 记录日志和性能指标 -> 返回响应。
52
+
53
+ ## 典型使用场景
54
+
55
+ ### Claude Code 配置
56
+
57
+ 通过环境变量将 Claude Code 指向 Router:
58
+
59
+ **方式一:shell alias(推荐)**
60
+
61
+ ```bash
62
+ alias clodedev='ANTHROPIC_AUTH_TOKEN="<your-router-key>" ANTHROPIC_BASE_URL="http://127.0.0.1:3000" claude'
63
+ ```
64
+
65
+ **方式二:~/.claude/settings.json**
66
+
67
+ ```json
68
+ {
69
+ "env": {
70
+ "ANTHROPIC_AUTH_TOKEN": "sk-router-change-me",
71
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:3000",
72
+ "ANTHROPIC_MODEL": "some-model"
73
+ }
74
+ }
75
+ ```
76
+
77
+ 将 `<your-router-key>` 替换为管理后台中创建的 Router Key。
78
+
79
+ ### 管理后台配置模型映射
80
+
81
+ | 客户端模型 | 后端模型 | 供应商 | 时间窗口 |
82
+ |-----------|---------|--------|---------|
83
+ | opus | glm-5.1 | 智谱 | / |
84
+ | sonnet | glm-5.1 | 智谱 | / |
85
+ | sonnet | kimi-for-coding | Moonshot | 14:00-18:00 |
86
+ | sonnet | glm-5-turbo | 智谱 | / |
87
+
88
+ 高峰期 GLM 3倍用量,且频繁超限时,将 sonnet 切到 Kimi;低谷期切回 GLM。
89
+
90
+ ## 快速开始
91
+
92
+ ```bash
93
+ npm install
94
+ cp .env.example .env
95
+ # 编辑 .env,设置 ADMIN_PASSWORD、ENCRYPTION_KEY、JWT_SECRET
96
+ npm run dev
97
+ # 访问 http://localhost:3000/admin
98
+ ```
99
+
100
+ 生成随机密钥:`openssl rand -hex 32`
101
+
102
+ ## Docker 部署
103
+
104
+ ```bash
105
+ docker compose up -d
106
+ ```
107
+
108
+ ## 环境变量
109
+
110
+ | 变量 | 必需 | 默认值 | 说明 |
111
+ |------|------|--------|------|
112
+ | `ADMIN_PASSWORD` | Yes | -- | 管理后台密码 |
113
+ | `ENCRYPTION_KEY` | Yes | -- | AES-256-GCM 密钥(64字符 hex) |
114
+ | `JWT_SECRET` | Yes | -- | JWT 签名密钥(64字符 hex) |
115
+ | `PORT` | No | `3000` | 服务端口 |
116
+ | `DB_PATH` | No | `./data/router.db` | SQLite 数据库路径 |
117
+ | `LOG_LEVEL` | No | `info` | 日志级别 |
118
+ | `TZ` | No | -- | 时区设置 |
119
+ | `STREAM_TIMEOUT_MS` | No | `3000000` | 流式代理空闲超时(ms) |
120
+ | `RETRY_MAX_ATTEMPTS` | No | `3` | 最大重试次数 |
121
+ | `RETRY_BASE_DELAY_MS` | No | `1000` | 重试基础延迟(ms) |
@@ -0,0 +1,10 @@
1
+ import type { FastifyReply } from "fastify";
2
+ export declare const HTTP_BAD_REQUEST = 400;
3
+ export declare const HTTP_CREATED = 201;
4
+ export declare const HTTP_FORBIDDEN = 403;
5
+ export declare const HTTP_NOT_FOUND = 404;
6
+ export declare const HTTP_CONFLICT = 409;
7
+ export declare const HTTP_INTERNAL_ERROR = 500;
8
+ export declare const HTTP_BAD_GATEWAY = 502;
9
+ export declare const HTTP_SERVICE_UNAVAILABLE = 503;
10
+ export declare function sendErrorResponse(reply: FastifyReply, statusCode: number, message: string): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
@@ -0,0 +1,11 @@
1
+ export const HTTP_BAD_REQUEST = 400;
2
+ export const HTTP_CREATED = 201;
3
+ export const HTTP_FORBIDDEN = 403;
4
+ export const HTTP_NOT_FOUND = 404;
5
+ export const HTTP_CONFLICT = 409;
6
+ export const HTTP_INTERNAL_ERROR = 500;
7
+ export const HTTP_BAD_GATEWAY = 502;
8
+ export const HTTP_SERVICE_UNAVAILABLE = 503;
9
+ export function sendErrorResponse(reply, statusCode, message) {
10
+ return reply.code(statusCode).send({ error: { message } });
11
+ }
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface GroupRoutesOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminGroupRoutes: FastifyPluginCallback<GroupRoutesOptions>;
7
+ export {};
@@ -0,0 +1,118 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getProviderById, getMappingGroupById, } from "../db/index.js";
3
+ import { STRATEGY_NAMES } from "../proxy/strategy/types.js";
4
+ import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_CONFLICT } from "./constants.js";
5
+ const CreateGroupSchema = Type.Object({
6
+ client_model: Type.String({ minLength: 1 }),
7
+ strategy: Type.String({ minLength: 1 }),
8
+ rule: Type.String(),
9
+ });
10
+ const UpdateGroupSchema = Type.Object({
11
+ client_model: Type.Optional(Type.String({ minLength: 1 })),
12
+ strategy: Type.Optional(Type.String({ minLength: 1 })),
13
+ rule: Type.Optional(Type.String()),
14
+ });
15
+ async function validateRule(db, strategy, ruleJson) {
16
+ let rule;
17
+ try {
18
+ rule = JSON.parse(ruleJson);
19
+ }
20
+ catch {
21
+ return "Invalid rule JSON";
22
+ }
23
+ if (strategy === STRATEGY_NAMES.SCHEDULED) {
24
+ const r = rule;
25
+ if (!r.default || !r.default.backend_model || !r.default.provider_id) {
26
+ return "rule.default.backend_model and rule.default.provider_id are required";
27
+ }
28
+ const defaultProvider = getProviderById(db, r.default.provider_id);
29
+ if (!defaultProvider) {
30
+ return `provider_id '${r.default.provider_id}' not found`;
31
+ }
32
+ if (r.windows !== undefined && !Array.isArray(r.windows)) {
33
+ return "rule.windows must be an array";
34
+ }
35
+ if (Array.isArray(r.windows)) {
36
+ for (let i = 0; i < r.windows.length; i++) {
37
+ const w = r.windows[i];
38
+ if (!w.start || !w.end || !w.target || !w.target.backend_model || !w.target.provider_id) {
39
+ return `window[${i}] missing start/end/target.backend_model/target.provider_id`;
40
+ }
41
+ const p = getProviderById(db, w.target.provider_id);
42
+ if (!p) {
43
+ return `window[${i}] provider_id '${w.target.provider_id}' not found`;
44
+ }
45
+ }
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ export const adminGroupRoutes = (app, options, done) => {
51
+ const { db } = options;
52
+ app.get("/admin/api/mapping-groups", async (_request, reply) => {
53
+ const groups = getAllMappingGroups(db);
54
+ return reply.send(groups);
55
+ });
56
+ app.post("/admin/api/mapping-groups", { schema: { body: CreateGroupSchema } }, async (request, reply) => {
57
+ const body = request.body;
58
+ const validationError = await validateRule(db, body.strategy, body.rule);
59
+ if (validationError) {
60
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: validationError } });
61
+ }
62
+ try {
63
+ const id = createMappingGroup(db, {
64
+ client_model: body.client_model,
65
+ strategy: body.strategy,
66
+ rule: body.rule,
67
+ });
68
+ return reply.code(HTTP_CREATED).send({ id });
69
+ }
70
+ catch (err) {
71
+ if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
72
+ return reply.code(HTTP_CONFLICT).send({ error: { message: "client_model already exists" } });
73
+ }
74
+ throw err;
75
+ }
76
+ });
77
+ app.put("/admin/api/mapping-groups/:id", { schema: { body: UpdateGroupSchema } }, async (request, reply) => {
78
+ const { id } = request.params;
79
+ const body = request.body;
80
+ const fields = {};
81
+ if (body.client_model !== undefined)
82
+ fields.client_model = body.client_model;
83
+ if (body.strategy !== undefined)
84
+ fields.strategy = body.strategy;
85
+ if (body.rule !== undefined)
86
+ fields.rule = body.rule;
87
+ const strategy = body.strategy ?? findGroupStrategy(db, id);
88
+ const ruleJson = body.rule ?? findGroupRule(db, id);
89
+ const validationError = await validateRule(db, strategy, ruleJson);
90
+ if (validationError) {
91
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: validationError } });
92
+ }
93
+ try {
94
+ updateMappingGroup(db, id, fields);
95
+ return reply.send({ success: true });
96
+ }
97
+ catch (err) {
98
+ if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
99
+ return reply.code(HTTP_CONFLICT).send({ error: { message: "client_model already exists" } });
100
+ }
101
+ throw err;
102
+ }
103
+ });
104
+ app.delete("/admin/api/mapping-groups/:id", async (request, reply) => {
105
+ const { id } = request.params;
106
+ deleteMappingGroup(db, id);
107
+ return reply.send({ success: true });
108
+ });
109
+ done();
110
+ };
111
+ function findGroupStrategy(db, id) {
112
+ const g = getMappingGroupById(db, id);
113
+ return g?.strategy ?? STRATEGY_NAMES.SCHEDULED;
114
+ }
115
+ function findGroupRule(db, id) {
116
+ const g = getMappingGroupById(db, id);
117
+ return g?.rule ?? "{}";
118
+ }
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface LogRoutesOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminLogRoutes: FastifyPluginCallback<LogRoutesOptions>;
7
+ export {};
@@ -0,0 +1,43 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getRequestLogs, getRequestLogById, deleteLogsBefore } from "../db/index.js";
3
+ import { HTTP_NOT_FOUND } from "./constants.js";
4
+ const LogQuerySchema = Type.Object({
5
+ page: Type.Optional(Type.String()),
6
+ limit: Type.Optional(Type.String()),
7
+ api_type: Type.Optional(Type.String()),
8
+ model: Type.Optional(Type.String()),
9
+ router_key_id: Type.Optional(Type.String()),
10
+ });
11
+ const DeleteLogsBeforeSchema = Type.Object({
12
+ before: Type.String({ minLength: 1 }),
13
+ });
14
+ export const adminLogRoutes = (app, options, done) => {
15
+ const { db } = options;
16
+ app.get("/admin/api/logs", { schema: { querystring: LogQuerySchema } }, async (request, reply) => {
17
+ const query = request.query;
18
+ const page = parseInt(query.page || "1", 10);
19
+ const limit = parseInt(query.limit || "20", 10);
20
+ const result = getRequestLogs(db, {
21
+ page,
22
+ limit,
23
+ api_type: query.api_type || undefined,
24
+ model: query.model || undefined,
25
+ router_key_id: query.router_key_id || undefined,
26
+ });
27
+ return reply.send({ ...result, page, limit });
28
+ });
29
+ app.get("/admin/api/logs/:id", async (request, reply) => {
30
+ const params = request.params;
31
+ const log = getRequestLogById(db, params.id);
32
+ if (!log) {
33
+ return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Log not found" } });
34
+ }
35
+ return reply.send(log);
36
+ });
37
+ app.delete("/admin/api/logs/before", { schema: { body: DeleteLogsBeforeSchema } }, async (request, reply) => {
38
+ const body = request.body;
39
+ const deleted = deleteLogsBefore(db, body.before);
40
+ return reply.send({ deleted });
41
+ });
42
+ done();
43
+ };
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface MappingRoutesOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminMappingRoutes: FastifyPluginCallback<MappingRoutesOptions>;
7
+ export {};
@@ -0,0 +1,120 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getProviderById, getMappingGroupById, getMappingGroup, } from "../db/index.js";
3
+ import { STRATEGY_NAMES } from "../proxy/strategy/types.js";
4
+ import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT } from "./constants.js";
5
+ const CreateMappingSchema = Type.Object({
6
+ client_model: Type.String({ minLength: 1 }),
7
+ backend_model: Type.String({ minLength: 1 }),
8
+ provider_id: Type.String({ minLength: 1 }),
9
+ });
10
+ const UpdateMappingSchema = Type.Object({
11
+ client_model: Type.Optional(Type.String({ minLength: 1 })),
12
+ backend_model: Type.Optional(Type.String({ minLength: 1 })),
13
+ provider_id: Type.Optional(Type.String({ minLength: 1 })),
14
+ });
15
+ function toLegacy(group) {
16
+ let rule;
17
+ try {
18
+ rule = JSON.parse(group.rule);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ const defaultTarget = rule?.default;
24
+ if (!defaultTarget)
25
+ return null;
26
+ return {
27
+ id: group.id,
28
+ client_model: group.client_model,
29
+ backend_model: defaultTarget.backend_model ?? "",
30
+ provider_id: defaultTarget.provider_id ?? "",
31
+ is_active: 1,
32
+ created_at: group.created_at,
33
+ };
34
+ }
35
+ function findGroupByIdOrClientModel(db, id) {
36
+ return getMappingGroupById(db, id) ?? getMappingGroup(db, id);
37
+ }
38
+ export const adminMappingRoutes = (app, options, done) => {
39
+ const { db } = options;
40
+ app.get("/admin/api/mappings", async (_request, reply) => {
41
+ const groups = getAllMappingGroups(db);
42
+ const legacy = groups.map(toLegacy).filter((m) => m !== null);
43
+ return reply.send(legacy);
44
+ });
45
+ app.post("/admin/api/mappings", { schema: { body: CreateMappingSchema } }, async (request, reply) => {
46
+ const body = request.body;
47
+ const provider = getProviderById(db, body.provider_id);
48
+ if (!provider) {
49
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: "provider_id not found" } });
50
+ }
51
+ try {
52
+ const id = createMappingGroup(db, {
53
+ client_model: body.client_model,
54
+ strategy: STRATEGY_NAMES.SCHEDULED,
55
+ rule: JSON.stringify({
56
+ default: { backend_model: body.backend_model, provider_id: body.provider_id },
57
+ windows: [],
58
+ }),
59
+ });
60
+ return reply.code(HTTP_CREATED).send({ id });
61
+ }
62
+ catch (err) {
63
+ if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
64
+ return reply.code(HTTP_CONFLICT).send({ error: { message: "client_model already exists" } });
65
+ }
66
+ throw err;
67
+ }
68
+ });
69
+ app.put("/admin/api/mappings/:id", { schema: { body: UpdateMappingSchema } }, async (request, reply) => {
70
+ const { id } = request.params;
71
+ const group = findGroupByIdOrClientModel(db, id);
72
+ if (!group) {
73
+ return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Mapping not found" } });
74
+ }
75
+ const body = request.body;
76
+ let rule;
77
+ try {
78
+ rule = JSON.parse(group.rule);
79
+ }
80
+ catch {
81
+ rule = { default: {}, windows: [] };
82
+ }
83
+ const defaultTarget = { ...(rule.default || {}) };
84
+ if (body.backend_model !== undefined)
85
+ defaultTarget.backend_model = body.backend_model;
86
+ if (body.provider_id !== undefined) {
87
+ const provider = getProviderById(db, body.provider_id);
88
+ if (!provider) {
89
+ return reply.code(HTTP_BAD_REQUEST).send({ error: { message: "provider_id not found" } });
90
+ }
91
+ defaultTarget.provider_id = body.provider_id;
92
+ }
93
+ rule.default = defaultTarget;
94
+ const fields = {
95
+ rule: JSON.stringify(rule),
96
+ };
97
+ if (body.client_model !== undefined)
98
+ fields.client_model = body.client_model;
99
+ try {
100
+ updateMappingGroup(db, group.id, fields);
101
+ return reply.send({ success: true });
102
+ }
103
+ catch (err) {
104
+ if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
105
+ return reply.code(HTTP_CONFLICT).send({ error: { message: "client_model already exists" } });
106
+ }
107
+ throw err;
108
+ }
109
+ });
110
+ app.delete("/admin/api/mappings/:id", async (request, reply) => {
111
+ const { id } = request.params;
112
+ const group = findGroupByIdOrClientModel(db, id);
113
+ if (!group) {
114
+ return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Mapping not found" } });
115
+ }
116
+ deleteMappingGroup(db, group.id);
117
+ return reply.send({ success: true });
118
+ });
119
+ done();
120
+ };
@@ -0,0 +1,7 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface MetricsRoutesOptions {
4
+ db: Database.Database;
5
+ }
6
+ export declare const adminMetricsRoutes: FastifyPluginCallback<MetricsRoutesOptions>;
7
+ export {};
@@ -0,0 +1,41 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getMetricsSummary, getMetricsTimeseries } from "../db/index.js";
3
+ const PeriodEnum = Type.Union([
4
+ Type.Literal("1h"), Type.Literal("6h"), Type.Literal("24h"),
5
+ Type.Literal("7d"), Type.Literal("30d"),
6
+ ]);
7
+ const MetricEnum = Type.Union([
8
+ Type.Literal("ttft"), Type.Literal("tps"), Type.Literal("tokens"),
9
+ Type.Literal("cache_rate"), Type.Literal("request_count"),
10
+ Type.Literal("input_tokens"), Type.Literal("output_tokens"),
11
+ Type.Literal("cache_hit_tokens"),
12
+ ]);
13
+ const SummaryQuerySchema = Type.Object({
14
+ period: Type.Optional(PeriodEnum),
15
+ provider_id: Type.Optional(Type.String()),
16
+ backend_model: Type.Optional(Type.String()),
17
+ router_key_id: Type.Optional(Type.String()),
18
+ });
19
+ const TimeseriesQuerySchema = Type.Object({
20
+ period: Type.Optional(PeriodEnum),
21
+ metric: MetricEnum,
22
+ provider_id: Type.Optional(Type.String()),
23
+ backend_model: Type.Optional(Type.String()),
24
+ router_key_id: Type.Optional(Type.String()),
25
+ });
26
+ export const adminMetricsRoutes = (app, options, done) => {
27
+ app.get("/admin/api/metrics/summary", { schema: { querystring: SummaryQuerySchema } }, async (request, reply) => {
28
+ const query = request.query;
29
+ const period = (query.period ?? "24h");
30
+ const summary = getMetricsSummary(options.db, period, query.provider_id, query.backend_model, query.router_key_id);
31
+ return reply.send(summary);
32
+ });
33
+ app.get("/admin/api/metrics/timeseries", { schema: { querystring: TimeseriesQuerySchema } }, async (request, reply) => {
34
+ const query = request.query;
35
+ const period = (query.period ?? "24h");
36
+ const metric = query.metric;
37
+ const timeseries = getMetricsTimeseries(options.db, period, metric, query.provider_id, query.backend_model, query.router_key_id);
38
+ return reply.send(timeseries);
39
+ });
40
+ done();
41
+ };
@@ -0,0 +1,8 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ interface ProviderRoutesOptions {
4
+ db: Database.Database;
5
+ encryptionKey: string;
6
+ }
7
+ export declare const adminProviderRoutes: FastifyPluginCallback<ProviderRoutesOptions>;
8
+ export {};
@@ -0,0 +1,101 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getAllProviders, getProviderById, createProvider, updateProvider, deleteProvider, getAllMappingGroups } from "../db/index.js";
3
+ import { encrypt } from "../utils/crypto.js";
4
+ import { HTTP_CREATED, HTTP_NOT_FOUND, HTTP_CONFLICT } from "./constants.js";
5
+ const API_KEY_PREVIEW_MIN_LEN = 8;
6
+ const API_KEY_PREVIEW_PREFIX_LEN = 4;
7
+ const CreateProviderSchema = Type.Object({
8
+ name: Type.String({ minLength: 1 }),
9
+ api_type: Type.Union([Type.Literal("openai"), Type.Literal("anthropic")]),
10
+ base_url: Type.String({ minLength: 1 }),
11
+ api_key: Type.String({ minLength: 1 }),
12
+ models: Type.Optional(Type.Array(Type.String())),
13
+ is_active: Type.Optional(Type.Number()),
14
+ });
15
+ const UpdateProviderSchema = Type.Object({
16
+ name: Type.Optional(Type.String({ minLength: 1 })),
17
+ api_type: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("anthropic")])),
18
+ base_url: Type.Optional(Type.String({ minLength: 1 })),
19
+ api_key: Type.Optional(Type.String({ minLength: 1 })),
20
+ models: Type.Optional(Type.Array(Type.String())),
21
+ is_active: Type.Optional(Type.Number()),
22
+ });
23
+ function computeApiKeyPreview(apiKey) {
24
+ if (apiKey.length <= API_KEY_PREVIEW_MIN_LEN)
25
+ return "****";
26
+ return `${apiKey.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${apiKey.slice(-API_KEY_PREVIEW_PREFIX_LEN)}`;
27
+ }
28
+ export const adminProviderRoutes = (app, options, done) => {
29
+ const { db, encryptionKey } = options;
30
+ app.get("/admin/api/providers", async (_request, reply) => {
31
+ const providers = getAllProviders(db);
32
+ return reply.send(providers.map((s) => ({
33
+ id: s.id,
34
+ name: s.name,
35
+ api_type: s.api_type,
36
+ base_url: s.base_url,
37
+ api_key_preview: s.api_key_preview || "****",
38
+ models: JSON.parse(s.models || "[]"),
39
+ is_active: s.is_active,
40
+ created_at: s.created_at,
41
+ updated_at: s.updated_at,
42
+ })));
43
+ });
44
+ app.post("/admin/api/providers", { schema: { body: CreateProviderSchema } }, async (request, reply) => {
45
+ const body = request.body;
46
+ const encryptedKey = encrypt(body.api_key, encryptionKey);
47
+ const apiKeyPreview = computeApiKeyPreview(body.api_key);
48
+ const id = createProvider(db, {
49
+ name: body.name,
50
+ api_type: body.api_type,
51
+ base_url: body.base_url,
52
+ api_key: encryptedKey,
53
+ api_key_preview: apiKeyPreview,
54
+ models: JSON.stringify(body.models ?? []),
55
+ is_active: body.is_active ?? 1,
56
+ });
57
+ return reply.code(HTTP_CREATED).send({ id });
58
+ });
59
+ app.put("/admin/api/providers/:id", { schema: { body: UpdateProviderSchema } }, async (request, reply) => {
60
+ const { id } = request.params;
61
+ const existing = getProviderById(db, id);
62
+ if (!existing) {
63
+ return reply.code(HTTP_NOT_FOUND).send({ error: { message: "Provider not found" } });
64
+ }
65
+ const body = request.body;
66
+ const fields = {};
67
+ if (body.name !== undefined)
68
+ fields.name = body.name;
69
+ if (body.api_type !== undefined)
70
+ fields.api_type = body.api_type;
71
+ if (body.base_url !== undefined)
72
+ fields.base_url = body.base_url;
73
+ if (body.is_active !== undefined)
74
+ fields.is_active = body.is_active;
75
+ if (body.models !== undefined)
76
+ fields.models = JSON.stringify(body.models);
77
+ if (body.api_key) {
78
+ fields.api_key = encrypt(body.api_key, encryptionKey);
79
+ fields.api_key_preview = computeApiKeyPreview(body.api_key);
80
+ }
81
+ updateProvider(db, id, fields);
82
+ return reply.send({ success: true });
83
+ });
84
+ app.delete("/admin/api/providers/:id", async (request, reply) => {
85
+ const { id } = request.params;
86
+ const groups = getAllMappingGroups(db);
87
+ for (const g of groups) {
88
+ try {
89
+ const rule = JSON.parse(g.rule);
90
+ const targets = [rule.default, ...(rule.windows || [])].filter(Boolean);
91
+ if (targets.some((t) => t.provider_id === id)) {
92
+ return reply.code(HTTP_CONFLICT).send({ error: { message: `Provider is referenced by mapping group '${g.client_model}'` } });
93
+ }
94
+ }
95
+ catch { /* rule format invalid, skip */ }
96
+ }
97
+ deleteProvider(db, id);
98
+ return reply.send({ success: true });
99
+ });
100
+ done();
101
+ };
@@ -0,0 +1,9 @@
1
+ import { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ import { RetryRuleMatcher } from "../proxy/retry-rules.js";
4
+ interface RetryRuleRoutesOptions {
5
+ db: Database.Database;
6
+ matcher: RetryRuleMatcher | null;
7
+ }
8
+ export declare const adminRetryRuleRoutes: FastifyPluginCallback<RetryRuleRoutesOptions>;
9
+ export {};